![条理结构](http://www.eleven-smile.com/usr/uploads/2022/03/4061085140.png)

一、volatile简介

volatile是Java虚拟机提供的轻量级的同步机制。

二、原理

1、可见性

volatile 的实现原理是在执行变量写操作后执行 lock 指令,这个指令会将变量实时写入内存而不是处理器的内存缓冲区,然后其他处理器通过缓存一致性协议嗅探到这个变量的变更,将该变量的缓存设为失效,从而实现可见性

2、有序性

JMM中,通过内存屏障实现,具体如下:

  • 在每个volatile写之前插入一个storestore屏障,防止volatile写和上面的其他写重排序;

  • volatile写之后插入一个storeload内存屏障,防止上面volatile写和下面的读操作重排序;

  • volatile读之后插入loadloadloadstore内存屏障,防止上面的volatile读和下面的普通读,volatile写和普通写重排序; 从而实现有序性

还有根据happens-before原则,每个volatilehappens-before于后续对这个变量的读

三、线程可见性问题

描述: 指当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看得到修改的值。 即及时感知,类似于数据库层面的脏读。见下述代码例子。

例一:

预期效果是:双线程对i=0,进行i++操作,最终结果为2

public class Thread4Volatile{
    public static int i = 0;


    public static void main(String[] args) {
        Thread t1 = new Thread(Thread4Volatile::add,"t1");
        Thread t2 = new Thread(Thread4Volatile::add,"t2");
        t1.start();
        t2.start();
    }
    public static void add(){
        i++;
        System.out.println(Thread.currentThread().getName()+"----"+i);
    }

}

代码执行结果:

第一次:
t2----2
t1----2
第二次:
t1----1
t2----2
第三次:
t2----1
t1----2

例二:

预期效果是:runFlag标识变更,中断线程。

public class Test4Volatile2 {
    private static boolean runFlag = true;

    public static void main(String[] args) {
        Thread t1 = new Thread(() -> {
            int i = 0;
            while (runFlag) {
                i++;
            }
        });
        System.out.println("begin");
        t1.start();
        try {
            TimeUnit.MILLISECONDS.sleep(100);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        //停止
        runFlag = false;
    }
}

执行结果: 线程一直在执行,未中断。

综述:

上述两个例子,可以得出:多线程的可见性问题。,如:例二,main线程将变量runFlag的值的变化,从而不再执行任何操作。由true改为false,预期效果t1线程会感知到变量runFlag的值的变化,从而不再执行任何操作。但是从实际结果来看,未达到预期效果,即main线程和t1线程都用的runFlag变量,main线程对其修改,t1线程对其感知不到已修改,就是因为线程的可见性问题导致的。

解决方案: 这里不列举例一,针对例二 列出几种解决方案 第一种:添加volatile,禁止底层优化(活性失败)

// 添加volatile 可见性
private volatile static boolean runFlag = true;

public static void main(String[] args) {
    Thread t1 = new Thread(() -> {
        int i = 0;
        while (runFlag) {
            i++;
        }
    });
    System.out.println("begin");
    t1.start();
    try {
        TimeUnit.MILLISECONDS.sleep(100);
    } catch (InterruptedException e) {
        e.printStackTrace();
    }
    //停止
    runFlag = false;
}
/**
 * @author eleven
 * @date 2022年02月11日 15:58
 */
public class Test4Volatile2 {
    private static boolean runFlag = true;
    private volatile static int i = 0;

    public static void main(String[] args) {
        Thread t1 = new Thread(() -> {
            while (runFlag) {
                i++;
                /*try {
                    Thread.sleep(0);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }*/
                /*System.out.println(i);*/
            }
        });
        System.out.println("begin");
        t1.start();
        try {
            TimeUnit.MILLISECONDS.sleep(100);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        //停止
        runFlag = false;
    }
}

第二种:VM options配置

通过VM options 配置化(-Djava.compiler=NONE)的方式。 VM options

第三种:Thread.sleep方法

/**
 * @author eleven
 * @date 2022年02月11日 15:58
 */
public class Test4Volatile2 {
    private static boolean runFlag = true;

    public static void main(String[] args) {
        Thread t1 = new Thread(() -> {
            int i = 0;
            while (runFlag) {
                i++;
                // 这里添加了 sleep方法
                try {
                    Thread.sleep(0);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        });
        System.out.println("begin");
        t1.start();
        try {
            TimeUnit.MILLISECONDS.sleep(100);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        //停止
        runFlag = false;
    }
}

第四种:System.out.println方法

/**
 * @author eleven
 * @date 2022年02月11日 15:58
 */
public class Test4Volatile2 {
    private static boolean runFlag = true;

    public static void main(String[] args) {
        Thread t1 = new Thread(() -> {
            int i = 0;
            while (runFlag) {
                i++;
                // 这里添加了System.out.println方法
                System.out.println(i);
            }
        });
        System.out.println("begin");
        t1.start();
        try {
            TimeUnit.MILLISECONDS.sleep(100);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        //停止
        runFlag = false;
    }
}

综述: 以上列举的四种解决方法

  • 第一种:添加volatile,禁止底层优化(活性失败) 解析: JVM层面有个深度编译,如JIT深度优化,专业术语称为活性失败。 相当于将例二的代码由 while(runFlag) 调整为 while(true), 未加volatile关键字之前: while循环条件的runFlag变量一直为true,当循环一定次数后,触发了JIT即时编译功能,从而死循环。 加了volatile关键字后: 相当于禁止了该优化。

  • 第二种:VM options配置 解析: 禁止JIT

  • 第三种:Thread.sleep方法 解析: 从代码上看,虽然是睡了0秒,但是底层会切换CPU时间片。会重新加载runFlag变量的值

  • 第四种:System.out.println方法 解析: IO操作,通过该方法的源码查看,其内部是含有synchronized同步锁操作

public void println(int x) {
    synchronized (this) {
        print(x);
        newLine();
    }
}

四:剖析线程可见性

CPU性能优化的博弈之路

1、CPU的提升

已知:在计算机高速发展的时代,CPU、磁盘、内存等硬件设施不断迭代更新。在单线程时代,向一个变量写入了一个值,然后在没有写干涉的情况下读取值,这时读取的值是正确的,是之前写操作的值。但是随着硬件CPU的升级(为了充分利用CPU资源,不像之前那样在读/写的时候等待这种情况),就有了多线程。在多线程环境下,读写操作应用在多个不同的线程中,就会出现,读线程不能及时的读取到其他线程写入的最新值,这就是多线程引发的线程可见性。

比如例二,while循环读取变量runFlag ,数据和程序都放在了磁盘/内存中,运行的时候通过CPU ,由于CPU的运算速度较之前两者的IO速度非常快,所以就会有当CPU等待内存的响应返回数据,处于阻塞状态,造成资源浪费。

程序指令执行

由此可见: 为了能够使CPU的资源充分利用,

  • 硬件层面上,增加了高速缓存;
  • OS层面上 增加了进程、线程,->CPU时间片的切换,使得CPU的利用率大大提升;
  • 编译器层面上 JVM的深度优化

而这些的种种优化,带来了一些问题,正是线程安全性问题的根源

2、CPU高速缓存

上文说到:因为CPU的运算速度较之内存的IO操作非常快,所以就会造成CPU处于等待内存响应结果时,故而增加缓存方式。 CPU高速缓存流程图

基于现流行的window x86平台的CPU高速缓存图

当代CPU架构基本都是三层架构,这是基于访问速度和容量成本做出的权衡

从L1到L3到Main Memory,访问速度相对变慢,存储变大

L1、L2基本都是被单核独享,L3被插槽上的CPU的所有核共享

当CPU运算需要数据时,先从L1上要,L1上没有就到L2上要,L2上没有就到L3上要,直到Main Memory,硬盘,越远就越耗时。 本机CPU缓存

CPU高速缓存

注意上图的总线,这里是因为CPU增加了高速缓存后,引发了缓存一致性问题。如例二的增加高速缓存后的执行流程图,内存中runFlag=true缓存到高速缓存中runFlag=true,两个线程读取到runFlag=true并发),CPU0执行runFlag=false,而CPU1的注意上图的总线,这里是因为CPU增加了高速缓存后,引发了缓存一致性问题。如例二的增加高速缓存后的执行流程图,内存中runFlag=true缓存到高速缓存中runFlag=true,两个线程读取到runFlag=true并发),CPU0执行runFlag=false,而CPU1runFlag依旧是true 两者信息不同步或不对等。 缓存一致性问题

3、缓存行Cache Line

这里引申一个缓存行的概念。 缓存行(Cache Line):高速缓存的最小单元,一次从内存中读取的数据大小。常用的 Intel 服务器 Cache Line 的大小通常是 64 字节。 详情参考存储器 - 高速缓存(CPU Cache):为什么要使用高速缓存

4、伪共享False Sharing

上文提到缓存行的64字节,但是在x86平台 ,JAVA中基本类型如int 是4 字节,不够64字节。随之有了伪共享问题

假设:有两个线程分别访问并修改X和Y这两个变量(可以想象是一个int数组之类),X和Y恰好在同一个缓存行上,这两个线程分别在不同的CPU上执行。那么每个CPU分别更新好X和Y时将缓存行刷入内存时,发现有别的修改了各自缓存行内的数据,这时缓存行会失效,从L3中重新获取。这样的话,程序执行效率明显下降。为了减少这种情况的发生,其实就是避免X和Y在同一个缓存行中,可以主动添加一些无关变量将缓存行填充满,比如在X对象中添加一些变量,让它有64 Byte那么大,正好占满一个缓存行。这种称之为对其填充伪共享

伪共享简介、缓存失效、例子描述、解决方案、JAVA6/7/8中的解决方案,以及在实际开发中如何应对伪共享 等等问题 都可以参考借鉴 伪共享参考资料1 伪共享参考资料2

5、缓存一致性问题

每个CPU的处理过程是,先将计算需要用到的数据缓存在 CPU高速缓存中,在CPU进行计算时,直接从高速缓存中读取数据并且在计算完成之后写入到缓存中。在整个运算过程完成后,再把缓存中的数据同步到主内存。 由于每个线程可能会运行在不同的CPU内, 并且每个线程拥有自己的高速缓存。同一份数据可能会被缓存到多个 CPU中,如果在不同CPU中运行的不同线程看到同一份内存的缓存值不一样就会存在缓存不一致的问题。

故而缓存的一致性问题,不是多处理器导致,而是多缓存导致的。

6、总线嗅探机制

在上文2、CPU高速缓存图 看到了一个L3 和主内存 之间有一个总线。对于可见性问题我们可以通过加锁或者volatile方式解决,我们当然也可以给总线加锁,即总线锁,也可以对缓存加锁,即缓存锁。

总线锁 在多 cpu 下,当其中一个处理器要对共享内存进行操作的时候,在总线上发出一个 LOCK# 信号,这个信号使得其他处理器无法通过总线来访问到共享内存中的数据,总线锁定把 CPU 和内存之间的通信锁住了,这使得锁定期间,其他处理器不能操作其他内存地址的数据,总线锁定的开销比较大,这种机制显然是不合适的。

总线锁的力度太大了,最好的方法就是控制锁的保护粒度,只需要保证对于被多个 CPU 缓存的同一份数据是一致的就可以了。所以引入了缓存锁。

缓存锁 相比总线锁,缓存锁即降低了锁的力度。核心机制是基于缓存一致性协议来实现的。

为了保证各个处理器的缓存是一致的,就会实现缓存一致性协议,而嗅探是实现缓存一致性的常见机制

嗅探机制工作原理: 每个处理器通过监听在总线上传播的数据来检查自己的缓存值是不是过期了,如果处理器发现自己缓存行对应的内存地址修改,就会将当前处理器的缓存行设置无效状态,当处理器对这个数据进行修改操作的时候,会重新从主内存中把数据读到处理器缓存中。

7、缓存一致性协议

为了达到数据访问的一致,需要各个处理器在访问缓存时遵循一些协议,在读写时根据协议来操作,常见的协议有 MSI、MESI、MOSI 等。最常见的就是 MESI 协议。

MESI表示缓存行的四种状态,分别是

  • M(Modify) 表示共享数据只缓存在当前CPU缓存中,并且是被修改状态,也就是缓存的 数据和主内存中的数据不一致
  • E(Exclusive) 表示缓存的独占状态,数据只缓存在当前CPU缓存中,并且没有被修改
  • S(Shared) 表示数据可能被多个CPU缓存,并且各个缓存中的数据和主内存数据一致
  • I(Invalid) 表示缓存已经失效

这些状态本身是静态的,那么动态来看,又是如何产生状态变化的呢?

首先不同CPU之间也是需要沟通的,这里的沟通是通过在消息总线上传递message实现的。这些在总线上传递的消息有如下几种:

Read :带上数据的物理内存地址发起的读请求消息; Read Response:Read 请求的响应信息,内部包含了读请求指向的数据; Invalidate:该消息包含数据的内存物理地址,意思是要让其他如果持有该数据缓存行的 CPU 直接失效对应的缓存行; Invalidate Acknowledge:CPU 对Invalidate 消息的响应,目的是告知发起 Invalidate 消息的CPU,这边已经失效了这个缓存行啦; Read Invalidate:这个消息其实是 Read 和 Invalidate 的组合消息,与之对应的响应自然就是一个Read Response 和 一系列的 Invalidate Acknowledge; Writeback:该消息包含一个物理内存地址和数据内容,目的是把这块数据通过总线写回内存里。

缓存一致性协议流程

local read和local write分别代表本地CPU读写。remote read和remote write分别代表其他CPU读写 状态流转

五、CPU层面的指令重排序

1、例子:

public class Test4Volatile3 {
    private static int a,b=0;
    private static int x,y=0;

    public static void main(String[] args) throws InterruptedException {
        int i = 0;
        for (;;) {
            i++;
            x=0;y=0;
            a=0;b=0;
            Thread t1 = new Thread(()->{
                a=1;
                x=b;
            });
            Thread t2 = new Thread(()->{
                b=1;
                y=a;
            });
            t1.start();
            t2.start();
            t1.join();
            t2.join();

            if(x==0&&y==0){
                System.out.println("第"+i+"次"+x+","+y);
                break;
            }
        }
    }
}

分析: 首先看代码示例,我们不难猜出,x和y的值有哪些情况呢 1、x=0,y=1; 2、x=1,y=0; 3、x=1,y=1;

  • 情况1: 当t1线程先执行,此时a=1,x=b=0; 故x=0;然后t2线程执行,此时b=1,y=a=1;故y=1; 综述:x=0,y=1;
  • 情况2: 当t2线程先执行,此时b=1,y=a=0; 故y=0;然后t2线程执行,此时a=1,x=b=1;故x=1; 综述:x=1,y=0;
  • 情况3: t1 t2并行或并行一部分代码,此时t1执行到a=1;t2执行到b=1;然后再t1执行x=b=1;t2执行y=a=1;综述:x=1,y=1;

思考: 那么有没有可能x,y都为0呢?假如我们把t1线程和t2线程的代码调整位置。

Thread t1 = new Thread(()->{
    //调整前
    //a=1;
    //x=b;
    //调整后
    x=b;
    a=1;
});
Thread t2 = new Thread(()->{
    //调整前
    //b=1;
    //y=a;
    //调整后
    y=a;
    b=1;
});

这样调整后的代码就可以看到x,y为0的情况了吧。

代码实际执行的结果:

第88457次0,0

发现竟然有x,y都为0的情况。 这是为什么呢。

这就是因为指令重排序导致的。将示例中的a=1;x=b;变成了x=b;a=1;

2、指令重排序的类型

在执行程序时为了提高性能,编译器和处理器常常会对指令做重排序。重排序分三种类型:

  1. 编译器优化的重排序 编译器在不改变单线程程序语义的前提下(代码中不包含synchronized关键字),可以重新安排语句的执行顺序。

  2. 指令级并行的重排序 现代处理器采用了指令级并行技术(Instruction-Level Parallelism, ILP)来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。

  3. 内存系统的重排序。 由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去可能是在乱序执行。 从java源代码到最终实际执行的指令序列,会分别经历下面三种重排序:

源代码-->编译器优化的重排序-->指令级并行的重排序-->内存系统的重排序-->最终实际执行的指令序列

上述的1属于编译器重排序,2和3属于处理器重排序。这些重排序都可能会导致多线程程序出现内存可见性问题。对于编译器,JMM的编译器重排序规则会禁止特定类型的编译器重排序(不是所有的编译器重排序都要禁止)。对于处理器重排序,JMM的处理器重排序规则会要求java编译器在生成指令序列时,插入特定类型的内存屏障(memory barriers,intel称之为memory fence)指令,通过内存屏障指令来禁止特定类型的处理器重排序(不是所有的处理器重排序都要禁止)。 JMM属于语言级的内存模型,它确保在不同的编译器和不同的处理器平台之上,通过禁止特定类型的编译器重排序和处理器重排序,为程序员提供一致的内存可见性保证。

指令重排序CPU空闲状态

3、Store Buffers

上图看到CPU0执行写操作时,会发送失效通知到CPU1,在等待CPU1响应的这段时间,CPU0一直处于空闲/延迟状态,这对于频率很高的CPU来说,简直不能接受!。解决这类不必要的延迟(stall)的一个方案就是在CPU和Cache之间加一个Store Buffer: CPU可以先将要写入的数据写到Store Buffer,然后继续做其它事情。等到收到其它CPU发过来的Cache Line(Read Response),再将数据从Store Buffer移到Cache Line。结构如下所示:

故而,这里引入了Store Buffers。一种异步设计的思想,避免CPU同步等待响应结果。

4、CPU层面如何导致指令重排序的原因详解

上面我们了解了 CPU层面的指令重排序以及Store Buffer。但其实在引入Store Buffer后依然有问题,见下述例子分析。

例子:

int a,b=0;
cpuDo(){
    a=1;
    b=a+1;
    assert(b==2);
}

在多线程情况下,断言的结果可能为false。因为可能会出现a=1;b=a+1;的指令执行顺序,变成了b=a+1;a=1;,b的值为1,故断言结果为false。 执行流程图:

执行流程图

  1. a=0,CPU2和别的CPU都读取到Cache中 状态为Shared共享。(注意CPU1没有读取到缓存行)CPU1发起修改 a=1操作,会先写入到Store Buffer,然后发送失效指令(Read Invalidate)到CPU2。 CPU2收到Read Invalidate,返回给CPU1,此时返回Read Response(CPU2中缓存行的数据,即a=0)以及Invalidate ack。

  2. CPU1将a=0 放入缓存行。是E独占状态。

  3. 执行b=a+1命令,因为没有b变量的值,所以CPU1先从内存中 read invalidate b,b=0放入缓存行, 此时CPU2还未读取,CPU1中b=0是E独占状态。

  4. 执行b=a+1命令,因为缓存行a=0 所以b=a+1 即 b= 0+1。因为是E独占状态 ,不需要触发发送失效命令到别的CPU缓存行,b=0到b=1,由E独占变更M修改状态。

  5. CPU1收到CPU返回的a=1,由a=0到a=1, 缓存行E独占状态变更 M修改状态。 此时因为b=a+1命令已经执行完了。所以最终a =1 ,b =1。断言false

综述: 可以看出因为发送失效命令异步机制导致的信息没有及时同步。 表面看:先执行了 b= a+1 ,后执行了a=1。

造成这个问题的根源在于对同一个CPU存在对a的两份拷贝,一份在Cache,一份在Store Buffer,前者用于读,后者用于写,因而出现CPU执行顺序与程序顺序(Program Order)不一致(先执行了b=a+1,再执行a=1)。

从执行流程图看,CPU都是从缓存行加载数据(a=0),如果从Store Buffer 加载数据(a=1)呢?所以引申了CPU的另一种递进的优化策略。 Store Forwarding。

5、Store Forwarding

引入store buffer之后又带了新的问题,单个 CPU 在顺序执行指令的过程中,有可能出现,前面的已经执行写入变更,但对后面的代码逻辑不可见。

因为Store Buffer可能导致破坏程序顺序(Program Order)的问题,硬件工程师在Store Buffer的基础上,又实现了”Store Forwarding”技术: CPU可以直接从Store Buffer中加载数据,即支持将CPU存入Store Buffer的数据传递(forwarding)给后续的加载操作,而不经由Cache。结构如图:

Store Forwarding

例子:

int a,b=0;
//CPU1执行
cpu1Do(){
    a=1;
    b=1;
}
//CPU2执行
cpu2Do(){
    while(b==1){
        assert(a==1);
    }
}

执行流程图:

剖析步骤: 假设初始状态下,a=0; b=0;,b存在于CPU1的Cache中,a存在于CPU2的Cache中,均为Exclusive独享状态,CPU1执行cpu1Do方法,CPU2执行cpu2Do方法,上面代码的预期显然为断言为true。实际:while(b==1) 是true,assert(a==1) 是false。那么来看下执行步骤:

  1. CPU2执行b==1命令,此时b不在缓存行,因为b==1是读操作,发出 read b;

  2. CPU1执行a=1命令,此时a不在缓存行,因为a=1是写操作,发出 read Invalidate a;

  3. CPU1执行b=1命令,此时b在缓存行,并b=0是独享状态,由b=0变b=1,独享状态变更为修改状态。

  4. CPU2收到了b的值,因为b的值已经从0变为1,收到了b=1;

  5. CPU2将b=1 写入缓存行 状态为共享;while(b==1) 已经为true 满足条件。

  6. 因为CPU2缓存行的数据a=0,所以assert(a==1) false

  7. CPU2收到CPU1发出的失效指令,将a=0由独享状态变更无效状态,因为失效指令是异步的,,在第六步,读取的本地缓存值。信息没有同步(已经晚了)。

  8. CPU2返回CPU1 read a(a=0)以及 Invalidate ack。

  9. CPU1 将 store buffer 数据同步到缓存行 a=1 状态为修改状态。

思考: 步骤2为什么 发出的是read Invalidate 而非 Invalidate 呢?

出现这个问题的原因在于CPU不知道a, b之间的数据依赖,CPU0对a的写入走的是Store Buffer(有延迟),而对b的写入走的是Cache,因此b比a先在Cache中生效,导致CPU1读到b=1时,a还存在于Store Buffer中。

六、内存屏障

上文引申的数据依赖问题,很难从硬件层面优化,因为CPU不可能知道哪些值是相关联的,因此硬件工程师提供了一个叫内存屏障的东西,开发者可以用它来告诉CPU该如何处理值关联性。 CPU层面不知道,什么时候不允许优化,什么时候允许优化。

  1. 读屏障 (lfence) l -> load

  2. 写屏障 (sfence) s -> save

  3. 全屏障 (mfence) m -> mix

linux 层面,做了封装: smp_wmb: 写屏障方法 smp_rmb: 读屏障方法 smp_mb: 读写屏障方法

我们可以在a=1和b=1之间插入一个内存屏障:

例子:

int a,b=0;
//CPU1执行
cpu1Do(){
    a=1;
    //添加内存屏障
    smp_mb()
    b=1;
}
//CPU2执行
cpu2Do(){
    while(b==1){
        assert(a==1);
    }
}

还是之前那个例子,差异点:这里添加了内存屏障。 当CPU看到内存屏障smp_mb()时,会先刷新当前(屏障前)的Store Buffer,然后再执行后续(屏障后)的Cache写入。这里的”刷新Store Buffer”有两种实现方式: 一是简单地刷新Store Buffer(需要挂起等待相关的Cache Line到达),二是将后续的写入也写到Store Buffer中,直到屏障前的条目全部应用到Cache Line(可以通过给屏障前的Store Buffer中的条目打个标记来实现)。这样保证了屏障前的写入一定先于屏障后的写入生效,第二种方案明显更优,以第二种方案为例:

执行流程:

差异点: 第三步,CPU1看到smp_mb()内存屏障,它会标记当前Store Buffer中的所有条目(即a = 1被标记)

第四步,CPU1执行b = 1,尽管b已经存在在Cache中(Exclusive),但是由于Store Buffer中还存在被标记的条目,因此b不能直接写入,只能先写入Store Buffer中

七、Invalid Queue

1、问题引申

Store Buffer本身是很小的,所有写入操作的Cache Missing都会使用Store Buffer,特别是出现内存屏障时,后续的所有写入操作(不管是否Cache Miss)都会挤压在Store Buffer中(直到Store Buffer中屏障前的条目处理完),因此Store Buffer很容易会满,当Store Buffer满了之后,CPU还是会卡在等对应的Invalid Ack以处理Store Buffer中的条目。这就导致 CPU 依然存在的空等(stall)现象。

因此还是要回到Invalid Ack中来,Invalid Ack耗时的主要原因是CPU要先将对应的Cache Line置为Invalid后再返回Invalid Ack,一个很忙的CPU可能会导致其它CPU都在等它回Invalid Ack。

CPU 设计者的思路是同步变异步,在发出Invalidate 到 收到Invalidate ack 这个过程是同步的。

所以为了尽可能减少Invalidate ack 的时延,以减少CPU的无谓等待。目前的方案是,CPU不必要处理了Cache Line之后才回Invalid Ack,而是可以先将Invalid消息放到某个请求队列Invalid Queue,然后就返回Invalid Ack。CPU可以后续再处理Invalid Queue中的消息,大幅度降低Invalid Ack响应时间。

此时的CPU Cache结构图如下:

Invalid Queue

Invalid Queue有两个问题要考虑,

  1. 问题一是CPU在处理任何Cache Line的MSEI状态前,都必须先看Invalid Queue中是否有该Cache Line的Invalid消息没有处理。若有,则取处理。这一点在CPU数据竞争不是很激烈时是可以接受的。但是在极端情况下就是上文提到的伪共享问题了。

问题二是它也增加了破坏内存一致性的可能。即可能破坏上文提到的内存屏障。

执行流程:

差异点: CPU2收到CPU1的Invalidate a消息,将其放入Invalidate Queue,返回Invalidate Ack

八、其他补充

1、JMM

在不同的CPU架构中,为了避免因为指令重排序、或者缓存一致性问题,都提供了不同的内存屏障指令。同时,在不同的操作系统中,也都会实现封装一个内存屏障的实现。 那么,我们写的Java线程,如何能够在不同的硬件、不同操作系统下,仍然能够保证线程安全性呢?这就要引出JMM(Java 内存模型),它就是为了屏蔽操作系统和硬件的差异,让一套代码在不同平台下都能达到线程安全的访问目的

那什么是JMM呢? 首先,我们都知道Java程序是运行在Java虚拟机上的,同时我们也知道,JVM是一个跨语言跨平台的实现,也就是Write Once、Run Anywhere。 那么JVM如何实现在不同平台上都能达到线程安全的目的呢?所以这个时候JMM出来了,Java内存模型(Java Memory Model ,JMM)就是一种符合内存模型规范的,屏蔽了各种硬件和操作系统的访问差异的, 保证了Java程序在各种平台下对内存的访问都能保证效果一致的机制及规范

2、Java 中的 volatile

VM 是如何实现自己的内存屏障的?抽象上看 JVM 涉及到的内存屏障有四种:

LoadLoad:两个 Load 操作之间内存屏障,smp_rmb 就是典型实现; StoreStore:两个Store 操作之间的内存屏障,smp_wmb 典型实现; LoadStore:在 Load 操作和 Store 操作之间的内存屏障; StoreLoad:在 Store 操作和 Load 操作之间的内存屏障。

默认情况下,普通读写随机组合都是不会出现内存屏障的; volatile 读 与之前的普通读写操作之间不存在内存屏障; volatile 写 与之后的普通读写操作之间不存在内存屏障; 同步块入口(monitor enter)和 volatile 读 等价; 同步块出口(monitor exit)和 volatile 写 等价;

JVM 是如何分别插入上面四种内存屏障到指令序列之中的呢?这里的设计相当巧妙。

对于 volatile 读 or monitor enter

int t = x; // x 是 volatile 变量
[LoadLoad]
[LoadStore]
<other ops>

对于 volatile 写 or monitor exit

<other ops>
[StoreStore]
[LoadStore]
x = 1; // x 是 volatile 变量
[StoreLoad] // 这里带了个尾巴

借助编译器分别对上面两种情况将内存屏障插入到指令序列中。这种看似简洁不拖沓的实现,堪称精妙。

3、为什么 volatile 写不具备原子性?

volatile 变量写入并不保证线程安全,也不具备原子性。原因很简单,在执行内存屏障之前,不同 CPU 依旧可以对同一个缓存行持有,一个 CPU 对同一个缓存行的修改不能让另一个 CPU 及时感知,因此出现并发冲突。线程安全还是需要用锁来保障,锁能有效的让 CPU 在同一个时刻独占某个缓存行,执行完并释放锁后,其他CPU才能访问该缓存行。

锁既能保证线程安全又能保证内存可见,而 volatile 只能保证内存可见。