Volatile 算是一个面试中的高频问题了。我们都知道 Volatile 有两个作用:
- 禁止指令重排
- 保证内存可见
指令重排序
指令重排序的问题,基本上都是通过 DCL 问题来考察。
DCL,Double Check Look
面试中通常会是下面这种情景:
面试官:用过单例吗?
你:用过。
面试官:如何实现一个线程安全的懒汉式单例
你:DCL。
面试官:DCL 可以保证线程绝对安全吗?
你:加 Volatile。
面试官满意的点点头。通常情况下,面试中这个问题聊到这里也就结束了。
但这个问题,还有一些可挖掘的内容。我们顺着单例的代码继续往下挖:
public class Singleton { private static volatile Singleton instance = null; private Singleton() { } public static Singleton getInstance() { if (instance == null) { synchronized (Singleton.class) { if (instance == null) { instance = new Singleton(); } } } return instance; }}
如果不加 Volatile,会有什么问题呢?问题就出现在下面这行代码:
instance = new Singleton();
上面这行代码看起来也平平无奇呀,就是一个赋值操作,还能整什么幺蛾子呢?我们只写了一行代码,但 JVM 则需要做好几步操作。那 JVM 究竟干了啥呢?大概也许可能差不多就是把大象给放冰箱里了。
Java 代码中的一条赋值语句,到了 JVM 指令层面大概分三步:
- 分配一块内存空间
- 初始化
- 返回内存地址
下面通过字节码来一探究竟,为了简化问题,我们替换成下面的代码:
Object o = new Object();
编译以后,通过 javap -v 命令,或者 IDEA 中的 JClassLib 插件可以看到如下图所示的内容:
通过上面的字节码信息,可以更加清楚的看到上面提到的那三个步骤:
- new 用来分配一块内存空间
- invokspecial 调用了 Object 的 init() 方法,做了初始化
- astore_1 就是将 o 指向了 Object 实例对象的内存地址,完成赋值
dup 指令会做一些入栈操作,跟我们要讨论的问题关系不大,这里可以先忽略。
到这里,问题就比较明了了。重排的问题会发生在第 2 和 3 步。因为先初始化还是先把对象的内存地址赋值给 o,并没有必然的前后制约关系。因此,这类的指令在某些情况下会被重排序。
单线程下,这种重排序完全没有问题。但是多线程的场景下,就有可能出问题:A 线程进入到 instance = new Singleton(); 后,由于指令重排,在 init 之前,将地址给了 o。此时 B 线程来了,发现 instance 不为 null,于是直接拿去用了,然而此时 instance 并没有初始化,只是个半成品。所以,当 B 拿到 instance 进行操作的时候就会出现问题了。
因此,instance 需要使用 volatile 来修饰,从而禁止进行指令重排。
到这里,你可能要说了,我用单例不加 volatile,这么长时间了也没遇到你说的重排序问题。你怎么证明「重排序」的存在呢?好问题,下面咱们通过一个小例子来验证一下重排序是否真的存在。
private static int x = 0;private static int y = 0;private static int a = 0;private static int b = 0;public static void main(String[] args) throws InterruptedException { int i = 0; while (true) { i++; x = 0; y = 0; a = 0; b = 0; Thread one = new Thread(() -> { a = 1; x = b; }); Thread two = new Thread(() -> { b = 1; y = a; }); one.start(); two.start(); one.join(); two.join(); if(x == 0 && y == 0) { log.info("第 {} 次,x = {}, y = {}", i, x, y); break; } }}
代码很简单,就是几个赋值操作,但却很巧妙。x、y、a、b 初始都为 0,两个线程分别给 a、x 和 b、y 赋值,线程 one 先让 a = 1,然后再让 x = b;two 线程先让 b = 1,然后再让 y = a。
假如不发生重排序,那么以上程序只会有下面六种可能:
每一列,从上到下代表代码执行的顺序。
也就是说,在没有重排序的情况下,不可能出现 x、y 同时为 0 的情况。而如果 x、y 同时为 0 了,那么一定是出现了下面六种情况中的一种,既发生了重排。
每一列,从上到下代表代码执行的顺序。
运行程序,经过漫长的等待,得到了如下的输出:
可以看到,在执行了五十多万次以后,我们终于捕捉到了一次重排序。发生这种情况的几率很低,所以你就算没有用 volatile 大概率不会有问题,但我们在今后还是要合理的使用 volatile。
内存可见性
聊完指令重排,接下来聊聊内存可见。这次我们直接上代码:
private static boolean flag = true;private static void justRun() { System.out.println("Thread One Start"); while (flag) {} System.out.println("Thread One End");}public static void main(String[] args) throws InterruptedException { new Thread(() -> justRun(), "Thread One").start(); TimeUnit.SECONDS.sleep(1); flag = false;}
代码很简单,主线程内开启一个子线程,子线程中一个 while 循环,当 flag 为 false 时,结束循环。flag 初始值为 true,一秒钟后,被主线程设置为 false。
按照上面这个逻辑,子线程应该会在程序启动一秒后停止。然而,当你运行程序后会发现,这个程序就像吃了炫迈一样,根本停不下来。
这说明主线程对 flag 的修改,子线程并没有感知到。我们修改一下程序:
private static volatile boolean flag = true;
为 flag 加上 volatile 修饰符,再次运行,你会发现程序运行后,很快(大概一秒钟)就停止了。这是为啥?是炫迈的药劲儿过了吗?
哈哈,当然不是。为了更好的性能,线程都有自己的缓存(CPU 中的高速缓存),我们称之为工作内存或者本地内存。还有一块公共内存,我们叫它主从吧。它们的结构大致如下图所示:
主存中定义了一个 flag 变量,每个线程读取它的时候,为了更好的性能会在线程本地缓存一份它的副本。读取的时候也是优先读取本地副本的值。当 flag 被 volatile 修饰后,每次被修改,都会让其他线程中的副本失效,从而必须去主存中读取最新的值。所以,在使用了 volatile 后,子线程能够立即感知到 flag 的变化,从而停止。
上图简化了线程(CPU)的缓存结构,其完整结构如下图所示:
现代 CPU 共有三级缓存,分别为:L1、L2 和 L3。CPU 中的每个核心都有自己的 L1 和 L2,而一颗 CPU 中的多个核心会共享 L3。
总结
Volatile 的意思是,易变的,动荡不定的,反复无常的。volatile 的作用就是告诉 JVM,被我修饰的变量它非常善变,你要给我盯好了,一旦有风吹草动要立马通知大家;另外,你不要自作聪明的调整它的位置(为了性能重排序),它可是说翻脸就翻脸的主儿。
最后,留一个小问题:内存可见性的那个程序中,就算 flag 没有被 volatile 修饰,线程顶多不是第一时间读到 flag 的修改,但也不应该一直读不到呀,这是为啥?这太反直觉了!