面试题 / Java

volatile 关键字

如何保证变量的可见性?

在 Java 中,volatile 关键字可以保证变量的可见性,如果我们将变量声明为 volatile ,这就指示 JVM,这个变量是共享且不稳定的,每次使用它都到主存中进行读取。

JMM(Java 内存模型)

JMM(Java 内存模型)强制在主存中进行读取

volatile 关键字其实并非是 Java 语言特有的,在 C 语言里也有,它最原始的意义就是禁用 CPU 缓存。如果我们将一个变量使用 volatile 修饰,这就指示 编译器,这个变量是共享且不稳定的,每次使用它都到主存中进行读取。

volatile 关键字能保证数据的可见性,但不能保证数据的原子性。synchronized 关键字两者都能保证。

如何禁止指令重排序?

在 Java 中,volatile 关键字除了可以保证变量的可见性,还有一个重要的作用就是防止 JVM 的指令重排序。 如果我们将变量声明为 volatile ,在对这个变量进行读写操作的时候,会通过插入特定的 内存屏障 的方式来禁止指令重排序。

在 Java 中,Unsafe 类提供了三个开箱即用的内存屏障相关的方法,屏蔽了操作系统底层的差异:

public native void loadFence();
public native void storeFence();
public native void fullFence();

理论上来说,你通过这个三个方法也可以实现和volatile禁止重排序一样的效果,只是会麻烦一些。

4 种内存屏障类型

JMM(Java 内存模型)定义了 4 种内存屏障(Memory Barrier),用于控制特定条件下的指令重排序和内存可见性:

屏障类型指令示例说明
LoadLoadLoad1; LoadLoad; Load2保证 Load1 的读取操作在 Load2 及其后续读取操作之前完成
StoreStoreStore1; StoreStore; Store2保证 Store1 的写入操作对其他处理器可见(刷新到内存),先于 Store2 及其后续写入操作
LoadStoreLoad1; LoadStore; Store2保证 Load1 的读取操作在 Store2 及其后续写入操作刷新到内存之前完成
StoreLoadStore1; StoreLoad; Load2保证 Store1 的写入操作对其他处理器可见,先于 Load2 及其后续读取操作。StoreLoad 屏障的开销是四种屏障中最大的,它同时具有其他三种屏障的效果,因此也称为 全能屏障(Full Barrier)

volatile 读写操作的内存屏障插入策略

JMM 针对编译器制定了 volatile 读写操作的内存屏障插入策略,以确保在任意处理器平台上都能获得正确的 volatile 内存语义:

volatile 写操作的内存屏障插入策略:

在每个 volatile 写操作的 前面 插入一个 StoreStore 屏障,在 后面 插入一个 StoreLoad 屏障。

StoreStore 屏障
volatile 写操作
StoreLoad 屏障
  • 前面的 StoreStore 屏障:保证在 volatile 写之前,其前面的所有普通写操作已经对任意处理器可见(刷新到主内存)。
  • 后面的 StoreLoad 屏障:保证 volatile 写之后,其写入的值对后续的 volatile 读/写操作可见。这是开销最大的屏障,但也是最关键的——它避免了 volatile 写与后面可能有的 volatile 读/写操作发生重排序。

volatile 读操作的内存屏障插入策略:

在每个 volatile 读操作的 后面 插入一个 LoadLoad 屏障和一个 LoadStore 屏障。

volatile 读操作
LoadLoad 屏障
LoadStore 屏障
  • LoadLoad 屏障:保证 volatile 读之后的普通读操作不会被重排序到 volatile 读之前。
  • LoadStore 屏障:保证 volatile 读之后的普通写操作不会被重排序到 volatile 读之前。

这样一来,volatile 写-读的组合就建立了一个类似于 锁的释放-获取 的语义:volatile 写操作之前的所有操作结果,对于后续对该 volatile 变量的读操作之后的所有操作都是可见的。

下面我以一个常见的面试题为例讲解一下 volatile 关键字禁止指令重排序的效果。

面试中面试官经常会说:“单例模式了解吗?来给我手写一下!给我解释一下双重检验锁方式实现单例模式的原理呗!”

双重校验锁实现对象单例(线程安全)

public class Singleton {

    private volatile static Singleton uniqueInstance;

    private Singleton() {
    }

    public static Singleton getUniqueInstance() {
       //先判断对象是否已经实例过,没有实例化过才进入加锁代码
        if (uniqueInstance == null) {
            //类对象加锁
            synchronized (Singleton.class) {
                if (uniqueInstance == null) {
                    uniqueInstance = new Singleton();
                }
            }
        }
        return uniqueInstance;
    }
}

uniqueInstance 采用 volatile 关键字修饰也是很有必要的, uniqueInstance = new Singleton(); 这段代码其实是分为三步执行:

  1. uniqueInstance 分配内存空间
  2. 初始化 uniqueInstance
  3. uniqueInstance 指向分配的内存地址

但是由于 JVM 具有指令重排的特性,执行顺序有可能变成 1->3->2。指令重排在单线程环境下不会出现问题,但是在多线程环境下会导致一个线程获得还没有初始化的实例。例如,线程 T1 执行了 1 和 3,此时 T2 调用 getUniqueInstance() 后发现 uniqueInstance 不为空,因此返回 uniqueInstance,但此时 uniqueInstance 还未被初始化。

从内存屏障角度理解 DCL 必须使用 volatile

上面从指令重排序的角度解释了 DCL 单例中 uniqueInstance 为什么需要 volatile 修饰。下面从内存屏障的角度进一步分析 volatile 是如何解决这个问题的。

uniqueInstance = new Singleton(); 这行代码的三个步骤(分配内存、初始化对象、赋值引用)中,如果不加 volatile,步骤 2 和步骤 3 可能会被重排序为 1→3→2。加了 volatile 之后,由于 uniqueInstance 是 volatile 变量,对它的写操作(步骤 3:将引用赋值给 uniqueInstance)会按照前面介绍的 volatile 写的内存屏障插入策略来处理:

  1. 在 volatile 写 之前 插入 StoreStore 屏障:保证步骤 1(分配内存)和步骤 2(初始化对象)的写操作在步骤 3(赋值引用)之前完成,禁止了步骤 2 和步骤 3 的重排序
  2. 在 volatile 写 之后 插入 StoreLoad 屏障:保证步骤 3 的写入结果对其他线程立即可见。

这样,当线程 T2 读取 uniqueInstance 时(volatile 读),如果发现 uniqueInstance != null,那么可以保证该对象一定已经被完全初始化了。

volatile 与 happens-before 的关系

JMM 中的 happens-before 原则是判断数据是否存在竞争、线程是否安全的重要依据。volatile 变量的读写操作与 happens-before 原则有着密切的关系。

关于 happens-before 原则的详细介绍,可以参考 JMM(Java 内存模型)详解 这篇文章。

happens-before 原则中与 volatile 直接相关的是 volatile 变量规则

对一个 volatile 变量的写操作 happens-before 于后续对该 volatile 变量的读操作。

也就是说,如果线程 A 写入了一个 volatile 变量,线程 B 随后读取了同一个 volatile 变量,那么线程 A 在写入 volatile 变量之前所做的所有修改(包括对非 volatile 变量的修改),对线程 B 都是可见的。

这个规则配合 happens-before 的 传递性规则(如果 A happens-before B,B happens-before C,那么 A happens-before C),可以实现一种轻量级的线程间通信。下面通过一个示例来说明:

public class VolatileHappensBeforeDemo {
    private int a = 0;
    private int b = 0;
    private volatile boolean flag = false;

    // 线程 A 执行
    public void writer() {
        a = 1;           // 操作1:普通写
        b = 2;           // 操作2:普通写
        flag = true;     // 操作3:volatile 写
    }

    // 线程 B 执行
    public void reader() {
        if (flag) {      // 操作4:volatile 读
            int x = a;   // 操作5:普通读,x 一定等于 1
            int y = b;   // 操作6:普通读,y 一定等于 2
            System.out.println("x=" + x + ", y=" + y);
        }
    }
}

上面代码中,happens-before 关系链如下:

  1. 操作1、操作2 happens-before 操作3(程序顺序规则:同一线程中,前面的操作 happens-before 后面的操作)
  2. 操作3 happens-before 操作4(volatile 变量规则:volatile 写 happens-before volatile 读)
  3. 操作4 happens-before 操作5、操作6(程序顺序规则

根据 传递性:操作1、操作2 happens-before 操作5、操作6。

因此,当线程 B 在操作4 读取到 flag == true 时,线程 A 在操作3 之前对 ab 的修改对线程 B 一定是可见的。这里的关键在于:volatile 变量的写-读操作,不仅保证了 volatile 变量本身的可见性,还通过 happens-before 的传递性”顺带”保证了其前后普通变量的可见性。

这也解释了为什么在实际开发中,volatile 经常被用作 状态标志位(如上面例子中的 flag),它可以在不使用锁的情况下,安全地在线程间传递状态信息,同时保证相关数据的可见性。

volatile 可以保证原子性么?

volatile 关键字能保证变量的可见性,但不能保证对变量的操作是原子性的。

我们通过下面的代码即可证明:

/**
 * 微信搜 JavaGuide 回复"面试突击"即可免费领取个人原创的 Java 面试手册
 *
 * @author Guide哥
 * @date 2022/08/03 13:40
 **/
public class VolatileAtomicityDemo {
    public volatile static int inc = 0;

    public void increase() {
        inc++;
    }

    public static void main(String[] args) throws InterruptedException {
        ExecutorService threadPool = Executors.newFixedThreadPool(5);
        VolatileAtomicityDemo volatileAtomicityDemo = new VolatileAtomicityDemo();
        for (int i = 0; i < 5; i++) {
            threadPool.execute(() -> {
                for (int j = 0; j < 500; j++) {
                    volatileAtomicityDemo.increase();
                }
            });
        }
        // 等待1.5秒,保证上面程序执行完成
        Thread.sleep(1500);
        System.out.println(inc);
        threadPool.shutdown();
    }
}

正常情况下,运行上面的代码理应输出 2500。但你真正运行了上面的代码之后,你会发现每次输出结果都小于 2500

为什么会出现这种情况呢?不是说好了,volatile 可以保证变量的可见性嘛!

也就是说,如果 volatile 能保证 inc++ 操作的原子性的话。每个线程中对 inc 变量自增完之后,其他线程可以立即看到修改后的值。5 个线程分别进行了 500 次操作,那么最终 inc 的值应该是 5*500=2500。

很多人会误认为自增操作 inc++ 是原子性的,实际上,inc++ 其实是一个复合操作,包括三步:

  1. 读取 inc 的值。
  2. 对 inc 加 1。
  3. 将 inc 的值写回内存。

volatile 是无法保证这三个操作是具有原子性的,有可能导致下面这种情况出现:

  1. 线程 1 对 inc 进行读取操作之后,还未对其进行修改。线程 2 又读取了 inc的值并对其进行修改(+1),再将inc 的值写回内存。
  2. 线程 2 操作完毕后,线程 1 对 inc的值进行修改(+1),再将inc 的值写回内存。

这也就导致两个线程分别对 inc 进行了一次自增操作后,inc 实际上只增加了 1。

其实,如果想要保证上面的代码运行正确也非常简单,利用 synchronizedLock或者AtomicInteger都可以。

使用 synchronized 改进:

public synchronized void increase() {
    inc++;
}

使用 AtomicInteger 改进:

public AtomicInteger inc = new AtomicInteger();

public void increase() {
    inc.getAndIncrement();
}

使用 ReentrantLock 改进:

Lock lock = new ReentrantLock();
public void increase() {
    lock.lock();
    try {
        inc++;
    } finally {
        lock.unlock();
    }
}

来源引用