Volatile实现多线程下共享变量可见性和指令有序性

可见性

 public void shutdown(){
     shutdownRequested=false;
 }
 public void run(){
     while(shutdownRequested){
     //业务代码
     };
 }

上述代码中,shutdownRequested被定义为static变量,在多线程的语境下就是一个共享变量。volatile实现了共享变量shutdownRequested的可见性:
如果有多个线程正在执行run(),若有线程A执行了shutdown(),将shutdownRequested置为true,则

  1. 线程A工作内存中的shutdownRequested变量变为false,且立即写入主存;
  2. 所有线程的工作内存中的shutdownRequested变量均会失效Invalid,并且在下一次使用时从主存中获取新的shutdownRequested变量值,即为false,则所有线程都会退出while循环。
    假如没有把shutdownRequested定义为volatile类型,那么执行shutdown()并没办法保证,所有线程全部立即停止运行。

除了可见性外,volatile还可以为指令执行提供有序性。

有序性

public class DoubleCheckLockSingelton {
    private static volatile DoubleCheckLockSingelton instance;
    DoubleCheckLockSingelton(){
    }
    private static DoubleCheckLockSingelton getInstance(){
        if (instance==null)
            synchronized (DoubleCheckLockSingelton.class){
                if (instance==null)
                    instance=new DoubleCheckLockSingelton();
            }
        return instance;
    }
}

这一段代码就是著名的DCL单例模式-双重检查锁,似乎曾经在阿里的真实业务中出现过问题。我们知道在代码执行过程为了更好地利用cpu计算资源,可能发生指令重排,这个指令指的是cpu直接执行的指令,并不是字节码,不过在这个例子中我们从字节码指令就可以看到指令重排可能导致的问题。 我们仅关注synchronized代码块:

10 monitorenter
11 getstatic #7 
14 ifnonnull 27 (+13)
17 new #8 
20 dup
21 invokespecial #13 <com cyw="" doublechecklocksingelton. : ()V>
24 putstatic #7 
27 aload_0
28 monitorexit

17行new了一个DoubleCheckLockSingelton对象,21行执行了构造方法的初始化,24行则是把DoubleCheckLockSingelton对象引用赋值给静态变量instance。
我们假设有两个线程A和B都在尝试执行getInstance(),线程A先拿到synchronized构造的锁,在未发生指令重排情况下,对象初始化完成后才赋值给静态变量instance,在此动作完成之前instance变量一直是null,对于线程B也是如此,所以它只能一直等待A释放锁,然后才能拿到非null的instance引用。
但是我们知道,虽然synchronized可以提供有序性,但是是在synchronized内部与外部之间,而在synchronized内部是可能发生指令重排的,如果21行和24发生指令重排,先执行了instance变量赋值,再执行初始化,那么如果又碰巧,instance变量从线程A的工作内存中刷新到主存中,并且线程B拿到了最新的instance变量引用,并立刻执行了某些操作,这些操作需要依赖于instance初始化值,那么问题就出现了,因为此时instance对象可能完成还没初始化,或者只执行了部分初始化,那么B的业务逻辑就会产生bug。
volatile可以帮我们解决这个问题,它可以实现禁止指令重排,可以看下图汇编程序。

    通过对比发现,关键变化在于有volatile修饰的变量,赋值后(前面mov%eax,0x150(%esi)这句便是赋值操作)多执行了一个“lock addl$0x0,(%esp)”操作,这个操作的作用相当于一个内存屏障(Memory Barrier或Memory Fence,指重排序时不能把后面的指令重排序到内存屏障之前的位置,注意不要与第3章中介绍的垃圾收集器用于捕获变量访问的内存屏障互相混淆),只有一个处理器访问内存时,并不需要内存屏障;但如果有两个或更多处理器访问同一块内存,且其中有一个在观测另一个,就需要内存屏障来保证一致性了。
    这句指令中的“addl$0x0,(%esp)”(把ESP寄存器的值加0)显然是一个空操作,之所以用这个空操作而不是空操作专用指令nop,是因为IA32手册规定lock前缀不允许配合nop指令使用。这里的关键在于lock前缀,查询IA32手册可知,它的作用是将本处理器的缓存写入了内存,该写入动作也会引起别的处理器或者别的内核无效化(Invalidate)其缓存,这种操作相当于对缓存中的变量做了一次前面介绍Java内存模式中所说的“store和write”操作。所以通过这样一个空操作,可让前面volatile变量的修改对其他处理器立即可见。
    那为何说它禁止指令重排序呢?从硬件架构上讲,指令重排序是指处理器采用了允许将多条指令不按程序规定的顺序分开发送给各个相应的电路单元进行处理。但并不是说指令任意重排,处理器必须能正确处理指令依赖情况保障程序能得出正确的执行结果。譬如指令1把地址A中的值加10,指令2把地址A中的值乘以2,指令3把地址B中的值减去3,这时指令1和指令2是有依赖的,它们之间的顺序不能重排——(A+10)2与A2+10显然不相等,但指令3可以重排到指令1、2之前或者中间,只要保证处理器执行后面依赖到A、B值的操作时能获取正确的A和B值即可。所以在同一个处理器中,重排序过的代码看起来依然是有序的。因此,lock addl$0x0,(%esp)指令把修改同步到内存时,意味着所有之前的操作都已经执行完成,这样便形成了“指令重排序无法越过内存屏障”的效果。
---周志华《深入理解Java虚拟机》

这里涉及内存一致性协议、内存屏障等多的知识点,在其他文章再叙,先知道其效果即可:在执行instance赋值之前的语句一定会先都执行完,才能把instance等变量值(if any)刷入内存。

总结

volatile可以帮助我们实现共享变量的可见性,同时禁止指令的重排序。

  1. 共享变量发生修改,则立即写入主存;
  2. 其他线程的工作内存中此共享变量失效;(依靠缓存一致性协议)
  3. 插入内存屏障,禁止指令重排。

这是一个从 https://juejin.cn/post/7369416837249056806 下的原始话题分离的讨论话题