• 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;
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18

    如果不加 Volatile,会有什么问题呢?问题就出现在下面这行代码:

    instance = new Singleton();
    
    • 1

    上面这行代码看起来也平平无奇呀,就是一个赋值操作,还能整什么幺蛾子呢?我们只写了一行代码,但 JVM 则需要做好几步操作。那 JVM 究竟干了啥呢?大概也许可能差不多就是把大象给放冰箱里了(如果这句看不懂,请咨询宋丹丹老师)。

    Java 代码中的一条赋值语句,到了 JVM 指令层面大概分三步:

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

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

    Object o = new Object();
    
    • 1

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

    请添加图片描述

    关于 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;
            }
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34

    代码很简单,就是几个赋值操作,但却很巧妙。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;
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13

    代码很简单,主线程内开启一个子线程,子线程中一个 while 循环,当 flag 为 false 时,结束循环。flag 初始值为 true,一秒钟后,被主线程设置为 false。

    按照上面这个逻辑,子线程应该会在程序启动一秒后停止。然而,当你运行程序后会发现,这个程序就像吃了炫迈一样,根本停不下来。

    这说明主线程对 flag 的修改,子线程并没有感知到。我们修改一下程序:

    private static volatile boolean flag = true;
    
    • 1

    为 flag 加上 volatile 修饰符,再次运行,你会发现程序运行后,很快(大概一秒钟)就停止了。这是为啥?是炫迈的药劲儿过了吗?

    哈哈,当然不是。为了更好的性能,线程都有自己的缓存(CPU 中的高速缓存),我们称之为工作内存或者本地内存。还有一块公共内存,我们叫它主从吧。它们的结构大致如下图所示:

    请添加图片描述

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

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

    请添加图片描述

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

    总结

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

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

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

    【限时】价值 107 元的《Spring Boot趣味实战课》免费送啦!

  • 相关阅读:
    Spring 更简单的读取和存储对象
    239. 奇偶游戏
    GBase 8s 常用函数
    人工智能、深度学习、机器学习常见面试题141~160
    一文读懂Js中的this指向
    R语言作业--第六章判别分析
    Hooks进阶--useEffect - 发送网络请求
    关系型和非关系型数据库的区别?
    C++心决之stl中那些你不知道的秘密(string篇)
    数学笔记1
  • 原文地址:https://blog.csdn.net/liushuijinger/article/details/126219234