volatile 的内存屏障:为什么 DCL 必须 volatile?
一句话结论(30s)
volatile 的本质是通过内存屏障同时解决可见性和有序性两个问题——写后插 StoreLoad 屏障强制 drain 写缓冲让所有 CPU 看到最新值,写前插 StoreStore 屏障禁止关键重排。它的关键设计是「写前 StoreStore、写后 StoreLoad;读后 LoadLoad、LoadStore」这套屏障插入规则,DCL 单例之所以必须加 volatile,就是因为 instance = new Singleton() 的「分配内存 → 初始化 → 引用赋值」三步可被重排为「分配 → 赋值 → 初始化」,不加屏障其他线程会拿到半成品对象。核心权衡是 StoreLoad 是最昂贵的屏障(等于一次 CPU 与缓存的全局同步,x86 上就是 MFENCE),所以 volatile 只适合一写多读的标志位场景。
核心原理(2min)
主流程:可见性问题的根源是 CPU 的 Store Buffer 和 Invalidate Queue——线程 A 的写缓存在 Store Buffer 未广播,线程 B 的失效消息在 Invalidate Queue 未处理,于是 B 读到旧值;volatile 写后的 StoreLoad 屏障强制 drain Store Buffer,让新值对所有人可见。有序性问题的根源是 JIT/CPU 会重排无依赖的指令,volatile 的 StoreStore 屏障禁止「引用赋值」跑到「构造器初始化」之前。关键机制是四大屏障各管一段重排(LoadLoad / StoreStore / LoadStore / StoreLoad),其中 StoreLoad 要同时 drain 写缓冲又处理失效队列,所以最贵——这就是 volatile 写比普通写慢几十倍的原因。
底层深入(5-10min)
JMM 的两大核心问题
- 可见性:线程 A 修改了变量,线程 B 为什么看不到?
- 有序性:JIT/CPU 为什么要打乱指令顺序?
可见性:硬件层面
线程A: x = 1 线程B: 读 x
↓ Store Buffer ↑ Invalidate Queue
CPU A 的写缓存在 Store CPU B 的失效消息在 Invalidate
Buffer 中,未广播 Queue 中,未处理
→ 线程 B 读到的是旧值(自己的 Cache Line)
volatile 写之后插 StoreLoad 屏障 → 强制 drain Store Buffer(刷新写缓冲)→ 所有 CPU 都能看到最新值。
想一想:volatile 能保证可见性,为什么保证不了原子性? 因为可见性解决的是「改了的值别人能不能看到」,而原子性解决的是「读-改-写这三步中间会不会被别人插入」。i++ 在字节码层面是「读 i → 加 1 → 写回 i」三步,volatile 只保证最后一步写回的 i 对所有人可见,但不保证这三步作为一个整体不被别的线程打断——两个线程可能同时读到 1、各自加 1、再写回 2,最终丢了一次自增。可见性是「传播」问题,原子性是「互斥」问题,两者是不同维度的保证。
有序性:DCL 的 “半成品”问题
// instance = new Singleton() 在字节码层面是三步:
1. allocate memory // 分配内存
2. call <init> // 调用构造器(初始化)
3. astore // 将引用赋值给 instance 变量
// 但 JIT 可以重排为: 1 → 3 → 2
// 引用指向了有地址但还没初始化的内存 → "半成品对象"
另一个线程外围检查 instance != null → 拿到半成品 → 访问字段时 NullPointerException 或读到未初始化值。
volatile 的 StoreStore 屏障禁止步骤 3 在步骤 2 之前执行,保证 instance 指向的是完全初始化的对象。
想一想:为什么加个 volatile 就能让 DCL 安全? 因为 instance = new Singleton() 的三步「分配 → 初始化 → 引用赋值」本可被 JIT 重排成「分配 → 引用赋值 → 初始化」,一旦引用先赋值,别的线程外围检查 instance != null 通过后就会拿到还没初始化的半成品。volatile 写前的 StoreStore 屏障禁止「引用赋值」跑到「构造器初始化」之前,保证赋值时对象已经完整构造——于是别的线程看到 instance 非空时,它一定是个成品。
四大内存屏障
| 屏障 | 效果 | 重排禁止 |
|---|---|---|
| LoadLoad | 确保 Load1 在 Load2 之前执行 | Load-Load |
| StoreStore | 确保 Store1 在 Store2 之前执行 | Store-Store |
| LoadStore | 确保 Load1 在 Store2 之前执行 | Load-Store |
| StoreLoad | 确保 Store1 在 Load2 之前执行,且 Store1 对 Load2 可见 | Store-Load |
StoreLoad 是最昂贵的屏障——它同时执行 drain StoreBuffer + 处理 Invalidate Queue,相当于 CPU 和缓存之间的一次全量同步。在 x86 上编译为 MFENCE 指令。这就是为什么 volatile 只适合一写多读的”标志位”场景。
想一想:StoreLoad 为什么是四大屏障里最贵的? 因为它要同时做两件方向相反的事:既要把本 CPU 的 Store Buffer 全部 drain(把写推给所有人),又要处理 Invalidate Queue(接收别人失效自己缓存的请求),等于一次 CPU 与缓存之间的全量同步。其它三个屏障只是「禁止某种局部重排」,不涉及与所有核的通信,而 StoreLoad 在 x86 上要落到 MFENCE 这种全同步指令,所以 volatile 写比普通写慢几十倍。
volatile 写/读的屏障插入
// volatile 写:
StoreStore 屏障
写入 volatile 变量
StoreLoad 屏障 ← 最昂贵的全同步
// volatile 读:
读取 volatile 变量
LoadLoad 屏障
LoadStore 屏障
写的 StoreLoad 屏障在 x86 上走 LOCK ADDL 或 MFENCE——清空 Store Buffer + 处理 Invalidate Queue。这就是 volatile 写比普通写慢几十倍的原因。
想一想:既然 volatile 写这么贵,它适合用在什么场景? 适合一写多读的标志位/开关(如 shutdown 标志、DCL 的 instance):写很稀疏、读很频繁,读端没有额外开销(volatile 读不刷缓存,只是插入轻量屏障)。不适合 i++ 计数器、多写场景,因为既要付 StoreLoad 的昂贵代价、又保证不了复合操作的原子性——那种场景该交给 AtomicInteger 或锁。
总结
| 问题 | 原因 | volatile 解法 |
|---|---|---|
| 可见性 | Store Buffer / Invalidate Queue | StoreLoad 屏障 drain 写缓冲 |
| 有序性 | JIT/CPU 指令重排 | StoreStore 屏障禁止关键重排 |
| DCL 半成品 | 构造器和引用赋值可重排 | volatile 禁止 3 在 2 之前 |
章末提问
-
volatile 为什么能保证可见性但不能保证原子性? 结论:因为可见性和原子性是两回事——volatile 靠写后 StoreLoad 屏障解决「改的值别人能不能看到」,但
i++这种读-改-写复合操作仍然会被别的线程插入打断。因为屏障只同步缓存、不提供互斥,两个线程仍可能同时读到旧值再各自写回,丢更新。 -
DCL 单例为什么要加 volatile? 结论:因为
new Singleton()的「分配内存 → 初始化 → 引用赋值」可被 JIT 重排为「分配 → 引用赋值 → 初始化」,不加 volatile 别的线程会拿到半成品对象。因为 volatile 写前的 StoreStore 屏障禁止引用赋值跑到初始化之前。 -
StoreLoad 为什么是最昂贵的屏障? 结论:因为它要同时 drain 本 CPU 的 Store Buffer 和处理 Invalidate Queue,等于一次全局同步,x86 上落成 MFENCE。因为其它屏障只禁局部重排,而它要跟所有核的缓存通信。
-
volatile 适合什么场景、不适合什么场景? 结论:适合一写多读的标志位(shutdown 开关、DCL 的 instance);不适合 i++ 计数器等多写或读-改-写场景。因为写端要付昂贵 StoreLoad 代价,且 volatile 保证不了复合操作的原子性,那种场景该用 AtomicInteger 或锁。
-
可见性和有序性分别由哪个屏障解决? 结论:可见性靠写后的 StoreLoad 屏障强制 drain 写缓冲;有序性靠写前的 StoreStore 屏障禁止关键重排。因为前者保证新值传播,后者保证赋值/初始化顺序不被 JIT/CPU 打乱。