🌑

Shawn Fux

深入理解volatile关键字

初识 volatile 关键字是在实现单例模式的双检锁机制,那时只知道 volatile 能够解决可见性和有序性,然而对为什么需要解决可见性和有序性缺完全不知道,如果不能解决可见性和有序性会带来什么问题?以及 volatile 是如何去保证可见性和有序性的?

什么是可见性?

public class VolatileTest {
    private static boolean flag = true;

    public static void main(String[] args) throws InterruptedException {
        // t1 线程
        new Thread(() -> {
            while (flag) {

            }
            System.out.println("跳出循环了");
        }).start();
        Thread.sleep(1000L);
        // t2 线程
        new Thread(() -> {
            flag = false;
        }).start();

    }
}

在了解可见性之前,先看上面这一段代码,首先开启了 t1 线程,然后执行 while 循环期望在 flag 等于 FALSE 时跳出 while 循环,接着主线程睡眠1秒,主睡眠1秒要是为了保证 t1 线程先于 t2 线程先执行,在 t2 线程将 flag 该为了 FALSE,预期结果是 t1 线程收到 flag 变为 FALSE 的结果跳出循环结束。然而你试着运行这段代码会发现 t1 线程可能永远无法跳出循环,因为它的 flag 一直是 TRUE,这与我们设想的相违背,因为 flag 变量是一个全局变量,按理来说 t2 线程修改了 flag 这个全局变量,t1 线程应该能感知到才对,这就引出了我们的可见性问题了。

JMM内存模型

在上面的例子中,我们发现明明是全局变量,却好似变成了局部变量了,那么到底是什么原因导致的了?这与我们 Java 的内存模型有关系,在Java虚拟机规范中定义了Java内存模型(Java Memory Model,JMM),但是这个 JMM 并非我们常说的堆栈内存分布,而是用于屏蔽掉各种硬件和操作系统的内存访问差异,以实现让Java程序在各种平台下都能达到一致的并发效果,JMM 规范了Java虚拟机与计算机内存是如何协同工作的:规定了一个线程如何和何时可以看到由其他线程修改过后的共享变量的值,以及在必须时如何同步的访问共享变量。

jmm

通过分析 JMM 内存模型,把我们上面的例子代码代入以后,会发现每个线程在访问共享主内存的 flag 变量时,都是会先拷贝一份数据到自己的本地内存缓存中操作,而不是直接与共享主内存交互,而这就是造成我们可见性的根本原因。可以这样试想一下,如果线程 t1 先读取 flag 到自己的本地内存缓存中,此时读取到的值为 TRUE,接着线程 t2 也从共享主内存读取变量 flag到自己的本地内存缓存中,然后 t2 线程修改了 flag 值为 FALSE,那么这时候就出现了可见性的问题,因为线程 t1 的值还是 TRUE,它不知道线程 t2 修改了 flag。那么怎么解决这个问题了?我们可以这样做,修改者(t2)每次修改完必须强制更新到共享主内存,而读取者(t1)必须每次都去共享主内存去读取最新的值。

为什么是共享内存模型?

分析了 JMM 内存模型以后,会产生这样的疑问:为什么要设计成这种共享内存模型了?直接每次操作共享主内存就行了,省去了从主内存复制到本地缓存内存的时间,也不会有可见性的问题,岂不是更好?要理解为什么要这么设计,首先得明白在当前主流计算机的内存架构是什么样的:

内存架构

通过上面的图可以分析出,我们 JMM 内存模型的线程本地内存缓存相当于 CPU 的寄存器,L1 Cache,L2 Cache,而共享主内存相当于 L3 Cache,RAM,磁盘这些。那么为什么要设计出这么多级缓存了?这是因为现代 CPU 运行速度很快,但是访存 IO 却远远跟不上 CPU 的运行速度,所以我们利用就近原则缓存的思想来尽量弥补访存慢的问题。

什么是有序性?

在了解为什么需要有序性之前,必须先知道为什么会出现无序,难道计算机不是按我们编写的程序顺序执行吗?其实我们的代码编写完成后,还需要编译成机器指令,才能最终被执行。比方说一条简单的 i++,可能需要三个指令才能完成,①. 从内存获取 i 的值到操作寄存器,②. 对 i 执行加1的操作,③. 更新后 i 的值到内存。所以我们写的高级语言一条语句的代码,也许对应 CPU 的好几条指令才能完成,而不管是 CPU 还是编译器都会对这些指令做一些重排序优化,来支撑更好的并行能力,当然是在不影响整体程序结果的条件前提下,然而这个不影响又仅仅是在单线程语义环境下,如果是多线程的指令才排序就会发生一些奇奇怪怪的现象。

public class Singleton {
    private volatile static Singleton singleton;
    private Singleton (){}
    public static Singleton getSingleton() {
        if (singleton == null) {
            synchronized (Singleton.class) {
                if (singleton == null) {
                    singleton = new Singleton();
                }
            }
        }
        return singleton;
    }
}

上面的代码是一个非常经典的单例模式双检锁机制实现,但是为什么 singleton 这个变量需要使用 volatile 关键字来修饰了?为了保证内存可见性是一方面,为什么还需要用它来保证有序性了?不保证有序性会引发什么问题?

Singleton

在创建一个对象的时候,对应到汇编指令需要三个步骤才能完成,1. 开辟内存空间,2. 对象初始化,3. 内存空间地址赋值给对象引用。假设如果发生了指令重排序,现在执行顺序是这样的:1,3,2,如果是单线程执行这样并没有什么问题,但是如果多线程执行的话就会有可能出现问题了。假设线程1执行到上面代码图中的①这个位置创建 Singleton 对象,但是它只执行完开辟内存空间和将一个未初始化的内存空间地址赋值给 singleton 引用时,就发生了上下文线程切换失去了时间片,而此时另一个线程2也进来执行这个方法当它执行判断②这个位置即判断 singleton == null 这个条件时,其实这个时候条件是不成立的,因为线程1已结完成了内存地址赋值的操作,只是未初始化,所以线程2会获取到一个未初始化的对象返回,这便是问题所在。

volatile如何解决可见性和有序性?

参考文章:

— Nov 22, 2022