volatile 禁止重排序
候选人小钱在面试 B站 P6 时,面试官问道:
"volatile 除了保证可见性,还能禁止指令重排序。能具体说说它是怎么做到的吗?"
小钱说:"通过内存屏障..."面试官追问:"那 volatile 读写分别插入的是什么屏障?sfence、lfence 还是 mfence?"
小钱支支吾吾答不上来。面试官继续追问:"volatile 写在 volatile 读之前还是之后?两个 volatile 写之间呢?"
小钱彻底哑火了...
一、核心问题:volatile 如何禁止重排序 🔴
1.1 问题拆解
1.2 ❌ 错误示范
候选人原话 A:"volatile 禁止所有指令重排序。"
问题诊断:这是不对的。volatile 只禁止与该 volatile 变量相关的重排序,不禁止其他普通变量的重排序。volatile 的屏障只影响与屏障相邻的 volatile 读/写操作的顺序。
候选人原话 B:"x86 下 volatile 读和 volatile 写代价一样大。"
问题诊断:在 x86 架构下,volatile 读的代价很小(因为 x86 的 TSO 模型天然保证读不重排序到写之前),但 volatile 写的代价较大(需要 lock 前缀指令)。在 ARM 架构下,两者代价都很大。
1.3 标准回答
P5 级别:说清禁止的类型
为什么需要禁止重排序?
编译器和 CPU 会进行指令重排序以提升性能,但重排序可能破坏多线程程序的正确性。as-if-serial 语义允许单线程内自由重排序,但多线程场景下,重排序可能导致可见性问题。
volatile 禁止的重排序:
简单记忆:屏障的"前"侧禁止重排序到"后"侧,屏障的"后"侧禁止重排序到"前"侧。
P6 级别:四种内存屏障详解
四种基本内存屏障:
x86 架构的具体实现:
为什么 volatile 读也隐含 loadstore?
因为如果 volatile 读之后是普通写,这个普通写可能被重排序到 volatile 读之前,导致错误。例如:
P7 级别:x86 TSO vs ARM 弱内存模型
x86 的 TSO(Total Store Order)模型:
x86 实现了强内存模型,基本保证:
- Store-Load 不重排序(写后读不会读旧值)
- Store-Store 不重排序(写操作的顺序与程序一致)
因此 x86 上 volatile 写的主要开销是 lock 前缀的全局总线事务(广播 invalidate),volatile 读开销极小(天然顺序)。
ARM 的弱内存模型:
ARM 是弱内存模型,几乎所有重排序都可能发生:
- Store-Load 可重排序
- Load-Load 可重排序
- Store-Store 可重排序
所以 ARM 上必须使用显式屏障指令:
dmb ish:内存屏障(full)dmb ishst:写屏障(store barrier)dmb ishld:读屏障(load barrier)JMM 的跨平台设计:JIT 编译器在生成字节码时,在 volatile 操作前后插入适当的内存屏障。这些屏障在不同平台上生成不同的机器指令。Java 程序员不需要关心平台差异,只需要记住 Happens-Before 规则。
一个实战细节:在 x86 上,
volatile的性能开销主要在写操作(约 30~100 纳秒,跨核通信延迟),读操作几乎无开销。所以在"一写多读"场景下,volatile 是非常高效的选择。
【面试官心理】 这道题我能问到 P7 级别。能说出四种屏障类型并解释何时需要哪种屏障的候选人,说明他理解了 volatile 的本质而非只是背诵。能比较 x86 和 ARM 内存模型差异的候选人,说明他对硬件有了解。能说出 x86 上 volatile 读开销很小的候选人,说明他有过实际性能优化的经验。
1.4 追问升级
追问 1:final 字段在 JMM 中有禁止重排序的保证吗?
JSR-133 对 final 字段有特殊规则:
- 禁止将 final 字段的写重排序到构造函数之外(通过在构造函数末尾插入 storestore 屏障实现)
- 前提:this 引用不能在构造函数中逸出
这使得不可变对象的 final 字段在发布后对所有线程可见,即使不使用 volatile。这个规则使得 String 的 value 数组(final + private)可以安全地在无锁情况下共享。
追问 2:synchronized 块中的代码会被重排序吗?
synchronized 块中的代码可能被重排序,但必须遵守以下约束:
- synchronized 块内的代码不会重排序到 synchronized 块外
- synchronized 块外的代码不会重排序到 synchronized 块内
- synchronized 块内的代码在 synchronized 块内部可能重排序(只要不违反语义)
这是 synchronized 实现中的"管程(Monitor)"语义的一部分。
二、volatile 与 Happens-Before 的推导 🟡
2.1 完整推导链
推导过程:
- a HB b(程序顺序规则)
- b HB 循环条件成立(volatile 变量规则)
- d HB ... (普通读没有 Happens-Before 保证,除非通过其他手段)
关键点:c = 1(普通写)和 b = 1(volatile 写)之间,volatile 写规则保证了 b HB c?错!普通写和 volatile 写之间没有 Happens-Before 关系——a 和 c 是普通写,不受 volatile 影响。
所以线程 B 不一定能看到 c 的最新值。这就是为什么有时 a = 1; b = 1; 重排序后,b 可能先执行(因为 volatile 写有 storeload 屏障,但普通写在 volatile 写之前的 storestore 屏障只保证"volatile 写之前的普通写在 volatile 写之前")。
2.2 volatile 写的 storestore 屏障细节
storestore barrier 的作用是:禁止屏障之前的普通写重排序到屏障之后的任何写操作之后。
即:
a = 1(普通写)不能被重排序到b = 1(volatile 写)之后。但a = 1可以被重排序到b = 1之前(这是 storestore 屏障允许的)。这保证了:当其他线程看到 volatile 写
b = 1时,之前的普通写a = 1一定已经完成。
三、生产避坑
3.1 错误的链式依赖
问题:误以为 volatile 写 y 能保证之前的普通写 x 对其他线程可见。实际上只有 volatile 变量本身被保证。
3.2 单例模式的 volatile 必须性
面试加分点:能说出"JDK 9+ 的 VarHandle 提供了 setPlain/setOpaque/setRelease/getAcquire 等细粒度内存排序操作,Opaque 模式比 volatile 代价更低但不保证 Happens-Before"。这说明你对 JMM 的演进有跟进。
面试陷阱:被问到"两个 volatile 写之间有 Happens-Before 关系吗",很多人会说"有"。错!volatile 写 HB volatile 读(读写配对),但两个 volatile 写之间没有直接的 Happens-Before 关系,除非通过传递性(程序顺序规则)间接建立。