volatile的可见性和禁止指令重排序怎么实现的?

2,413 阅读6分钟

持续创作,加速成长!这是我参与「掘金日新计划 · 6 月更文挑战」的第18天,点击查看活动详情

《深入理解JAVA虚拟机》中有如下描述:

“观察加入volatile关键字和没有加入volatile关键字时所生成的汇编代码发现,加入volatile关键字时,会多出一个lock前缀指令”

lock前缀指令实际上相当于一个内存屏障(也成内存栅栏) ,内存屏障会提供3个功能:

  1. 它确保指令重排序时不会把其后面的指令排到内存屏障之前的位置,也不会把前面的指令排到内存屏障的后面;即在执行到内存屏障这句指令时,在它前面的操作已经全部完成;
  2. 它会强制将对缓存的修改操作立即写入主存;(每个线程都有自己的工作内存)
  3. 如果是写操作,它会导致其他CPU中对应的缓存行无效。

所以可见性和禁止指令重排序如下:

  • 可见性: volatile的功能就是被修饰的变量在被修改后可以立即同步到主内存,被修饰的变量在每次是用之前都从主内存刷新。本质也是通过内存屏障来实现可见性 写内存屏障(Store Memory Barrier)可以促使处理器将当前store buffer(存储缓存)的值写回主存。读内存屏障(Load Memory Barrier)可以促使处理器处理invalidate queue(失效队列)。进而避免由于Store Buffer和Invalidate Queue的非实时性带来的问题。
  • 禁止指令重排序: volatile是通过内存屏障来禁止指令重排序

内存屏障

是一种屏障指令,它使得CPU或编译器对屏障指令的前和后所发出的内存操作执行一个排序的约束。也叫内存栅栏或栅栏指令 内存屏障的能力∶

  1. 阻止屏障两边的指令重排序
  2. 写数据的时候 加了屏障的话,强制把写缓冲区的数据刷回到主内存中
  3. 读数据的时候 加了屏障的话,让工作内存/CPU高速缓存当中缓存的数据失效,重新到主内存中获取新的数据

基本分类:

  1. 读屏障:Load Barrier ,在读指令之前插入读屏障,让工作内存/CPU高速缓存当中缓存的数据失效,重新到主内存中获取新的数据
  2. 写屏障::Store Barrier ,在写指令之后插入写屏障,强制把写缓冲区的数据刷回到主内存中

重排序和内存屏障

  1. 重排序可能会给程序带来问题,因此,有些时候,我们希望告诉JVM,这里不需要排序

    JVM本身为了保证可见性

  2. 对于编译器的重排序,JMM 会根据重排序的规则,禁止特定类型的编译器重排序

  3. 对于处理器的重排序,Java编译器在生成指令序列的适当位置,插入内存屏障指令,来禁止特定类型的处理器排序

  • 内存屏障类型

    • LoadLoad屏障:对于这样的语句Load1; LoadLoad; Load2,在Load2及后续读取操作要读取的数据被访问前,保证Load1要读取的数据被读取完毕。(1.禁止重排序:Load2及后续读取操作不能在Load1读取操作之前;2.保证Load2去主内存读取数据而不是自己的内存)
    • StoreStore屏障:对于这样的语句Store1; StoreStore; Store2,在Store2及后续写入操作执行前,保证Store1的写入操作对其它处理器可见。
    • LoadStore屏障:对于这样的语句Load1; LoadStore; Store2,在Store2及后续写入操作被刷出前,保证Load1要读取的数据被读取完毕。(只起到禁止重排序的作用,因为读屏障是加在读操作之前强制从主内存读取数据,写屏障加在写操作之后强制将数据写回主内存,而这里恰好反过来,所以只起到重排序作用)
    • StoreLoad屏障:对于这样的语句Store1; StoreLoad; Load2,在Load2及后续所有读取操作执行前,保证Store1的写入对所有处理器可见。它的开销是四种屏障中最大的(只有这个是读写屏障都有)。在大多数处理器的实现中,这个屏障是个万能屏障,兼具其它三种内存屏障的功能

    为什么说:StoreLoadBarriers是最重的?

    重︰就是跟内存交互次数多,交互延迟较大、消耗资源较多

    为什么说StoreLoadBarriers能实现其它Barriers的功能?

    因为就StoreLoadBarriers有重排序+读屏障+写屏障三个功能一起

    扩展∶

    这些屏障指令并不是处理器真实的执行指令,他们只是JMM定义出来的、跨平台的指令。

    因为不同硬件实现内存屏障的方式并不相同,JMM为了屏蔽这种底层硬件平台的不同,抽象出了这些内存屏障指令,在运行的时候,由JVM来为不同的平台生成相应的机器码

    这些内存屏障指令,在不同的硬件平台上,可能会做一些优化,从而只支持部分的JMM的内存屏障指令。 ​ 在x86 机器上,就只有 StoreLoadBarriers是有效的,其它的都不支持,被替换成nop,也就是空操作。

  • JMM内存屏障的策略

    • 在每个 volatile 写操作的前面插入一个 StoreStore 屏障。(A StoreStore B 就相当于B的写不会重排序到A写之前)
    • 在每个 volatile 写操作的后面插入一个 StoreLoad 屏障。
    • 在每个 volatile 读操作的后面插入一个 LoadLoad 屏障。
    • 在每个 volatile 读操作的后面插入一个 LoadStore 屏障。

image.png

上图的StoreStore屏障可以保证在volatile写之前,其前面的所有普通写操作已经对任意处理器可见了

因为StoreStore屏障将保障上面所有的普通写在volatile写之前刷新到主内存

image.png

image.png

典型使用场景DCL:


public class Singleton {
	//volatile是防止指令重排
    private static volatile Singleton singleton;
    // 无参构造
    private Singleton() {}
    public static Singleton getInstance() {
        //第一层判断singleton是不是为null
        //如果不为null直接返回,这样就不必加锁了
        if (singleton == null) {
            //现在再加锁
            synchronized (Singleton.class){
                //第二层判断
                //如果A,B两个线程都在synchronized等待
                //A创建完对象之后,B还会再进入,如果不再检查一遍,B又会创建一个对象
                if (singleton == null) {
                    /*volatile主要是防止这里:
                    下面字节码会生成三个操作:
                    一是为Singleton对象在堆中分配空间
                    二是执行Singleton的构造函数
                    三是将新生成的Singleton对象的引用赋给singleton字段
                    而在重排序之后,上面的顺序有可能编程 一、三、二,那么这对象是残缺不全的--半对象
                    于是,在多线程情况下,别的线程可能会访问到一个singleton不为null却没有执行完构造函数的无效引用
                    */
                    singleton = new Singleton();
                }
            }
        }
        return singleton;
    }
}