Double check 为何需要 volatile?

1,928 阅读3分钟

对于单例的双重检锁,为何要对变量加上 volatile 修饰关键字?

Prerequisite:对象创建的过程

要理解一个对象的创建过程,需要从运行时数据区进行分析,首先需要对JVM运行时数据区布局有深入的理解,同时掌握类加载过程中各个阶段的行为。详见后续的《从JVM角度分析 new 一个对象的详细过程》。

本文无需深入分析这两个主题,只需要了解创建对象的总体流程即可。

核心:非原子操作、指令重排序


private static volatile Singleton instance;

public static Singleton getInstance() {
    if(instance == null) {
        synchronized(Singleton.class) {
            if(instance == null) {
                instance = new Singleton();
            }
        }
    }
    return instance;
}

目的

避免重排序问题导致其他线程看到一个已经分配内存和地址,但是没有初始化的对象(对象还处于不可用状态),就被其他线程引用了(报异常)。

创建对象的指令重排序分析

下面代码在多线程环境下不是原子操作

instance = new Singleton();

这条指令坑被重排序,分如下两种可能的指令排序场景。

场景1:不重排序(正常步骤)

正常的底层执行顺序会分3步走:

1、给 instance 分配内存

2、调用实例 instance 的构造函数来初始化成员变量

3、将 instance 这个在栈中的引用,指向在步骤1和2中打包好的对象

无论线程A当前执行到1、2、3哪一步,对于线程B,可能看到的 instance 的状态只有两种:null 和 非 null。

步骤 1 和 2 中的 instance 对象都是 null 的,第3步看到的是非 null,对于正常顺序来说,这是没问题的。

场景2:重排序

如果线程A 在重排序的情况下,可能会变成 1,3,2,假如线程A执行到第二步“3”时,instance 虽然已经不是 null,但还没初始化,不可用。

此时CPU时间片切换,从线程A 切换到线程B,线程B来调用 double check 这个 getInstance 单例方法,那么第一个 null check 时,看到的 instance 引用由于已经被线程 A 指向了内存块,不为 null,则直接返回这个instance。

但是,当使用这个对象的某个字段时,由于还没被初始化,处于不可用状态,会导致异常发生。

解决方案:使用 volatile 修饰变量,禁止指令重排序

volatile 的原理

使用 volatile 修饰成员变量,那么在变量赋值时,会有一个内存屏障,也就是说只有执行完123步操作之后,其他线程读取操作时才能看到 instance 这个变量的值,不会造成误判,解决了对象状态不完整的问题。

同时,volatile 会强制将缓存中修改的数据刷新到主内存中,确保对其他线程的可见性。

此时,invalidate 其他CPU的缓存行,当其他CPU需要使用这个缓存行的变量时,就会去重新到主内存读取,保证数据是最新的。