Lesson 13 · 并发编程
volatile 深入:可见性、有序性与 happens-before
从一道面试题开始
面试官看着你的简历,不紧不慢地问了一句:
"不能。"——如果你只回答这两个字,面试就到此为止了。
面试官真正想听的是:volatile 保证可见性和有序性,但不保证原子性。紧接着,他会追问:
- 可见性是怎么实现的?缓存失效?内存屏障?
- 有序性是什么意思?指令重排为什么危险?
- 为什么
i++用 volatile 不行? - DCL 单例为什么一定要加 volatile?
这篇文章把 volatile 拆成 8 站,从可见性到内存屏障,从 happens-before 到 DCL 单例,一站一站把 volatile 讲透。
发车之前,先问自己:
- volatile 写操作后,CPU 缓存做了什么?
- StoreStore 屏障和 StoreLoad 屏障分别插在哪里?
- 对象创建的 3 条指令为什么可能被重排?
带着这些问题,我们开始。
可见性:一个线程改了值,其他线程立刻能看到
现代 CPU 都有多级缓存(L1、L2、L3),每个核心有自己独立的 L1/L2 缓存。Java 线程运行在不同的 CPU 核心上时,对同一个变量的读写可能发生在各自的缓存里——线程 A 修改了变量,值还在 L1 缓存中,线程 B 根本看不到。
volatile 保证可见性的机制可以概括为两条规则:
volatile write(写操作):
// 1. 将新值立即刷新到主内存
// 2. 通过 MESI 协议通知其他 CPU 核心:这个缓存行无效
volatile read(读操作):
// 1. 使当前 CPU 缓存中该变量的缓存行失效
// 2. 从主内存重新读取最新值
底层实现依赖 CPU 的 缓存一致性协议(如 MESI)和 Lock 前缀指令。JVM 会在 volatile 操作时插入特定的 CPU 指令(如 lock 前缀),强制把缓存行刷回主内存。
有序性:编译器、JVM、CPU 都在偷偷调顺序
为了提高性能,编译器和 CPU 会对指令进行重排序。只要最终结果在单线程中不变,重排就是合法的。但在多线程环境下,重排可能导致灾难。
来看一个经典例子:
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 = 1 和 x = b 之间不存在"先算 a 才能算 x"的关系,编译器/CPU 认为交换顺序不影响单线程结果。
volatile 通过建立 happens-before 关系来阻止特定的重排:
具体来说:
- volatile write 之前的普通写不会被重排到 volatile write 之后——保证其他线程看到 volatile flag 时,也能看到 flag 之前的所有写入。
- volatile read 之后的普通读不会被重排到 volatile read 之前——保证读 volatile flag 之后,能读到 flag 之前的所有写入。
不保证原子性:i++ 为什么用 volatile 还是不安全?
面试官最爱问的问题之一。先看代码:
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 条指令组成:
// 第 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 | 线程 B | count 值 |
|---|---|---|---|
| T1 | read count → 42 | 42 | |
| T2 | read count → 42 | 42 | |
| T3 | modify: 42+1=43 | 42 | |
| T4 | modify: 42+1=43 | 42 | |
| T5 | write count = 43 | 43 | |
| T6 | write count = 43 | 43 |
两个线程各做了一次 count++,count 应该变成 44,结果却只变成了 43——丢失了一次更新。100 个线程各做 1000 次,丢失的更新越多,最终结果离 100000 就越远。
内存屏障:阻止指令重排的硬件机制
volatile 的可见性和有序性最终都要落到 CPU 指令层面。JVM 通过在 volatile 操作前后插入内存屏障(Memory Barrier)来实现这两大保证。
在 x86 架构上,CPU 本身不会对 Store-Store、Load-Load、Load-Store 进行重排,唯一会重排的是 Store-Load。所以 JVM 在 x86 上做了优化:volatile write 之后只需要插入一个 lock 前缀指令(等效于 StoreLoad 屏障),其他屏障实际上是空操作(no-op)。
DCL 单例为什么一定要加 volatile?
这是 Java 面试中最经典的问题之一。先看 Double-Checked Locking(DCL)的代码:
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 // ① 分配内存空间
dup
invokespecial // ② 调用构造方法初始化对象
putstatic // ③ 把引用赋值给 instance 变量
在正常顺序下,执行顺序是 ① → ② → ③。但编译器和 CPU 可能将 ② 和 ③ 重排为 ① → ③ → ②:
| 步骤 | 正常顺序 | 重排后的顺序 |
|---|---|---|
| ① | 分配内存 | 分配内存 |
| ② | 初始化对象 | 把引用赋给 instance(对象还没初始化!) |
| ③ | 把引用赋给 instance | 初始化对象 |
重排之后的灾难场景:
// 线程 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 时,对象一定已经初始化完毕。
volatile 的适用场景与不适用场景
知道什么时候用 volatile 是基本功,知道什么时候不用 volatile 才是功力。
适合用 volatile 的场景:
volatile boolean running = true;
// 工作线程
while (running) {
// 干活...
}
// 管理线程
running = false; // 工作线程下次循环时一定能看到 false
volatile Config config;
// 初始化线程
Config c = new Config(loadFromFile()); // 构造完成
config = c; // volatile write:保证其他线程看到 config != null 时,Config 内部字段也已初始化
// 其他线程
if (config != null) {
config.getPort(); // 一定能读到正确的值
}
// 见第 6 站,volatile 防止指令重排导致半初始化对象
不适合用 volatile 的场景:
| 场景 | 问题 | 正确方案 |
|---|---|---|
| 计数器 count++ | 读-改-写不是原子的 | AtomicInteger 或 synchronized |
条件检查后修改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 的完整画像
用一张表把 volatile 的所有特性收拢起来:
| 特性 | 是否保证 | 实现机制 |
|---|---|---|
| 可见性 | 是 | volatile write 刷回主内存 + 通知其他核心缓存失效;volatile read 使缓存失效 + 重新读取 |
| 有序性 | 是 | 插入内存屏障(StoreStore、StoreLoad、LoadLoad、LoadStore),阻止屏障两侧的指令重排 |
| 原子性 | 否 | 只对单次读/单次写保证原子性(32 位 JVM 上的 long/double 除外),复合操作不安全 |
volatile 相关的 happens-before 规则:
规则 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 规则 + 面试回答模板