Skip to content
Go back

volatile的内存屏障——为什么DCL必须volatile

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 的两大核心问题

  1. 可见性:线程 A 修改了变量,线程 B 为什么看不到?
  2. 有序性: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 或读到未初始化值。

volatileStoreStore 屏障禁止步骤 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 ADDLMFENCE——清空 Store Buffer + 处理 Invalidate Queue。这就是 volatile 写比普通写慢几十倍的原因。

想一想:既然 volatile 写这么贵,它适合用在什么场景? 适合一写多读的标志位/开关(如 shutdown 标志、DCL 的 instance):写很稀疏、读很频繁,读端没有额外开销(volatile 读不刷缓存,只是插入轻量屏障)。不适合 i++ 计数器、多写场景,因为既要付 StoreLoad 的昂贵代价、又保证不了复合操作的原子性——那种场景该交给 AtomicInteger 或锁。

总结

问题原因volatile 解法
可见性Store Buffer / Invalidate QueueStoreLoad 屏障 drain 写缓冲
有序性JIT/CPU 指令重排StoreStore 屏障禁止关键重排
DCL 半成品构造器和引用赋值可重排volatile 禁止 3 在 2 之前

章末提问

  1. volatile 为什么能保证可见性但不能保证原子性? 结论:因为可见性和原子性是两回事——volatile 靠写后 StoreLoad 屏障解决「改的值别人能不能看到」,但 i++ 这种读-改-写复合操作仍然会被别的线程插入打断。因为屏障只同步缓存、不提供互斥,两个线程仍可能同时读到旧值再各自写回,丢更新。

  2. DCL 单例为什么要加 volatile? 结论:因为 new Singleton() 的「分配内存 → 初始化 → 引用赋值」可被 JIT 重排为「分配 → 引用赋值 → 初始化」,不加 volatile 别的线程会拿到半成品对象。因为 volatile 写前的 StoreStore 屏障禁止引用赋值跑到初始化之前。

  3. StoreLoad 为什么是最昂贵的屏障? 结论:因为它要同时 drain 本 CPU 的 Store Buffer 和处理 Invalidate Queue,等于一次全局同步,x86 上落成 MFENCE。因为其它屏障只禁局部重排,而它要跟所有核的缓存通信。

  4. volatile 适合什么场景、不适合什么场景? 结论:适合一写多读的标志位(shutdown 开关、DCL 的 instance);不适合 i++ 计数器等多写或读-改-写场景。因为写端要付昂贵 StoreLoad 代价,且 volatile 保证不了复合操作的原子性,那种场景该用 AtomicInteger 或锁。

  5. 可见性和有序性分别由哪个屏障解决? 结论:可见性靠写后的 StoreLoad 屏障强制 drain 写缓冲;有序性靠写前的 StoreStore 屏障禁止关键重排。因为前者保证新值传播,后者保证赋值/初始化顺序不被 JIT/CPU 打乱。


Share this post on:

Previous Post
MySQL InnoDB的BufferPool——分区LRU与Double Write
Next Post
Unsafe类——JUC所有无锁结构的原子操作基石