Lesson 13 · 并发编程

volatile 深入:可见性、有序性与 happens-before

高级·⭐ 必问·#并发·#JMM·#核心

第 1 站

从一道面试题开始

面试官看着你的简历,不紧不慢地问了一句:

"volatile 能保证原子性吗?"

"不能。"——如果你只回答这两个字,面试就到此为止了。

面试官真正想听的是:volatile 保证可见性和有序性,但不保证原子性。紧接着,他会追问:

  • 可见性是怎么实现的?缓存失效?内存屏障?
  • 有序性是什么意思?指令重排为什么危险?
  • 为什么 i++ 用 volatile 不行?
  • DCL 单例为什么一定要加 volatile?

这篇文章把 volatile 拆成 8 站,从可见性到内存屏障,从 happens-before 到 DCL 单例,一站一站把 volatile 讲透。

发车之前,先问自己:

  • volatile 写操作后,CPU 缓存做了什么?
  • StoreStore 屏障和 StoreLoad 屏障分别插在哪里?
  • 对象创建的 3 条指令为什么可能被重排?

带着这些问题,我们开始。

第 2 站

可见性:一个线程改了值,其他线程立刻能看到

现代 CPU 都有多级缓存(L1、L2、L3),每个核心有自己独立的 L1/L2 缓存。Java 线程运行在不同的 CPU 核心上时,对同一个变量的读写可能发生在各自的缓存里——线程 A 修改了变量,值还在 L1 缓存中,线程 B 根本看不到

主内存 (Main Memory) volatile int flag = 0 L3 共享缓存(所有核心共享) CPU 核心 A(线程 1) L1 缓存:flag = 0 volatile write: flag = 1 ① 写操作 → 强制刷回主内存 ② 通知其他核心缓存失效 CPU 核心 B(线程 2) L1 缓存:flag = 0(旧值) volatile read → 缓存失效,重新读取 ③ 收到失效通知 → 清除 L1 缓存行 ④ 从主内存读到 flag = 1(新值)
图 1volatile 写操作会立即刷回主内存并通知其他核心缓存失效;volatile 读操作会先使缓存失效,再从主内存重新加载

volatile 保证可见性的机制可以概括为两条规则:

volatile 可见性规则
volatile write(写操作):
  // 1. 将新值立即刷新到主内存
  // 2. 通过 MESI 协议通知其他 CPU 核心:这个缓存行无效

volatile read(读操作):
  // 1. 使当前 CPU 缓存中该变量的缓存行失效
  // 2. 从主内存重新读取最新值

底层实现依赖 CPU 的 缓存一致性协议(如 MESI)和 Lock 前缀指令。JVM 会在 volatile 操作时插入特定的 CPU 指令(如 lock 前缀),强制把缓存行刷回主内存。

记住volatile 让每一次写都"广而告之",每一次读都"眼见为实"——但这只对单个变量的单次读写有效。
第 3 站

有序性:编译器、JVM、CPU 都在偷偷调顺序

为了提高性能,编译器和 CPU 会对指令进行重排序。只要最终结果在单线程中不变,重排就是合法的。但在多线程环境下,重排可能导致灾难。

来看一个经典例子:

ReorderExample.java
int a = 0, b = 0;
int x = 0, y = 0;

// 线程 1
a = 1;      // ①
x = b;      // ②

// 线程 2
b = 1;      // ③
y = a;      // ④

// 正常思维:x=0,y=0 或 x=1,y=1 或 x=1,y=0
// 但 ① ② 重排后变成 ② ① → 可能出现 x=0, y=0 且 a=1, b=1 的诡异结果

为什么 ① 和 ② 可以重排?因为它们没有数据依赖——a = 1x = b 之间不存在"先算 a 才能算 x"的关系,编译器/CPU 认为交换顺序不影响单线程结果。

volatile 通过建立 happens-before 关系来阻止特定的重排:

volatile write 之前的所有操作 → happens-before → volatile write → happens-before → volatile read → happens-before → volatile read 之后的所有操作

具体来说:

  • volatile write 之前的普通写不会被重排到 volatile write 之后——保证其他线程看到 volatile flag 时,也能看到 flag 之前的所有写入。
  • volatile read 之后的普通读不会被重排到 volatile read 之前——保证读 volatile flag 之后,能读到 flag 之前的所有写入。
happens-before 关系的传递性:如果 A happens-before B,B happens-before C,那么 A happens-before C。volatile 就是那个"B"——一座桥梁。
第 4 站

不保证原子性:i++ 为什么用 volatile 还是不安全?

面试官最爱问的问题之一。先看代码:

VolatileAtomicTest.java
public class VolatileAtomicTest {
    static volatile int count = 0;

    public static void main(String[] args) throws Exception {
        CountDownLatch latch = new CountDownLatch(100);
        for (int i = 0; i < 100; i++) {
            new Thread(() -> {
                for (int j = 0; j < 1000; j++) {
                    count++; // 看似简单的自增
                }
                latch.countDown();
            }).start();
        }
        latch.await();
        System.out.println("count = " + count);
        // 期望:100000,实际:往往 < 100000
    }
}

问题出在 count++ 这个操作。它不是原子的——实际上它由 3 条指令组成:

count++ 的 3 步
// 第 1 步:read —— 从内存读取 count 的值
int temp = count;       // 比如读到 42

// 第 2 步:modify —— 在寄存器中加 1
temp = temp + 1;          // 变成 43

// 第 3 步:write —— 把新值写回内存
count = temp;            // 写入 43

volatile 只保证第 1 步读到的是最新值,但第 1 步和第 3 步之间,其他线程完全可以插入进来修改 count:

时间线线程 A线程 Bcount 值
T1read count → 4242
T2read count → 4242
T3modify: 42+1=4342
T4modify: 42+1=4342
T5write count = 4343
T6write count = 4343

两个线程各做了一次 count++,count 应该变成 44,结果却只变成了 43——丢失了一次更新。100 个线程各做 1000 次,丢失的更新越多,最终结果离 100000 就越远。

破解volatile 保证的是"读到的值是对的"(可见性),但不保证"从读到写之间没人改过"(原子性)。复合操作必须用 synchronized 或 AtomicXxx。
第 5 站

内存屏障:阻止指令重排的硬件机制

volatile 的可见性和有序性最终都要落到 CPU 指令层面。JVM 通过在 volatile 操作前后插入内存屏障(Memory Barrier)来实现这两大保证。

volatile 操作前后的内存屏障 volatile write 序列 普通 Store / Load 操作 StoreStore Barrier ← 确保上面的写先于 volatile 写完成 volatile Store(写操作) StoreLoad Barrier ← 确保 volatile 写先于下面的读完成 普通 Store / Load 操作 volatile read 序列 普通 Store / Load 操作 volatile Load(读操作) LoadLoad Barrier ← 确保下面的读在 volatile 读之后 LoadStore Barrier ← 确保下面的写在 volatile 读之后 普通 Store / Load 操作 四种屏障的含义 StoreStore :屏障之前的 Store 不会被重排到屏障之后 StoreLoad :屏障之前的 Store 不会被重排到屏障之后的 Load 之前(万能屏障) LoadLoad :屏障之后的 Load 不会被重排到屏障之前 LoadStore :屏障之后的 Store 不会被重排到屏障之前 x86 只对 Store-Load 重排敏感,所以 JVM 在 x86 上只在 volatile write 后插入 lock 前缀指令(等效于 StoreLoad)
图 2volatile write 前后插入 StoreStore + StoreLoad 屏障;volatile read 之后插入 LoadLoad + LoadStore 屏障

在 x86 架构上,CPU 本身不会对 Store-Store、Load-Load、Load-Store 进行重排,唯一会重排的是 Store-Load。所以 JVM 在 x86 上做了优化:volatile write 之后只需要插入一个 lock 前缀指令(等效于 StoreLoad 屏障),其他屏障实际上是空操作(no-op)。

为什么 StoreLoad 屏障最重要?因为"先写后读"是唯一会被 CPU 自由重排的组合——写操作还没刷到主内存,读操作就去读旧值,这是最常见的可见性问题。
第 6 站

DCL 单例为什么一定要加 volatile?

这是 Java 面试中最经典的问题之一。先看 Double-Checked Locking(DCL)的代码:

Singleton.java
public class Singleton {
    private static volatile Singleton instance; // 必须 volatile!

    public static Singleton getInstance() {
        if (instance == null) {                 // 第一次检查(无锁,快速路径)
            synchronized (Singleton.class) {
                if (instance == null) {           // 第二次检查(有锁,防止重复创建)
                    instance = new Singleton();    // ⚠️ 问题出在这里
                }
            }
        }
        return instance;
    }
}

问题出在 instance = new Singleton() 这一行。它不是原子操作——字节码层面它包含 3 条指令:

new Singleton() 的字节码
new        // ① 分配内存空间
dup
invokespecial // ② 调用构造方法初始化对象
putstatic   // ③ 把引用赋值给 instance 变量

在正常顺序下,执行顺序是 ① → ② → ③。但编译器和 CPU 可能将 ② 和 ③ 重排为 ① → ③ → ②:

步骤正常顺序重排后的顺序
分配内存分配内存
初始化对象把引用赋给 instance(对象还没初始化!)
把引用赋给 instance初始化对象

重排之后的灾难场景:

DCL 失败场景 · 时间线
// 线程 A 执行 getInstance()
T1: 分配内存
T2: instance = 引用          // 引用不为 null 了!但对象还没初始化

// 线程 B 执行 getInstance()
T3: if (instance == null)   // false!跳过 synchronized 块
T4: return instance          // 返回了一个半初始化的对象 → NPE 或逻辑错误

// 线程 A 继续
T5: 初始化对象               // 但线程 B 已经拿到了未初始化的引用

volatile 如何解决这个问题? 加入 volatile 后,putstatic(volatile write)之前会插入 StoreStore 屏障,阻止 ② 和 ③ 重排。保证其他线程看到 instance != null 时,对象一定已经初始化完毕。

面试金句DCL 不加 volatile 在 x86 上"可能"不会出问题(因为 x86 不做 Store-Store 重排),但在 ARM 等弱内存模型架构上必然出问题。面试时回答"必须加",并解释指令重排的原因。
第 7 站

volatile 的适用场景与不适用场景

知道什么时候 volatile 是基本功,知道什么时候不用 volatile 才是功力。

适合用 volatile 的场景:

场景 1:状态标志位
volatile boolean running = true;

// 工作线程
while (running) {
    // 干活...
}

// 管理线程
running = false; // 工作线程下次循环时一定能看到 false
场景 2:一次性安全发布
volatile Config config;

// 初始化线程
Config c = new Config(loadFromFile()); // 构造完成
config = c;  // volatile write:保证其他线程看到 config != null 时,Config 内部字段也已初始化

// 其他线程
if (config != null) {
    config.getPort(); // 一定能读到正确的值
}
场景 3:DCL 单例模式
// 见第 6 站,volatile 防止指令重排导致半初始化对象

不适合用 volatile 的场景:

场景问题正确方案
计数器 count++读-改-写不是原子的AtomicIntegersynchronized
条件检查后修改
if(!map.containsKey(k)) map.put(k,v)
检查和修改之间可能被其他线程插入ConcurrentHashMap.putIfAbsent()
多个变量的联合更新
x=1; y=2;
volatile 只保证单个变量的可见性synchronized

volatile vs AtomicXxx:

对比
// volatile:只保证可见性和有序性,适合简单的读写
volatile int flag = 0;
flag = 1;              // ✅ 单次写,安全
int x = flag;          // ✅ 单次读,安全
flag++;                // ❌ 读-改-写,不安全

// AtomicInteger:基于 CAS,保证原子性 + 可见性
AtomicInteger count = new AtomicInteger(0);
count.incrementAndGet(); // ✅ CAS 循环,原子操作
count.compareAndSet(0, 1); // ✅ CAS,原子操作
选择原则单次读或单次写 → volatile。需要读-改-写(CAS)→ AtomicXxx。多个操作需要整体原子性 → synchronized。
第 8 站

总结:volatile 的完整画像

用一张表把 volatile 的所有特性收拢起来:

特性是否保证实现机制
可见性volatile write 刷回主内存 + 通知其他核心缓存失效;volatile read 使缓存失效 + 重新读取
有序性插入内存屏障(StoreStore、StoreLoad、LoadLoad、LoadStore),阻止屏障两侧的指令重排
原子性只对单次读/单次写保证原子性(32 位 JVM 上的 long/double 除外),复合操作不安全

volatile 相关的 happens-before 规则:

规则 1:对同一个 volatile 变量,线程 A 的 write happens-before 线程 B 的 read
规则 2:volatile write 之前的所有操作 happens-before volatile write
规则 3:volatile read happens-before volatile read 之后的所有操作
规则 4(传递性):由规则 1+2+3,A 写之前的操作 → 对 B 读之后可见
面试回答模板
"volatile 保证可见性和有序性,不保证原子性。

 可见性:volatile write 会立即刷回主内存,并让其他核心的缓存行失效;
         volatile read 会使本地缓存失效,从主内存重新加载。

 有序性:通过在操作前后插入内存屏障,阻止编译器和 CPU 对屏障两侧的指令重排。
         例如 DCL 单例中,volatile 阻止了对象创建时 '赋值引用' 和 '初始化' 的重排。

 不保证原子性:count++ 是读-改-写三步操作,volatile 只保证读到的值是最新的,
              但三步之间可能被其他线程干扰,导致更新丢失。应该用 AtomicInteger。"
本路线回顾:
  • 第 1 站——面试官的开场白:volatile 保证什么?不保证什么?
  • 第 2 站——可见性:缓存失效 + 主内存刷新
  • 第 3 站——有序性:happens-before 阻止指令重排
  • 第 4 站——不保证原子性:count++ 的 3 步可以被交错
  • 第 5 站——内存屏障:StoreStore / StoreLoad / LoadLoad / LoadStore
  • 第 6 站——DCL 单例:new 操作的 3 条指令可能被重排
  • 第 7 站——适用场景:标志位、安全发布、DCL;不适用:复合操作
  • 第 8 站——总结:特性表 + happens-before 规则 + 面试回答模板