Volatile:JVM 我警告你,我的人你别乱动!

时间:2022-11-30 15:01:03


Volatile 算是一个面试中的高频问题了。我们都知道 Volatile 有两个作用:

  1. 禁止指令重排
  2. 保证内存可见

指令重排序

指令重排序的问题,基本上都是通过 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 指令层面大概分三步:

  1. 分配一块内存空间
  2. 初始化
  3. 返回内存地址

下面通过字节码来一探究竟,为了简化问题,我们替换成下面的代码:

Object o = new Object();

编译以后,通过 javap -v 命令,或者 IDEA 中的 JClassLib 插件可以看到如下图所示的内容:

Volatile:JVM 我警告你,我的人你别乱动!

关于 Java 字节码,可以戳这里:《​​写了那么多 Java 代码,却不一定见过它的真面目​​》

通过上面的字节码信息,可以更加清楚的看到上面提到的那三个步骤

  1. new 用来分配一块内存空间
  2. invokspecial 调用了 Object 的 init() 方法,做了初始化
  3. astore_1 就是将 o 指向了 Object 实例对象的内存地址,完成赋值

dup 指令会做一些入栈操作,跟我们要讨论的问题关系不大,这里可以先忽略。《​​Java 程序在 JVM 中是怎样执行的?​​》中有一个视频动画更形象的说明了这一点。

到这里,问题就比较明了了。重排的问题会发生在第 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。

假如不发生重排序,那么以上程序只会有下面六种可能:

Volatile:JVM 我警告你,我的人你别乱动!

每一列,从上到下代表代码执行的顺序。

也就是说,在没有重排序的情况下,不可能出现 x、y 同时为 0 的情况。而如果 x、y 同时为 0 了,那么一定是出现了下面六种情况中的一种,既发生了重排。

Volatile:JVM 我警告你,我的人你别乱动!

每一列,从上到下代表代码执行的顺序。

运行程序,经过漫长的等待,得到了如下的输出:

Volatile:JVM 我警告你,我的人你别乱动!

可以看到,在执行了五十多万次以后,我们终于捕捉到了一次重排序。发生这种情况的几率很低,所以你就算没有用 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 中的高速缓存),我们称之为工作内存或者本地内存。还有一块公共内存,我们叫它主从吧。它们的结构大致如下图所示:

Volatile:JVM 我警告你,我的人你别乱动!

主存中定义了一个 flag 变量,每个线程读取它的时候,为了更好的性能会在线程本地缓存一份它的副本。读取的时候也是优先读取本地副本的值。当 flag 被 volatile 修饰后,每次被修改,都会让其他线程中的副本失效,从而必须去主存中读取最新的值。所以,在使用了 volatile 后,子线程能够立即感知到 flag 的变化,从而停止。

上图简化了线程(CPU)的缓存结构,其完整结构如下图所示:

Volatile:JVM 我警告你,我的人你别乱动!

现代 CPU 共有三级缓存,分别为:L1、L2 和 L3。CPU 中的每个核心都有自己的 L1 和 L2,而一颗 CPU 中的多个核心会共享 L3。

总结

Volatile 的意思是,易变的,动荡不定的,反复无常的。volatile 的作用就是告诉 JVM,被我修饰的变量它非常善变,你要给我盯好了,一旦有风吹草动要立马通知大家;另外,你不要自作聪明的调整它的位置(为了性能重排序),它可是说翻脸就翻脸的主儿。

最后,留一个小问题:内存可见性的那个程序中,就算 flag 没有被 volatile 修饰,线程顶多不是第一时间读到 flag 的修改,但也不应该一直读不到呀,这是为啥?这太反直觉了!

开动你的脑筋思考一下吧!