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

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

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 插件可以看到如下图所示的内容:

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

  1. new 用来分配一块内存空间
  2. invokspecial 调用了 Object 的 init() 方法,做了初始化
  3. 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 的修改,但也不应该一直读不到呀,这是为啥?这太反直觉了!

责任编辑:姜华 来源: 今日头条
相关推荐

2022-08-19 08:17:36

JWT服务器身份信息

2020-12-30 09:18:46

JVM内部信息

2019-08-02 17:48:16

戴尔

2022-02-15 20:08:41

JDKJavaWindows

2010-05-14 11:37:46

2019-01-07 08:59:01

uCPEvCPE网络

2009-07-31 19:51:47

云计算

2012-08-15 10:50:51

IE6

2010-06-03 15:30:01

Windows2008

2011-02-23 10:45:51

IT人才

2020-02-04 16:37:17

k8s 相关应用

2023-11-18 09:17:56

Optional代码

2022-12-01 17:17:09

React开发

2018-04-06 09:42:39

Windows操作系统功能

2015-10-28 17:35:35

自动化运维Ansible配置管理

2015-04-16 13:41:24

2022-04-29 08:00:36

web3区块链比特币

2022-09-13 11:50:21

Linux运维命令行

2022-03-15 09:58:12

单例模式系统
点赞
收藏

51CTO技术栈公众号