深入理解volatile

3,383 阅读8分钟
在理解volatile之前,我们先来看下CPU的工作模式:



处理器这种工作产生的问题:
1、所有的变量在处理器运算期间都是变量对应值的一个副本,其它处理器无法感知其对变量的操作。
2、处理器为了高效利用寄存器而对指令的重排在多线程下将会产生无法预测的结果。
3、不同的处理器针对同一套编码所产生的指令会有不同的运行策略。

为了解决上述三个问题JVM为了保证每个平台代码运行结果的一致性提出了JMM(JAVA内存模型),目的是为了让Java程序在各种平台下都能达到一致性的结果。

JMM规范:
Happen-Before原则:
1、程序顺序原则:一个线程内保证语意的串行化
2、volatile规则:volatile变量的写先发生于读,这保证了volatile变量的可见性
3、锁规则:解锁必然发生于加锁前
4、传递性:A先于B,B先于C,A一定先于C
5、线程的start()方法先于它的每一个动作
6、线程的所有动作,先于线程的终结
7、线程的中断先于被中断的代码
8、对象的构造函数执行、结束先于finalize()方法

针对volatile的优化:
volatile能保证修改对其它线程可见。即修改了共享变量后肯定会刷回主内存,通知其它线程,但是为了使处理器的内部单元高效工作,处理器会对输入的代码进行乱序即指令重排。对于volatile如果不做针对性的处理,那显然volatile的可见性并不会有什么意义。并不能保证结果的确定性。
针对volatileJVM做了大量的工作:
关于工作内存(针对硬件就是高速缓存)JMM定义了8种操作来完成:
  • lock(加锁): 作用于主内存,把一个变量标记为线程独占。
  • unlock(解锁):作用于主内存,把一个已锁定的变量释放出来。
  • read(读取):作用于主内存,将一个变量从主内从中传输到工作内存中,以便随后的load。
  • load(载入):作用于工作内存,把read操作得到的变量放在工作内存的变量副本中。
  • use(使用):作用于工作内存,把工作内存中的一个变量传递给执行引擎。
  • assign(赋值):作用于工作内存,把一个执行引擎接受的值赋值给工作内存的变量。
  • store(存储):作用于工作内存,把工作内存中的一个变量的值传输到主内存,以便后续的write操作。
  • write(写入):作用于主内存,把store操作从工作内存得到的值放回主内存中。
8中操作有如下关系:
  • 不允许load和read,store和write单独出现。
  • 不允许一个线程丢弃它最近的assign操作,即变量在工作内存中改变,必须同步回主内存。
  • 不与许一个线程无原因的(没有assign操作)把数据从工作内存同步回主内存。
  • 一个新的变量只能在主内存中诞生。
  • 一个变量只能同时有一个线程进行加锁。lock可以被同一个线程加锁多次,但是必须解锁相同次数。这个变量才会被解锁。
  • 对一个变量执行lock操作。将会先清空该线程的工作内存中的该变量的值。在执行引擎使用这个变量前,需要重新执行load或assign操作。
  • 一个变量被lock,不允许其它线程执行unlock。也不允许执行unlock被别的线程lock的变量。即一个线程自己lock的只有自己能unlock.
  • 一个变量unlock之前,工作内存中的数据必须同步回主内存。
这八种操作和其使用规则,决定了变量在工作内存和主内存之间的同步策略。

针对于volatile变量又有额外如下定义:
  1. volatile变量在use时,必须执行load操作。即每次使用volatile变量必须先从主内存中刷新最新值。
  2. volatile变量在assign时,必须执行write操作。即每次对volatile进行赋值操作必须立马同步回主内存。
针对volatile和普通变量,或者volatile变量和volatile变量一起使用时。

JVM在编译期间也会针对volatile的重排加以干涉,干涉规则如下:


  1. 如果第二个操作时volatile写操作,不管第一操作是什么操作,都不能重排。
  2. 如果第一个操作时volatile读操作,不管第二个操作时什么操作,都不能重排。
  3. volatile写和volatile读不能重排。

为了实现这个语意,JVM在生成字节码时,会在指令序列中插入内存屏障(memory barrier)来禁止特定类型的处理器指令重排,对于编译器来说对所有的CPU来插入屏障数最小的方案几乎不可能,下面是基于保守策略的JMM内存屏障插入策略:
  1. 在每个volatile写操作前面插入StoreStore屏障
  2. 在每个volatile写操作后插入StoreLoad屏障
  3. 在每个volatile读后面插入一个LoadLoad屏障
  4. 在每个volatile读后面插入一个LoadStore屏障

这里要说下内存屏障是是什么东西:硬件层的内存屏障分为两种:Load Barrier 和 Store Barrier即读屏障和写屏障,内存屏障的作用有两个:
  • 阻止屏障两侧的的指令重排
  • 强制把高速缓存中的数据更新或者写入到主存中。Load Barrier负责更新高速缓存, Store Barrier负责将高速缓冲区的内容写回主存

LoadLoad,StoreStore,LoadStore,StoreLoad实际上是Java对上面两种屏障的组合,来完成一系列的屏障和数据同步功能:
  • LoadLoad屏障:对于这样的语句Load1; LoadLoad; Load2,在Load2及后续读取操作要读取的数据被访问前,保证Load1要读取的数据被读取完毕。
  • StoreStore屏障:对于这样的语句Store1; StoreStore; Store2,在Store2及后续写入操作执行前,保证Store1的写入操作对其它处理器可见。
  • LoadStore屏障:对于这样的语句Load1; LoadStore; Store2,在Store2及后续写入操作被刷出前,保证Load1要读取的数据被读取完毕。
  • StoreLoad屏障:对于这样的语句Store1; StoreLoad; Load2,在Load2及后续所有读取操作执行前,保证Store1的写入对所有处理器可见。它的开销是四种屏障中最大的。在大多数处理器的实现中,这个屏障是个万能屏障,兼具其它三种内存屏障的功能。



  • StoreStore屏障可以保证在volatile写之前,所有的普通写操作已经对所有处理器可见,StoreStore屏障保障了在volatile写之前所有的普通写操作已经刷新到主存。
  • StoreLoad屏障避免volatile写与下面有可能出现的volatile读/写操作重排。因为编译器无法准确判断一个volatile写后面是否需要插入一个StoreLoad屏障(写之后直接就return了,这时其实没必要加StoreLoad屏障),为了能实现volatile的正确内存语意,JVM采取了保守的策略。在每个volatile写之后或每个volatile读之前加上一个StoreLoad屏障,而大多数场景是一个线程写volatile变量多个线程去读volatile变量,同一时刻读的线程数量其实远大于写的线程数量。选择在volatile写后面加入StoreLoad屏障将大大提升执行效率(上面已经说了StoreLoad屏障的开销是很大的)。


  • LoadLoad屏障保证了volatile读不会与下面的普通读发生重排
  • LoadStore屏障保证了volatile读不回与下面的普通写发生重排。

即使JMM对volatile做了这么多的工作,它也仅仅只保证了volatile变量在原子性操作下多个线程之间的正确同步,对非原子操作,使用volatile仍然会发生无法预知的结果。
比如对i++操作,在多线程情况下结果依然是不定:
例子:



我们来使用 javap -c 来看下这个文件的编译指令:


increase方法的编译指令我们可以看出 ++ 操作经历了4步:
1、getstatic #10 获取静态变量num压入栈顶 此时volatile保证值是对的。
2、iconst_1 int型常量1入栈
3、iadd 栈顶两个int值相加,结果放入栈顶。
4、putstatic #10 把栈顶的值负值给指定域。
问题就出在2、3两步,在做这两步操作时,volatile变量有可能已经被其它线程修改。

根据volatile的内存语意我们可以总结出两条安全使用volatile的方式:
  • 运算结果不依赖于volatile变量的当前值,或者能保证只有单一线程能修改变量的值
  • 变量不需要与其它的状态变量共同参与不变性。