volatile 的底层实现原理是内存屏障,Memory Barrier(Memory Fence)

  • 对 volatile 变量的写指令后会加入写屏障
  • 对 volatile 变量的读指令前会加入读屏障

一、保护可见性

  • 写屏障(sfence)保证在该屏障之前的,对共享变量的改变,都同步到主存中,而不是cpu缓存中。

    1. public void actor2(I_result r){
    2. num=2;
    3. ready=true;//对ready的赋值是写操作,其后会加入写屏障
    4. // 写屏障
    5. }

    可理解为,在写指令之后加上限制,如果写的指令不是将新内容写进主存中,则会受到限制,该限制强迫写指令将最新内容写入主存中。

  • 读屏障保证在该屏障之后,对共享变量的读取,加载的是主存中的最新数据

    1. //线程1执行此方法
    2. public void actor1(I_result r){
    3. //读屏障
    4. //ready被volatile修饰,所以在读ready值之前要加读屏障
    5. if(ready){
    6. r.r1=num+num;
    7. }else{
    8. r.r1=1;
    9. }
    10. }

    可理解为,在读指令之前加上限制,该限制强迫读取的是主存中的最新内容。

流程图如下:
image.png

二、保护有序性

  • 写屏障会确保指令重排序时,不会将写屏障之前的代码排在写屏障之后

    1. public void actor2(I_result r){
    2. num=2;
    3. ready=true;//对ready的赋值是写操作,其后会加入写屏障
    4. //写屏障
    5. }
  • 读屏障会确保指令重排序时,不会将读屏障之后的代码排在读屏障之前

    1. public void actor1(I_result r){
    2. //读屏障
    3. //ready被volatile修饰,所以在读ready值之前要加读屏障
    4. if(ready){
    5. r.r1=num+num;
    6. }else{
    7. r.r1=1;
    8. }
    9. }

注意:

读写屏障(volatile)不能解决指令交错问题

  • 写屏障仅仅是保证之后的读能够读到最新的结果,但不能保证读跑到它前面去
  • 有序性的保证也只是保证了本线程内相关代码不被重排序,但是多线程间由于cpu调度而产生的指令交错(因时间片用完而造成的线程上下文切换问题)无法通过volatile解决。

image.png
比如即使变量i被 volatile 修饰,每次读取的i是最新的值,但仍然可能存在t2线程在t1线程执行前就读到错误i值的情况

总结来讲,volatile能保证线程可见性、线程内指令有序性(指令重排序禁用),但是不能保证代码的原子性。

三、double-checked locking 问题

单例模式中,著名的double-checked locking代码实现方式如下所示,当类实例已经被创建出,再调用getINSTANCE()方法的线程会直接查询INSTANCE是否为空,而无需进入synchronized块中查询,因为synchronized属于重量级操作,dcl的方式能够节省系统资源。

  1. import com.sun.scenario.animation.shared.SingleLoopClipEnvelope;
  2. public class Singleton {
  3. //构造方法设置为private,类外部不可以new出对象
  4. private Singleton(){}
  5. private static Singleton INSTANCE = null;
  6. public static synchronized Singleton getINSTANCE(){
  7. if(INSTANCE==null){
  8. synchronized (Singleton.class){
  9. if(INSTANCE==null){
  10. INSTANCE = new Singleton();
  11. }
  12. }
  13. }
  14. return INSTANCE;
  15. }
  16. }

以上dcl方式的实现特点是:

  • 懒惰实例化
  • 首次使用 getInstance() 才使用 synchronized 加锁后,后续使用时无需加锁
  • 有隐含的,但很关键的一点:第一个if使用了INSTANCE变量,是在同步块之外

但在多线程环境下,上面的代码是有问题的,getINSTANCE()方法对应的字节码为:
image.png

其中

  • 17 表示创建对象,将对象引用入栈 //new Singleton
  • 20 表示复制一份引用 //引用地址
  • 21 表示利用一个对象引用,调用构造方法 //根据引用地址调用
  • 24 表示利用一个对象引用,赋值给static INSTANCE //

也许jvm会优化为:先执行24,再执行21。如果两个线程t1,t2按如下时间序列执行:
image.png

则很有可能会出现当线程t1执行到第24行的指令时,切换到线程t2执行,而if(INSTANCE==null){} 这行代码没有进入synchronized中,所以线程t2不会立即因为没获得到锁而陷入阻塞状态,而是会执行if(INSTANCE==null){}对应的指令,这完全有可能出现上述的指令执行流程,这样整体结果就会出现问题,整体问题还是:多线程环境下由于指令交错与指令重排综合作用引起的结果错误

  1. if(INSTANCE==null){
  2. synchronized (Singleton.class){
  3. if(INSTANCE==null){
  4. INSTANCE = new Singleton();
  5. }
  6. }
  7. }

关键在于 0:getstatic 这行代码在monitor控制之外,它就像之前举例中不守规则的人,可以越过monitor读取 INSTANCE 变量的值。
这时t1还未完全将构造方法构造完毕,如果在构造方法中要执行很多初始化操作,那么t2拿到的将是一个未初始化完毕的单例。

解决方案:

  • 对INSTANCE使用 volatile 修饰即可,可以禁止指令重排,这样synchronized中的指令不会被重新排列,当与线程2的指令交错时也不会出现问题。
  • 将 INSTANCE 共享完全放进synchronized同步块中保护,这样利用原子性也可实现有序性。

问题解决分析:

如下图所示,加上volatile后,在字节码中的不同位置加上了读写屏障
image.png

加上读写屏障后,线程 t1 内synchronized块内的指令就禁止了重排序,与线程t2再有指令交错即不会再发生问题。
image.png