Volatile之内存可见性及禁止指令重排序

volatile的两重语义

1.多线程环境下保证内存可见性。
2.多线程环境下禁止指令重排序。
说起保证内存可见性,不得不从java内存模型开始讲起,但是我们先说一下缓存一致性,来帮助我们混淆java内存模型。
Volatile之内存可见性及禁止指令重排序_第1张图片

缓存一致性

之前的文章我们有聊过cpu、核、多级缓存、内存之间大概是怎么工作的,这里我就不画图进行解释了,一般处理器上都会有一套缓存一致性协议来保证缓存的一致性。就以我们常见的处理器来说,通常使用的是MESI协议(Modified Exclusive Shared Invalid),这也代表了缓存数据中缓存行的四种状态,之前也说过缓存行会有两个标志位记录数据是否有效以及描述它的修改状态。


状态 描述
M(Modified) 这行数据有效,数据被修改了,和内存中的数据不一致,数据只存在于本Cache中。
E(Exclusive) 这行数据有效,数据和内存中的数据一致,数据只存在于本Cache中。
S(Shared) 这行数据有效,数据和内存中的数据一致,数据存在于很多Cache中。
I(Invalid) 这行数据无效

在MESI协议中每个缓存会监听其他核的缓存操作,根据其他核缓存操作变更的状态改变自己的对应缓存行的状态。借用网上一张图简单说一下。
Volatile之内存可见性及禁止指令重排序_第2张图片
core0修改了x=5,这时会吧cache line状态变更为M(修改状态),并且讲数据写回内存中,这时其他核监听到了这一变化,会将自己对应的cache line状态修改为I(Invalid 失效状态)。

还有一点就是为了使得cpu被充分的利用,处理器还会对代码进行乱序优化,但是它会保证不影响最终的执行结果。
这块可以简单理解一下,有兴趣的同学可以深入挖掘。

Java内存模型

Java内存模型中会涉及到两个概念
1.主内存

虚拟机规定所有变量都必须产生在主内存中

2.工作内存

Java虚拟机中每个线程都有自己私有的工作内存。线程之间的工作内存是相互独立的。不同线程之间不能相互访问对方的工作内存,线程之间共享内容需要通过主内存完成。
工作内存和主存的交互过程是由Java虚拟机定义了以下八种操作来完成的。

  1. lock(锁定):作用于主内存的变量,一个变量在同一时间只能一个线程锁定,该操作表示这条线成独占这个变量
  2. unlock(解锁):作用于主内存的变量,表示这个变量的状态由处于锁定状态被释放,这样其他线程才能对该变量进行锁定
  3. read(读取):作用于主内存变量,表示把一个主内存变量的值传输到线程的工作内存,以便随后的load操作使用
  4. load(载入):作用于线程的工作内存的变量,表示把read操作从主内存中读取的变量的值放到工作内存的变量副本中(副本是相对于主内存的变量而言的)
  5. use(使用):作用于线程的工作内存中的变量,表示把工作内存中的一个变量的值传递给执行引擎,每当虚拟机遇到一个需要使用变量的值的字节码指令时就会执行该操作
  6. assign(赋值):作用于线程的工作内存的变量,表示把执行引擎返回的结果赋值给工作内存中的变量,每当虚拟机遇到一个给变量赋值的字节码指令时就会执行该操作
  7. store(存储):作用于线程的工作内存中的变量,把工作内存中的一个变量的值传递给主内存,以便随后的write操作使用
  8. write(写入):作用于主内存的变量,把store操作从工作内存中得到的变量的值放入主内存的变量中

如果要把一个变量从主内存传输到工作内存,那就要顺序的执行read和load操作,如果要把一个变量从工作内存回写到主内存,就要顺序的执行store和write操作,对于普通变量来说这些操作可以不是连续的,有可能操作之间间隔比较大,比如线程对变量做了修改,等线程结束了之后才会讲变量写回到主存。入下图所示:
Volatile之内存可见性及禁止指令重排序_第3张图片

volatile的可见性

已经知道了普通变量在JMM是如何在主存和工作内存中协作的,那么volatile修饰的特殊变量时如何保证可见性的,引用书上一段话

只有当线程T对变量V执行的前一个动作是load,线程T对变量V才能执行use动作;同时只有当线程T对变量V执行的后一个动作是use的时候线程T对变量V才能执行load操作。
只有当线程T对变量V执行的前一个动作是assign的时候,线程T对变量V才能执行store动作;同时只有当线程T对变量V执行的后一个动作是store的时候,线程T对变量V才能执行assign动作。
假定动作A是线程T对变量V实施的use或assign动作,动作F是和动作A相关联的load或store动作,动作P是和动作F相对应的对变量V的read或write动作;类似的,假定动作B是线程T对变量W实施的use或assign动作,动作G是和动作B相关联的load或store动作,动作Q是和动作G相对应的对变量W的read或write动作。如果动作A先于B,那么P先于Q。

不知道你看没看懂,反正我是没有看懂,其实这段话的大概意思就是JMM保证了volatile修饰的变量read、load、use和assign、store、write这两组交互命令是顺序且连续的。这么说你还得对照上面的释义看估计也有点恼火,那么最终解释就是:volatile修饰的变量,每次线程使用的时候都会从主存里面获取最新的值,每次修改的时候也会立即反馈到主内存中,通过JMM这几个连续有序的动作,保证了变量对所有线程的可见性,但是无法保证整体操作的原子性
举个栗子:两个线程同时对volatile修饰的变量进行非原子性操作x++,该操作可以分为int temp =x;temp = temp +1,x =temp;
1.线程A将x读取到工作内存中,并且执行temp =x;
2.线程B将x读取到工作内存中,执行完毕x++操作,并且写会主存,这时会导致线程A中x缓存对应缓存行失效;
3.如果此时线程A要使用x则需要从主存中重新读取,但是此时temp已经记录了x之前的值,直接使用temp进行temp = temp +1,然后执行x =temp的赋值操作,所以这时会将x值再次写回主存,导致主存中x的值发生错误。

volatile禁止指令重排序

下面我们从一个著名的DCl(double check lock)来讲解这个问题,先看代码

class Singleton {
    private static Singleton instance;
    private Singleton(){}
    public static Singleton getInstance() {
        if ( instance == null ) { //当instance不为null时,仍可能指向一个“被部分初始化的对象”
            synchronized (Singleton.class) {
                if ( instance == null ) {
                    instance = new Singleton();
                }
            }
        }
        return instance;
    }
}

这个看似安全的单例模式,在竞态条件下会出现一个半初始化对象的问题。那么我们要从创建单例对象这个语句说起了,这个语句实际在jvm是分成三步完成的,上伪代码,这一段代码在jvm进行优化的时候会乱序,有可能会变成1、3、2的执行顺序

instance = new Singleton();
//这个new 并且赋值的语句在jvm中其实可以抽象成三条指令
memory = allocate();    //1:给对象开辟一块内存
initInstance(memory);   //2:初始化对象
instance = memory;      //3:instance指向分配好的内存

当线程A执行到对象引用执行分配好的内存时,这时对象还未初始化,线程B此时调用getInstance()方法,判断引用已经不为null,因此直接返回,此时对象是半初始化状态,使用会导致异常出现。
解决该问题的方法可以使用volatile修饰成员变量instance,volatile可以通过内存屏障防止上述的指令重排序问题。
硬件层面的内存屏障分为Load Barrier 和 Store Barrier即读屏障和写屏障。

对于Load Barrier来说,在指令前插入Load Barrier,可以让高速缓存中的数据失效,强制重新从主内存加载数据。
对于Store Barrier来说,在指令后插入Store Barrier,能让写入缓存中的最新数据更新写入主内存,让其他线程可见。

下面是基于JMM内存屏障的插入策略:
1.在每个volatile写操作的前面插入一个storestore屏障。
2.在每个volatile写操作的后面插入一个storeload屏障。
3.在每个volatile读操作的后面插入一个loadload屏障。
4.在每个volatile读操作的后面插入一个loadstore屏障。

你可能感兴趣的:(java)