【原理/Java并发】深入理解volatile与MESI

文章目录

  • 1 前言
  • 2 有序性
    • 2.1 编译器层面的内存屏障
    • 2.2 CPU层面的内存屏障
  • 3 可见性
    • 3.1 MESI协议
    • 3.2 Store Buffer 和 Invalid Queue
    • 3.3 解决可见性
  • 4 总结
  • 5 案例分析
    • 5.1 非volatile变量
    • 5.2 volatile变量
  • 附录:如何查看Java运行时的汇编指令

1 前言

要说Java里面并发相关的内容里最复杂的,我觉得就是volatile关键字了。最基本的功能大部分Java程序员都能说出来,就是保证了可见性有序性,但是不保证原子性。

至于原理,看过的各种文章和视频既有说原理是内存屏障的,又有说是lock前缀。本篇结合我看过的文章,从解决可见性和有序性的两方面精炼总结一下volatile从JVM层面到CPU执行的原理。参考文章链接已经插入在本文末尾,看完都有很多收获。

全文主要内容都是基于x86平台。水平有限,有错误还请指出。

2 有序性

指令重排其实是一种提高程序运行效率很有效的方式。编译器和处理器会遵守数据依赖性,保证在单线程执行的情况下程序执行的正确结果(as-if-serial语义)1
比如:

int a = 1;
int b = 2;
a = a + 1;

按原序的话,有可能在给b赋值的时候,缓存中的a失效或者移除,第三句就又需要load一次a。如果交换了后两句,a就可能会少从缓存或内存load一次,又不影响程序最终的执行结果。

但在多线程情况下,有时会因为指令重排出现问题。

指令重排序一般有三种情况:
① 编译器优化重排
Java编译器行为,可以通过JVM层面的规范来解决
② 指令并行重排
由于CPU的多级流水线实现指令并行优化
③ 内存系统重排
CPU内部的Store Buffer和Invalid Queue导致

所以要彻底禁止指令重排序就要从这编译器和CPU两个层面下手。内存屏障解决了有序性和可见性,先说如何解决的有序性。

2.1 编译器层面的内存屏障

Java编译后的字节码是不会体现volatile关键字的,是在JVM字节码解释器实现类bytecodeInterpreter.cpp中对读写变量判断是否被volatile修饰,然后进行不同处理2

其中,对于volatile变量的读写相当于下面这样:

volatile int a;

int x = a; // 读操作 = load_acquire(a)

a = 2; // 写操作 = release_store(a) + storeload()

其中load_acquirerelease_store都使用了C++的volatile关键字,相当于对变量a的存取都直接操作内存而不是缓存(但后面看运行时的汇编指令并没有变化,不知道为什么)。
同时,acquire 等价于 LoadLoad + LoadStore,release 等价于 LoadStore + StoreStore。JVM会在字节码解释时遵守这些屏障。所以,对于volatile变量的读之后JVM插入了LoadLoad + LoadStore;写操作之前插入LoadStore + StoreStore,之后插入StoreLoad。
更加简单的理解就是读前加读屏障,写后加写屏障。

JDK中一共定义了四种内存屏障(在OrderAccess中):loadload() storestore() loadstore()storeload()。JVM(JIT)在运行Java代码时会遵守这些内存屏障,至少保证了在运行时,Java代码不会被JIT优化而重排序。具体这些屏障是如何实现的在不同平台下是不同的。在x86架构且多核下,只有StoreLoad使用了fence()会影响到汇编指令,其他三种加入了编译型屏障compiler_barrier())。

在Unsafe类中也会用到,其中有这三个本地方法:

    public native void loadFence(); // = require() = compiler_barrier()

    public native void storeFence(); // = release() = compiler_barrier()

    public native void fullFence(); // = fence() = lock

2.2 CPU层面的内存屏障

首先快速看下维基百科中对CPU指令层面内存屏障的这段介绍3

内存屏障是一类同步屏障指令,使得 CPU 或编译器在对内存进行操作的时候, 严格按照一定的顺序来执行, 也就是说在内存屏障之前的指令和之后的指令不会由于系统优化等原因而导致乱序。
大多数现代计算机为了提高性能而采取乱序执行,这使得内存屏障成为必须。

语义上,内存屏障之前的所有写操作都要写入内存;内存屏障之后的读操作都可以获得同步屏障之前的写操作的结果。

大多数处理器提供了内存屏障指令。常见的x86/x64,通常使用lock指令前缀加上一个空操作来实现,注意当然不能真的是nop指令,但是可以用来实现空操作的指令其实是很多的,比如Linux中采用的
addl $0, 0 (%esp)

lock前缀指令的几个作用4
① 给缓存行加锁,如果操作数据不在缓存或涉及多个缓存行发送LOCK#信号加总线锁
② 将缓存中的修改的内容写回内存,使其他核心中相关缓存行失效
③ 实现了内存屏障的功能,禁止lock前缀指令两边的指令重排

JDK源码对volatile变量写的操作就是在汇编指令中将lock指令前缀加一个空操作放在了volatile变量写操作之后5,这样也就解决了写volatile变量指令和之后指令的重排。
【原理/Java并发】深入理解volatile与MESI_第1张图片
对于读操作,对应JDK源码,在汇编指令中也没有特殊处理,因为x86架构处理器只允许写-读乱序,所以不用特意保证有序性。例如在下面loadA方法中对于普通变量和volatile变量读写的汇编指令:
非volatile的读:
【原理/Java并发】深入理解volatile与MESI_第2张图片
volatile的读:
【原理/Java并发】深入理解volatile与MESI_第3张图片

x86属于强内存序架构,大部分情况使用编译型屏障就可以保证多线程内存访问的一致性。但是在arm下无法做到这一点。6

至此解决了指令重排的前两种情况。
(第三种情况Store Buffer等结构造成内存系统重排的解决在下面3.2中)

3 可见性

3.1 MESI协议

MESI协议是一种基于缓存失效的一致性控制协议,保证了CPU核心之间缓存一致性,也就保证了可见性(各家CPU厂商也不是全部都完全遵守MESI协议来设计7,比如还有MESIF和MOESI。

本篇就MESI协议进行一个理论上的总结,落实到不同CPU可能还会有不同的情况。本篇中简单介绍一下MESI协议。

缓存行是什么
缓存中数据是以缓存行为单位进行操作的,缓存行大小根据CPU而定,一般是64B,因为内存的传输单位一般就是64B。

MESI就是对CPU缓存中的每一个缓存行标记一个状态,分别是:
① M(Modified被修改的):缓存行是脏的,与主存的值不同。如果别的CPU内核要读主存这块数据,该缓存行必须回写到主存,状态变为共享(S)。
② E(Exclusive独占):缓存行只在当前缓存中,但是干净——缓存数据同于主存数据。当别的缓存读取它时,状态变为共享;当前写数据时,变为已修改状态。
③ S(Shared共享):缓存行也存在于其它缓存中且是干净的。缓存行可以在任意时刻抛弃。
④ I(Invalid失效):缓存行是无效的。8
凭我个人理解大概是这样:
【原理/Java并发】深入理解volatile与MESI_第4张图片
那么,既然有了MESI,为什么还要volatile来保证缓存的可见性呢?
实际上,完全遵守MESI会影响执行效率,所以现在的CPU一般在L1缓存之前还有Store Buffer缓存,有些CPU还会引入Invalid Queue,见下一节。

3.2 Store Buffer 和 Invalid Queue

为了提高执行效率,CPU也采用了异步的方式。简单来说,就是当一个核心要对一个缓存行修改之前,根据MESI,需要等待其他核心确认已经将自己缓存中的这条缓存行设置为Invalid状态才可以写入到自己缓存当中。

Store Buffer 异步
核心之间的消息发送和响应一定是会影响效率的,解决方式就是异步。CPU在获取到其他核心的失效确认前,会先写到Store Buffer这一结构中然后继续处理其他的任务。等到收到其他核心的Invalid Ack失效确认后,空闲时才会逐个写入到自己的缓存当中(x86处理器会以FIFO的顺序来逐个写入)。

Invalid Queue 异步
从上面这段可以看出,其他核心处理来自其他核心的失效请求时,也不应该立即放下手头的任务去设置自己的这条缓存行为Invalid状态。实际上会先放入Invalid Queue,然后直接回复一个Invalid Ack,等到空闲时再设置缓存行状态。(x86处理器没有Invalid Queue,当修改从Store Buffer刷入cache时,总能读到最新值)

带有Store Buffer和Invalid Queue结构的CPU结构大致是这样:
【原理/Java并发】深入理解volatile与MESI_第5张图片

这两种异步行为就有可能会造成实际运行时的指令重排以及可见性问题。建议看这篇文章9,能更好理解Store Buffer和Invalid Queue是如何造成内存系统重排可见性问题的。

3.3 解决可见性

对于修改volatile变量
从2.2节可以知道了对于volatile变量的写入操作会在汇编指令之前加入一条lock前缀的空指令。lock前缀命令保证了volatile变量更新后,缓存中修改过的缓存行立即写出到内存,并使其他核心的相关缓存行失效。这样保证了对volatile变量的写入对其他CPU核心(其他线程)可见。

对于读取volatile变量
对于volatile的读取,在执行的汇编语言中和普通变量相同。因为只要自己缓存中的包含volatile变量的缓存行没有失效,就说明了没有其他线程进行更改,也就说明了是最新值。

4 总结

JVM的四种内存屏障只有StoreLoad屏障是会真正去在CPU层面加入lock指令相当于内存屏障的。其余三种只是插入了编译型屏障,使JIT不会优化重排屏障两侧相应的读写操作。lock前缀指令又禁止了两侧的指令重排;读取又不需要手动禁止指令重排,保证了有序性
StoreLoad在对volatile变量的写之后插入的lock前缀指令,使修改立即写出到内存并让其他缓存中的相关缓存行失效。x86没有Invalid Queue的结构,所以保证了可见性,与普通变量的读取无异。

5 案例分析

5.1 非volatile变量

  1. 编译器优化导致不可见
    首先看这段代码(sleep方法就是调用了Thread.sleep,为了简洁在里面try catch):

    static volatile int a = 0;
    static int b = 0;
    
    public static void main(String[] args) {
        Thread t1 = new Thread(() -> {
            while (b == 0) {
    			/* postion0 */
    		}
        });
    
        Thread t2 = new Thread(() -> {
            b++;
        });
    
        t1.start();
        sleep(100);
        t2.start();
    }
    

    程序是不会停止运行的。
    看起来好像是因为虽然线程t2把b修改为1,但是t1线程在这之前已经读取到了b的值为0,所以t2的修改对t1是不可见的。
    实际上如果按上面这句解释是不符合缓存一致性的。造成t1读取不到t2修改的b的原因是因为JIT优化了上面的循环使b不再进行判断而优化为了while (true)
    只要缩短t2线程启动延时sleep(1)使JIT还没进行优化,或者干脆禁用编译器优化-Xint-Djava.compiler=NONE等等就会发现t1能够读到t2了。

  2. synchronized 保证可见性
    只要在t1线程死循环之中(上文代码中的position0注释位置)加入System.out.println()可以保证下次读取b一定会读取到新值。

    因为如果点进println()方法中,使用了synchronized加锁,而Java内存模型保证在加synchronized锁时要清空线程的工作内存(其实就是CPU的缓存),这样就能使下次读取b一定是新的值。

    在退出synchronized代码块之后也会强制将缓存中修改过的缓存行写出到内存。

  3. Thread.sleep(time)可见性问题
    在睡眠Thread.sleep(time)的代码就会使从睡眠中恢复的线程有非常大概率可以读取到新的值。如下面的代码,主要逻辑是:
    ① 主线程重复一亿次test()(其实很快就会出现睡眠后没有读取到新值的情况,不需要这么多次)
    test()中创建了两个线程,t1中有个循环,进入条件是非volatile变量b等于0,进入循环后sleep1ms,然后记下到目前为止睡了几次;t2在t1读取到b之后将b改为1。如果t1线程在读取到t2修改的b值之前睡眠次数超过1就打印睡眠次数。

    static int b = 0;
    
    public static void main(String[] args) {
        for (int i = 0; i < 1_0000_0000; i++) {
            test();
            b = 0;
        }
    }
    
    private static void test() {
        Thread t1 = new Thread(() -> {
            int i = 0;
            while (b == 0) {
                sleep(2);
                i++;
            }
            if (i > 1) {
                System.out.println("sleep times i: " + i);
            }
        });
    
        Thread t2 = new Thread(() -> b++);
    
        t1.start(); sleep(1); t2.start();
        t1.join(); t2.join();
    }
    

    经过我的测试,有少数测试中会打印sleep times i: 2,而且大部分是睡了两次读到新值,最多也就睡了3次。所以说是非常大概率能使从睡眠中恢复的线程读到其他线程修改的值而无法完全保证。
    可以说明循环中有sleep会阻止JIT优化循环条件,并且也说明了不加volatile的变量是不一定立即被其他线程可见的。

5.2 volatile变量

  1. volatile影响其他普通变量
    对于使用了volatile的变量,每次获取一定是会获取到最新值,这里不再实验。
    但是,对于volatile变量的读写,还会影响到其他的普通变量。比如:
    static volatile int a = 0;
    static int b = 0;
    
    public static void main(String[] args) {
        Thread t1 = new Thread(() -> {
            while (b == 0) {
            	// 在这里插入对于volatile变量的读或写都会使t1线程读取到最新的b值
            	// 从而使程序结束运行
    			a = 1; 或者 int i = a;
    		}
        });
    
        Thread t2 = new Thread(() -> {
            b++;
        });
    
        t1.start();
        sleep(100);
        t2.start();
    }
    
    volatile变量读写都能更新其他变量的值。
    其实是循环过程中操作了volatile变量使JIT不再把b == 0优化为true
  2. volatile修饰引用类型的情况
    首先,下面这种情况是会结束循环的:
    static class Handler {
      int carry = 0;
    }
    static volatile Handler handler = new Handler();
    
    public static void main(String[] args) {
        Thread t1 = new Thread(() -> {
            while (handler.carry == 0) {
                
            }
        });
    
        Thread t2 = new Thread(() -> handler.carry++);
    
        t1.start();
        sleep(100);
        t2.start();
    }
    
    volatile修饰的引用类型的成员变量没有使用volatile,却也对于其他线程可见了。这其实跟5.1.1的情况类似,因为调用了volatile变量的读取使得循环条件没有被优化。
    使用5.1.3的方式来测试handler.carry就会发现volatile修饰的成员引用变量的非volatile成员变量不具有可见性。对于volatile修饰数组,修改里面的元素也是同理,无法保障元素的可见性。
    volatile只是保证这个引用值被修改的时候是可见的。

附录:如何查看Java运行时的汇编指令

Mac下先下载https://github.com/evolvedmicrobe/benchmarks/blob/master/hsdis-amd64.dylib,然后把它放在/Library/Java/JavaVirtualMachines/jdk1.8.0_351.jdk/Contents/Home/jre/lib下,jdk版本看自己的。(Windows自己搜一下吧)

然后去IDEA里(或者用命令也行,我觉得idea方便点)修改你要看汇编指令的类的Run Configurations,在右上角或者右键主方法左侧绿色箭头都可以修改.
【原理/Java并发】深入理解volatile与MESI_第6张图片
【原理/Java并发】深入理解volatile与MESI_第7张图片
然后添加虚拟机参数-XX:+UnlockDiagnosticVMOptions -XX:+PrintAssembly -Xcomp -XX:CompileCommand=compileonly,*VolatileTest.increaseA -XX:+LogCompilation -XX:LogFile=./hotspot.log【原理/Java并发】深入理解volatile与MESI_第8张图片
现在直接再跑一遍就行了。-Xcomp -XX:CompileCommand=compileonly,*VolatileTest.increaseA这个参数后面的VolatileTest.increaseA是你要看的类.方法,静态非静态都行,但一定要运行这个方法,要不啥也没有。


  1. 什么是指令重排序?为什么要重排序? ↩︎

  2. volatile的实现原理 ↩︎

  3. 内存屏障 ↩︎

  4. 汇编指令的LOCK指令前缀 ↩︎

  5. volatile与lock前缀指令 ↩︎

  6. 内存屏障和锁 从X86往ARM平台移植 ↩︎

  7. CPU缓存一致性:从理论到实战 ↩︎

  8. MESI协议 ↩︎

  9. 内存屏障今生之Store Buffer, Invalid Queue ↩︎

你可能感兴趣的:(jvm,java,开发语言)