写了十几年 Java,面试也面了上千个后端候选人,只要我问到 volatile,几乎 95% 的人张口就是那三句话:
- 可见性
- 不保证原子性
- 禁止指令重排序
背得滚瓜烂熟,然后我再追问一句:“那可见性到底是怎么实现的?”
现场直接安静,顶多支支吾吾来一句“JVM 做了什么底层的东西吧”。
好,今天咱们就把这事彻底讲透,从真正让 volatile 生效的那层铁——CPU 和内存开始讲。
一、别再拿“主内存和工作内存”骗自己了
Java 内存模型(JMM)里那张经典的图把多少人带偏了:
线程A工作内存 ←→ 主内存 ←→ 线程B工作内存
这张图是故意抽象的,实际硬件根本不是这么玩的。
真实世界是这样的(随便拆一台 64 核机器看看):
L1 Cache (per core, 32KB~128KB) → L2 Cache → L3 Cache (shared) → DDR5 → [远端NUMA节点]
每个核心都有自己私有的 L1/L2,L3 是每 8~16 核共享的,主内存?那其实是所有 CPU 看得到的最终一致性边界,但访问延迟是 L1 的 100~200 倍。
所以问题来了:你改了一个变量,别的核心怎么知道?
二、MESI 协议:真正让 volatile 可见的幕后英雄
现代 x86/ARM64 用的都是 MESI 协议的变种(MESIF、MOESI),核心就 4 个状态:
| 状态 | 含义 | 是否可读 | 是否可写 |
|---|---|---|---|
| M (Modified) | 本地缓存已修改,主内存旧了 | 是 | 是 |
| E (Exclusive) | 本地独占,主内存一致 | 是 | 是 |
| S (Shared) | 多个核心缓存一致,主内存也一致 | 是 | 否 |
| I (Invalid) | 本缓存行无效,必须从别处重新加载 | 否 | 否 |
画个最经典的场景你就懂了:
初始:两个核心都把变量 x = 0 加载进自己的 L1,状态都是 S (Shared)
Core 0 执行:x = 1
→ Core 0 先把自己的缓存行变成 M (Modified)
→ 同时发出 Invalidate 广播
→ Core 1 收到后把自己的缓存行标记为 I (Invalid)
Core 1 下次读 x
→ Cache Miss → 走总线去 Core 0 那里偷数据(或者写回主内存后再读)
→ 拿到最新值 1
这就是 volatile 可见性的底层实现。
三、volatile 到底在字节码里插了什么?
我们写段最简单的代码:
public class VolatileDemo {
volatile boolean flag = false;
public void flip() {
flag = true; // 写
}
public void check() {
if (flag) { // 读
System.out.println("flipped!");
}
}
}
用 javap -c 看字节码:
putfield #2 <VolatileDemo/flag> ; 普通写
getfield #2 <VolatileDemo/flag> ; 普通读
好像啥都没有?别急,真正的东西在 JIT 编译器里。
HotSpot 在 C2 编译器里会对 volatile 写插入:
movb $0x1, 0x8(%rbx) ; 真正写值
lock addl $0x0, (%rsp) ; ← 这条才是关键!Lock 前缀
这条 lock addl $0, (%rsp) 干了三件事:
- 立刻把本核所有 Store Buffer 里的数据刷到 L1(Store Buffer Forwarding 失效)
- 发出跨核的 Invalidate 信号,让其他核的缓存行失效
- 强制本核后面的读操作不能重排序到这句之前(Acquire 语义)
而 volatile 读后面会跟一条普通的 mov + 编译器内存屏障,确保前面的读不会被重排到读之后(Release 语义)。
这就是所谓的“写带 lock,读带 barrier”。
四、为什么 long/double 也需要 volatile?
64 位 JVM 在 32 位机器上会把 long/double 拆成两次 32 位写,如果不加 volatile,可能出现高 32 位是新值、低 32 位是旧值的“撕裂读”。
但现在几乎所有服务器都是 64 位 CPU,单条指令就能写 8 字节,所以这点其实已经不是问题了。
但规范仍然要求你加 volatile,因为 JMM 是平台无关的,它得照顾最差的情况。
五、一个很多人没见过的真香案例:单例双检的正确姿势
public class Singleton {
private static volatile Singleton instance; // 必须 volatile!
private Singleton() {}
public static Singleton getInstance() {
if (instance == null) {
synchronized (Singleton.class) {
if (instance == null) {
instance = new Singleton(); // 这里有三步!
}
}
}
return instance;
}
}
new Singleton() 实际上是三步:
- malloc 分配内存
- 调用构造方法
- 把对象引用赋给 instance
没有 volatile,JIT 可能把 2 和 3 重排序,导致别的线程看到一个“半初始化”的对象。
volatile 的内存屏障正好禁止了这种重排序。
六、性能到底有多大损失?
很多人怕 volatile 慢,其实在现代 CPU 上:
- 普通变量写:Store Buffer → 几十纳秒后刷到 L1
- volatile 写:强制立即刷 + 跨核失效,大概 20~80 纳秒(取决于核间距离)
比你想象的要快得多。
真正慢的是频繁的 cache line 乒乓(false sharing),而不是 volatile 本身。
七、写在最后
下次再有人跟你背那三句话,你可以直接问他:
“你知道 lock addl $0,(%rsp) 干了啥吗?”
能答上来再说 volatile。
我面试的时候,只要候选人能把 MESI + Store Buffer + Lock 前缀讲清楚,offer 当场就批了。
底层这点东西,学透了,你写并发代码永远不会再掉坑里。
2480

被折叠的 条评论
为什么被折叠?



