JAVA · Vol.II · DAY 20 · 并发编程
JMM 内存模型:happens-before 八条规则全解
一次"改不生效"的线上事故
凌晨 1 点 20 分,值班电话把你叫醒:新版本刚发布,灰度节点上的老逻辑还在跑,流量没有切过去。你登上去一看——配置中心明明把开关 flag 置成了 true,服务端日志也显示"已收到新配置",可业务线程读到的 flag 一直是 false。重启节点,好了;半小时后,又坏了。
是配置中心的问题吗?不是。是 flag 的锅——它是个普通 boolean,每个线程在自己的工作内存里缓存了旧值 false,主线程改的 true 它压根没重新读。这就是并发编程里最经典的可见性(Visibility)问题,而它的理论根源,就是本文的主角 JMM(Java Memory Model,Java 内存模型)。
flag = true,另一个线程却一直读到 false——是 Bug 吗?严格说不是代码 Bug,是没遵守 JMM 规则。" —— 面试官想考察的不是你会不会用 synchronized,而是你是否真正理解并发编程的底层规则。先给你一个 30 秒版答案,后面每一站都在给这句话"上细节":
JMM 是 JSR-133 定义的跨平台并发规范:主内存 + 工作内存 + happens-before 八条规则 + 内存屏障。
并发三性各有人负责:原子性靠 synchronized / Lock / CAS;可见性靠 volatile / synchronized / final;有序性靠 volatile / happens-before 规则。
遇到并发 Bug,先别急着加锁,用 happens-before 判断"到底缺哪条规则"。
在日常工作中,JMM 是你写的每一行并发代码的"交通规则":synchronized、volatile、ReentrantLock、线程池、CompletableFuture……这些工具的正确性,全部建立在这套规则之上。
本文的路线图:
- 主内存与工作内存 —— JMM 的抽象架构(第 2 站)
- 为什么需要 JMM —— 各 CPU 内存模型不一致,需要统一交规(第 3 站)
- 8 大原子操作 —— 主内存/工作内存的交互协议(第 4 站)
- 可见性、原子性、有序性 —— 并发三大问题(第 5 站)
- 指令重排序 —— 编译器与 CPU 的"自作主张"(第 6 站)
- happens-before 八条规则 —— JMM 的核心法则(第 7–10 站)
- 内存屏障 —— volatile 背后的硬件机制(第 11–12 站)
- final 域语义 —— 免同步的"安全通道"(第 14 站)
- JMM vs JVM 内存模型 —— 澄清最常见的概念混淆(第 16 站)
主内存与工作内存:JMM 的抽象架构
JMM 在逻辑上把内存划分为两个区域:主内存(Main Memory)和工作内存(Working Memory)。
- 主内存:所有线程共享,存放所有变量(实例字段、静态字段、数组元素)
- 工作内存:每个线程私有,是主内存中变量的本地副本(类比 CPU 缓存)
把主内存想象成公司的中央数据库,把每个线程的工作内存想象成你工位上贴着的便利贴:你算出来的中间数字先记在便利贴上,只有你主动"同步回数据库",别人(其它线程)才能查到;别人也只有在"重新拉取"之后,才能看到你写的新值。问题在于——什么时候同步、什么时候拉取,时机不确定。有人 1 毫秒就同步了,有人攒着 100 毫秒才同步,于是同一个变量在不同线程眼里就是不同的值。
线程对变量的所有操作(读/写)都必须在自己的工作内存中进行,不能直接操作主内存。这就意味着:
- 读变量:从主内存拷贝到工作内存 → 使用
- 写变量:在工作内存中修改 → 刷回主内存
问题在于——刷回的时机不确定。如果一个线程修改了变量但没有及时刷回主内存,或者另一个线程没有从主内存重新加载,就会出现"看不到"的情况。
MESI 只保证单个缓存行在硬件层面的一致性,它管不了两件事:① 编译器和 CPU 的指令重排序(第 6 站讲);② 写缓冲区(Store Buffer)造成的"写后读自己、别人读旧值"的延迟。而且不同架构的缓存一致性实现差异巨大——JMM 干脆抽象出"主内存 + 工作内存"这套统一模型,把底层差异全部屏蔽掉。这就是为什么说 JMM 是"规范",不是"物理实现"。
- 所有共享变量都在主内存,线程只操作自己的工作内存副本;
- 可见性问题 = 工作内存副本没刷回 / 没重读,时机不确定;
- "工作内存"是抽象概念,物理对应寄存器 + 缓存 + 编译器临时存储,不是 JVM 栈。
为什么需要 JMM:各 CPU 的内存模型不一样
面试官如果追问一句"JMM 是怎么来的",你要能答出背后的硬件现实:现代 CPU 为了性能,普遍做了三件"越界"的事——
- 写缓冲区(Store Buffer):写操作先进缓冲区,稍后再落缓存/内存,导致"别人看到旧值";
- 乱序执行(Out-of-Order Execution):CPU 发现指令间没有数据依赖,就提前执行后面的指令;
- 多级缓存:L1/L2/L3 各自为政,缓存一致性协议只能解决"缓存行"级别的一致。
更要命的是,不同架构的"宽松程度"完全不同:
| 架构 | 内存模型 | 强弱 | 特点 |
|---|---|---|---|
| x86/x64 | TSO(Total Store Order) | 强内存模型 | 仅允许 Store→Load 重排;日常开发的主力环境 |
| ARM | 弱内存模型 | 弱 | 几乎所有重排都可能发生,需要更多屏障 |
| POWER | 弱内存模型 | 更弱 | 比 ARM 更宽松,屏障要求更高 |
同一段 Java 代码,不加任何同步,在 x86 上可能"碰巧能跑对",搬到 ARM 服务器(手机、云上 ARM 实例)上就出并发问题。怎么办?两个方案:
- 方案 A:为每个架构各写一套代码——维护成本爆炸,且 Java 号称"一次编写,到处运行";
- 方案 B:在语言层面定义一套统一的内存模型,由 JVM 负责把它翻译成各架构需要的屏障——这就是 JMM 的定位。
一段不得不说的历史:JSR-133
JMM 不是一开始就这么完善。Java 5 之前的内存模型(旧 JMM)有三大硬伤:volatile 语义太弱(不能阻止重排)、final 字段没有任何可见性保证、happens-before 规则残缺。结果是 DCL(双重检查锁)在旧模型下是错的、final 字段可能看到默认值——全是著名大坑。2004 年,JSR-133 正式重写了内存模型(JLS 第 17 章),确立了以 happens-before 为核心的新体系,这些坑才被系统性修复。所以"JDK 5 之前 DCL 不靠谱"这种说法,根源就在 JSR-133。
- CPU 有写缓冲区、乱序执行、多级缓存,且 x86 / ARM / POWER 的宽松程度天差地别;
- JMM 的存在意义 = 跨平台统一 + 只给最小保证、给优化留空间;
- JSR-133(Java 5)重写 JMM,才有了今天的 happens-before 体系。
8 大原子操作:主内存与工作内存的交互协议
为了规范主内存和工作内存之间的数据搬运,JMM 定义了 8 种原子操作(Atomic Operations)——所谓"原子",就是这 8 个动作每一个都不可再拆分:
// 主内存操作:
lock → 锁定主内存中的变量(标识为线程独占)
unlock → 解锁主内存中的变量
read → 从主内存读取变量值(传输到工作内存)
write → 将工作内存的值写入主内存
// 工作内存操作:
load → 将 read 得到的值放入工作内存副本
store → 将工作内存的值传送到主内存(配合 write)
use → 将工作内存的值传递给执行引擎
assign → 将执行引擎的值赋给工作内存变量
一次完整的变量读取:read → load → use;一次完整的变量写入:assign → store → write。问题就出在 assign 到 write 之间存在不确定的延迟——赋值早已发生在工作内存,但什么时候 store + write 刷回主内存,JMM 不保证。
这 8 个操作是 JMM 早期(JSR-133 草案阶段)提出的交互协议,帮你理解"主内存/工作内存怎么协作"。但JSR-133 正式落地后,规范层面(JLS 第 17 章)已经改用 happens-before 作为主模型来描述可见性——8 操作退居"教学工具"的地位。面试时先讲 8 操作,再补一句"不过现在规范以 happens-before 为准",面试官就知道你有体系,而不是背书。
lock / unlock 正是 synchronized 在 JMM 层面的对应物:进入同步块时对锁对象执行 lock,退出时执行 unlock。unlock 之前的所有写操作必须已刷回主内存,lock 之后的所有读操作必须重新从主内存加载——这就是"解锁的写对加锁的读可见"的底层来源(第 8 站规则 2)。
- 8 操作分两组:主内存侧 lock/unlock/read/write,工作内存侧 load/store/use/assign;
- 读 = read→load→use,写 = assign→store→write,中间环节的"时机"不受控;
- 现代规范以 happens-before 为主,8 操作用于理解分工。
并发三大问题:可见性、原子性、有序性
JMM 的存在就是为了解决多线程的三大问题。每一种都有经典的代码案例——面试让你"手写一个并发 Bug",这三个就是现成模板。
1. 可见性(Visibility):一个线程的修改,另一个线程看不到
根源:工作内存未及时同步。典型场景就是第 1 站的"开关不生效"。
class VisibilityDemo {
static boolean flag = false;
static int number = 0;
// Thread-1
static void writer() {
number = 42; // 写入
flag = true; // 标志位
}
// Thread-2
static void reader() {
if (flag) { // 看到了 flag=true
System.out.println(number); // 但 number 可能还是 0!
}
}
}
即使 Thread-2 看到 flag=true,也可能看到 number=0——因为指令可能被重排序,或者 number 还没从工作内存刷回主内存。
2. 原子性(Atomicity):复合操作中间被打断
根源:读-改-写不是一步到位的。
class Counter {
static int count = 0;
// i++ 实际是三步:read → increment → write
static void increment() { count++; }
public static void main(String[] args) throws Exception {
Thread[] threads = new Thread[100];
for (int i = 0; i < 100; i++) {
threads[i] = new Thread(() -> {
for (int j = 0; j < 1000; j++) increment();
});
threads[i].start();
}
for (Thread t : threads) t.join();
System.out.println(count); // 期望 100000,实际 < 100000
}
}
100 个线程各加 1000 次,期望 100000,实际往往差一截——因为两个线程可能同时读到 count=0,各自加 1 写回,一次递增被"吞"掉了。
3. 有序性(Ordering):执行顺序和代码顺序不一样
根源:编译器和 CPU 的重排序。这是最能考倒人的一个,第 6 站单独展开。
class ReorderDemo {
static int a = 0, b = 0;
static int x = 0, y = 0;
public static void main(String[] args) throws Exception {
Thread t1 = new Thread(() -> { a = 1; x = b; }); // 可能被重排为 x=b; a=1;
Thread t2 = new Thread(() -> { b = 1; y = a; }); // 可能被重排为 y=a; b=1;
t1.start(); t2.start(); t1.join(); t2.join();
// 理论上 x=0, y=0 是可能出现的!
}
}
逻辑上不可能同时 x=0 且 y=0(总有一个线程先写),但重排序后它真的会出现——这就是面试里著名的"0,0 结果"问题。
三大问题,各由谁负责?
| 问题 | 根源 | 由谁负责解决 | 典型工具 |
|---|---|---|---|
| 可见性 | 工作内存未及时同步 | volatile / synchronized / final | volatile 标志位、锁 |
| 原子性 | 复合操作非原子 | 锁与 CAS(volatile 不负责!) | synchronized、ReentrantLock、AtomicInteger、LongAdder |
| 有序性 | 编译器/CPU 重排序 | volatile / happens-before 规则 | volatile、锁(也有序性语义) |
volatile int count; count++ 线程安全吗?不安全。count++ 是"读-改-写"三步,volatile 只保证每一步的可见性,不保证三步之间不被其它线程插队。两个线程同时读到 count=5,各自加 1 写回,结果是 6 而不是 7。volatile 管可见性和有序性,原子性必须交给锁或 CAS。这也是面试高频陷阱。
- 可见性 = "看不看得见",原子性 = "会不会被插队",有序性 = "顺序乱不乱";
- volatile 解决可见性 + 有序性,不解决原子性;
- 遇到并发问题先定位是"三性"里的哪一性,再选工具,别一把锁梭哈。
指令重排序:三层"自作主张"与 as-if-serial
重排序(Reordering)是"程序写的是 A、B,实际执行可能是 B、A"。它可能发生在三个层面:
| 层面 | 执行者 | 举例 |
|---|---|---|
| 编译器重排序 | javac / JIT(C1/C2) | 没有依赖关系的两条语句交换顺序,减少寄存器压力 |
| CPU 重排序 | 处理器乱序执行 | 指令流水线提前执行后面无依赖的指令 |
| 内存系统重排序 | 缓存 / 写缓冲区 | Store Buffer 让写操作"看起来"延后了 |
但重排序不是随心所欲的,它有一条铁律:不能破坏数据依赖(Data Dependency)。
| 依赖类型 | 示例 | 能重排吗 |
|---|---|---|
| 写后读 | a = 1; b = a; | ❌ 不能(b 依赖 a 的新值) |
| 写后写 | a = 1; a = 2; | ❌ 不能(顺序影响最终值) |
| 读后写 | b = a; a = 1; | ❌ 不能 |
| 无依赖 | a = 1; b = 2; | ✅ 可以随意交换 |
把这两张表放一起,你就理解 ReorderDemo 为什么能出现 x=0, y=0 了:a=1 和 x=b 之间没有数据依赖,编译器/CPU 有充分的理由交换它们。
as-if-serial:单线程的"遮羞布"
不管怎么重排,单线程程序的执行结果不能被改变,这叫 as-if-serial(似串行)语义——"好像"是按顺序执行的。编译器和 CPU 只对单线程负责,把"结果一致性"兜住;多线程的正确性,需要程序员自己用 happens-before 关系来保证。
把重排序想象成后厨的出餐:厨师(CPU)发现"煮面"和"烧水"互不依赖,就提前把水烧上(重排),只要最后端给顾客的菜(单线程结果)不变,随便折腾。但如果有两个厨师共享一个灶台(多线程),一个厨师提前用了灶台,另一个厨师的菜就乱了——后厨之间没有协调机制(happens-before),就会出乱子。
可以,但很看运气。用 -XX:+PrintAssembly 看汇编能看到重排痕迹;想稳定复现"0,0 结果"通常要循环跑几万次加上 CPU 压力。更实用的验证工具是 jcstress(OpenJDK 官方的并发压力测试框架),专门用来验证并发正确性——JMM 相关的测试和生产级并发库都在用它。
- 重排序三来源:编译器、CPU、内存系统;
- 有数据依赖的指令不能重排,无依赖的随便排;
- as-if-serial 只保护单线程结果,多线程正确性靠 happens-before。
happens-before:JMM 的现代核心
JSR-133 之后,JMM 描述可见性的主模型不再是 8 个原子操作,而是 happens-before(先行发生)关系。它回答一个问题:线程 A 的写入,什么时候能保证线程 B 一定看得到?
若操作 A happens-before 操作 B,则:
① A 的所有内存写入,在 B 执行时对 B 可见;
② A 不会被重排序到 B 之后(有序性)。
注意一个关键 nuance:happens-before 是"逻辑顺序保证",不是"物理时间先后"。JMM 并不要求 A 真的先于 B 在时间上执行完,它只保证"如果 B 看到了 A 的结果,那么 A 写的一切都对 B 可见"。这给编译器和 CPU 留出了重排空间——只要重排不破坏 happens-before 关系,就随便优化。
把 JMM 想象成银行大厅:A 号(写操作)在 B 号(读操作)之前叫号,那么柜台给 B 号办理业务时,A 号办的业务(存款/转账)一定已经入账。银行不保证 A 号客户"实际到达时间"一定早于 B 号,只保证叫号顺序——按号办理,先叫的先入账。happens-before 就是 JMM 的"叫号规则"。
happens-before 一共八条规则,分三组理解:
| # | 规则 | 一句话 | 详细站 |
|---|---|---|---|
| 1 | 程序顺序规则 | 同一线程内,写在前的 happens-before 写在后的 | 第 8 站 |
| 2 | 监视器锁规则 | 解锁 happens-before 后续对同一把锁的加锁 | 第 8 站 |
| 3 | volatile 变量规则 | volatile 写 happens-before 后续对该变量的 volatile 读 | 第 8 站 |
| 4 | 线程启动规则 | start() happens-before 新线程内的任何操作 | 第 9 站 |
| 5 | 线程终止规则 | 线程内所有操作 happens-before join() 返回 | 第 9 站 |
| 6 | 线程中断规则 | interrupt() happens-before 被中断线程检测到中断 | 第 9 站 |
| 7 | 对象终结规则 | 构造完成 happens-before finalize() 执行 | 第 10 站 |
| 8 | 传递性 | A→B 且 B→C ⟹ A→C | 第 10 站 |
- happens-before = "前面的写,后面的读一定看得见";
- 它是逻辑顺序保证,不是物理时间保证,给优化留空间;
- 八条规则是 JMM 的全部"交通规则",背下来不难,难的是会用——接下来三站逐个实战。
规则 1–3:程序顺序、监视器锁、volatile
规则 1:程序顺序规则(Program Order Rule)
同一个线程内,前面的操作 happens-before 后面的操作。注意前提是同一线程——跨线程这条规则立刻失效。
int a = 1; // A
int b = 2; // B
// 单线程内:A happens-before B
// 编译器/CPU 可以重排,但必须保证单线程结果一致(as-if-serial)
规则 2:监视器锁规则(Monitor Lock Rule)
对同一把锁的 unlock 操作 happens-before 后续的 lock 操作。关键词是"同一把锁"——锁对象不同,规则不成立。
class SyncExample {
int value = 0;
synchronized void set(int v) { value = v; } // unlock happens-before...
synchronized int get() { return value; } // ...后续 lock
}
// Thread-1 调用 set(42) → unlock
// Thread-2 调用 get() → lock → 一定看到 42
生产里最常见的错误:写方法加了锁、读方法没加锁(或者两把不同的锁)。这时候规则 2 根本不成立——读线程可能看到旧值。所以"加锁要成对、锁对象要一致",这是代码评审时一眼就能抓的问题。
规则 3:volatile 变量规则(Volatile Variable Rule)
对 volatile 变量的写操作 happens-before 后续对该变量的读操作。
volatile boolean ready = false;
int data = 0;
// Thread-1
data = 42; // A
ready = true; // B (volatile write)
// Thread-2
if (ready) { // C (volatile read) → B happens-before C
print(data); // 一定看到 42(借助传递性:A→B→C)
}
这正是第 1 站事故的解法:把 flag 声明成 volatile boolean,配置下发后所有线程立刻能看到。volatile 最经典的用法就是一写多读的状态标志(服务停止开关、配置开关)。
因为 data=42(A)和 ready=true(B)在同一线程内满足规则 1(A happens-before B),B 又 happens-before C(规则 3),由规则 8 传递性得到 A happens-before C——所以读线程看到 ready=true 时,data 一定已经是 42。这就是 volatile 的 piggyback(搭便车)效应,也是"标志位 + 数据"组合的合法性来源。
- 规则 1 只保护单线程;规则 2 要求"同一把锁、成对加锁";规则 3 是 volatile 的理论根基;
- 规则 3 + 传递性 = volatile 能"捎带"其它普通变量可见性的原因;
- 这三个规则覆盖了日常 80% 的并发场景。
规则 4–6:线程 start、join、interrupt
规则 4:线程启动规则(Thread Start Rule)
Thread.start() 调用 happens-before 被启动线程中的任何操作。也就是说:主线程在 start 之前写的一切,子线程一进来就都能看到,不需要任何同步。
int config = loadConfig(); // 主线程先准备好
Thread worker = new Thread(() -> {
// 这里一定能看到 config 的值
// start() happens-before 此线程中的任何操作
process(config);
});
worker.start(); // 这个调用 happens-before worker 线程内的所有代码
生产落点:给子线程传初始化好的配置、上下文、连接,靠 start 规则就够,不用画蛇添足加 volatile。同样,线程池 execute() 提交任务也有类似保证:任务提交 happens-before 任务开始执行(JUC 内部通过 AQS 的 volatile state 实现发布)。
规则 5:线程终止规则(Thread Join Rule)
线程中的所有操作 happens-before 其他线程成功从该线程的 join() 返回。
Thread worker = new Thread(() -> {
result = compute(); // 计算结果
});
worker.start();
worker.join(); // join 返回后
print(result); // 一定能看到 worker 线程中写入的 result
生产落点:主线程等一批子线程跑完再汇总,join() 返回后直接读结果就是安全的,不需要额外的锁或 volatile。这也解释了为什么 Future.get() 返回后能安全读取任务结果——底层正是同一套发布语义(AQS 的 state 写入 + 读取)。
规则 6:线程中断规则(Thread Interrupt Rule)
对线程 interrupt() 的调用 happens-before 被中断线程检测到中断事件。
Thread t = new Thread(() -> {
while (!Thread.currentThread().isInterrupted()) {
// 当 interrupt() 被调用,isInterrupted() 一定返回 true
// interrupt() happens-before isInterrupted() 检测到中断
}
});
t.start();
t.interrupt(); // 中断信号一定对目标线程可见
生产落点:优雅停机的推荐姿势就是"interrupt + 线程内检查中断标志",比"共享 boolean 停止标志"更规范(sleep/wait 时还能自动抛中断异常退出阻塞)。注意 interrupted()(静态,会清除标志)和 isInterrupted()(实例,不清除)的区别——写循环条件时用后者。
start 规则保证"主线程的准备对子线程可见",join 规则保证"子线程的成果对主线程可见",interrupt 规则保证"停止信号对子线程可见"——线程生命周期的三个节点,JMM 都给你兜好了底。这也是为什么"线程间共享数据"在很多场景下根本不需要显式加锁。
- start 之前写的,子线程一定看得到;join 之后读的,一定看得到子线程写的;
- interrupt 是协作式停机的官方通道,信号一定可见;
- 线程池 execute/submit 的内部可见性,同样建立在 AQS 的 volatile 发布上。
规则 7–8:对象终结与传递性
规则 7:对象终结规则(Finalizer Rule)
对象的构造函数 happens-before 该对象的 finalize() 方法。
class Resource {
FileInputStream stream;
Resource() { stream = new FileInputStream("data.bin"); } // 构造
protected void finalize() {
// 一定能看到构造函数中初始化的 stream
// 构造 happens-before finalize
stream.close();
}
}
// 注意:finalize 已进入"死亡名单"——
// JDK 9 标记弃用,JDK 18(JEP 421)标记待移除
// 生产环境用 Cleaner 或 try-with-resources,别再用 finalize
这条规则现在更多是"考古"价值:finalize 的执行时机不确定、性能差、还容易出 bug,JVM 官方已明确要移除。记住结论就行:资源清理走 try-with-resources 或 Cleaner,finalize 面试题答完即弃。
规则 8:传递性(Transitivity)
如果 A happens-before B,B happens-before C,那么 A happens-before C。这是八条规则里最"值钱"的一条,因为它让简单的规则组合出强大的效果。
// volatile 的经典用法就是利用传递性:
int a = 1; // A
int b = 2; // B
volatile boolean flag = true; // C(volatile write)
// A → B → C(程序顺序)→(volatile 写读)→ 后续操作
// 传递性保证:读到 flag=true 的线程,一定能看到 a=1, b=2
配置中心下发一批配置(普通变量),再更新一个 volatile long version。客户端线程循环里先读 version,变了就整批重读配置。靠的就是传递性:配置写 → version 写(volatile)→ 客户端读 version → 重读配置,中间全靠规则 1 + 规则 3 + 规则 8 串起来,一行锁都不用加。这就是 happens-before 在生产里最优雅的用法。
程序顺序 + 监视器锁 + volatile + start + join + interrupt + finalizer + 传递性
= JMM 的全部可见性保证。其它一切(内存屏障、8 操作)都是它们的实现细节。
- finalizer 规则是"历史包袱",生产用 Cleaner / try-with-resources;
- 传递性是"放大器":把简单的规则组合成强大的可见性链条;
- 配置发布、版本号切换等场景,靠 volatile + 传递性即可免锁实现。
内存屏障:happens-before 的硬件落地
happens-before 是规范层面的"承诺",而内存屏障(Memory Barrier / Memory Fence)是兑现承诺的硬件指令。JVM 在合适的位置插入屏障,CPU 看到屏障就"不敢乱动"。屏障一共四类,按"读写 × 读写"排列:
| 屏障类型 | 作用 | 禁止的重排序 |
|---|---|---|
| LoadLoad | 确保 Load1 在 Load2 之前完成 | Load1 ↔ Load2 |
| LoadStore | 确保 Load 在 Store 之前完成 | Load1 ↔ Store2 |
| StoreLoad | 确保 Store 在 Load 之前完成(万能屏障) | Store1 ↔ Load2 |
| StoreStore | 确保 Store1 在 Store2 之前完成 | Store1 ↔ Store2 |
记忆技巧:名字里第一个操作在屏障前,第二个操作在屏障后。比如 StoreLoad = 屏障前的 Store 必须完成,屏障后的 Load 才能开始。
x86 上的实现:为什么强模型也要屏障
x86 是强内存模型(TSO),普通读写之间基本不乱序,唯独允许 Store → Load 重排(因为写缓冲区)。所以:
- 普通场景(LoadLoad / StoreStore / LoadStore)在 x86 上大多数是空操作,硬件天然满足;
- StoreLoad 是唯一必须显式执行的,x86 上由
mfence指令或lock前缀指令(如lock addl $0, 0(%rsp))实现——它们会把当前处理器的写缓冲区(Store Buffer)全部排空并刷到缓存。
因为 volatile 写在 x86 上必须执行一次 StoreLoad(mfence 或 lock 前缀),而 StoreLoad 是四类屏障里最贵的——它要等写缓冲区排空,相当于让 CPU "停下来喘口气"。而 volatile 读在 x86 上几乎免费(读天然强序)。所以结论:x86 上 volatile 读 ≈ 普通读,volatile 写贵一个数量级。在 ARM / POWER 这类弱模型上,读和写都需要屏障,都会更贵。
顺带说一句:JMM 的屏障要求是"保守放哨"的——它声明了最严格的插入位置,JIT 在具体平台上发现某类屏障是空操作(比如 x86 上的 LoadLoad)就省略掉。这也是"同一套 Java 代码,x86 上很快、ARM 上明显慢"的原因之一。
- 四类屏障 = 四种"读写顺序"的硬约束,名字即规则;
- x86 只需 StoreLoad(mfence / lock 前缀),其余三类天然满足;
- StoreLoad 最贵,这是 volatile 写在 x86 上变慢的根源。
volatile 读/写插入的屏障与生产建议
volatile 的底层实现就是内存屏障。JMM 规定(保守做法):
- volatile 写之前插入
StoreStore屏障(前面的普通写先落地),之后插入StoreLoad屏障(我的写立刻对外可见); - volatile 读之后插入
LoadLoad+LoadStore屏障(后续普通读写不能越过我)。
把屏障落点和规则对上号:StoreStore 保证"写之前的写"先完成(配合规则 1),StoreLoad 保证写立刻对外可见(规则 3),读侧的 LoadLoad/LoadStore 保证读之后的普通操作不越过读(有序性)。
volatile 的完整语义
✅ 写 volatile 立即刷主内存,读 volatile 强制从主内存读(可见性)
✅ 禁止相关指令重排(有序性)
❌
volatile int i; i++ 依然不是原子的(原子性不归它管)生产里怎么用 / 别怎么用
| 场景 | 该用什么 | 为什么 |
|---|---|---|
| 服务停止开关、配置开关(一写多读) | volatile boolean | 读多写少,volatile 读便宜,语义正好 |
| DCL 单例的引用 | volatile | 阻止半初始化对象发布(第 13 站) |
| 计数器 / 累加器(高频写) | AtomicLong / LongAdder | volatile 写贵 + 不原子,两样都占 |
| 多字段状态一致性 | synchronized / Lock / 不可变对象 | volatile 管不了"多个变量一起变" |
有。JDK 9+ 的 VarHandle 提供了比 volatile 更细粒度的访问模式:opaque(只保证原子性)、acquire/release(只保证单向),在 ARM 等弱模型上能省掉一半屏障;JDK 8 的 LongAdder 用分片累加解决高频写。原则:先问自己"我要的是可见性还是原子性",再挑最便宜的机制。
- volatile 写 = StoreStore + StoreLoad,volatile 读 = LoadLoad + LoadStore;
- x86 上 volatile 读几乎免费、写贵一个数量级;
- 生产选型:一写多读用 volatile,计数器用 Atomic/LongAdder,多字段一致性用锁。
DCL 单例:为什么必须加 volatile
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;
}
}
new Singleton() 在字节码层面是三步:
- 分配内存空间
- 调用构造函数初始化对象
- 把引用指向分配的内存
第一次检查(无锁)是性能优化:单例已经建好就直接返回,避免每次抢锁。但它也让重排序的后果暴露给了无锁路径的读者——所以必须用 volatile 的 StoreStore 屏障钉死 ②③ 的顺序。
旧内存模型(Java 5 之前)下 volatile 语义太弱,挡不住这次重排,所以JDK 5 之前的 DCL 是错的;JSR-133 重写内存模型后,volatile 才真正能"禁止重排 + 保证可见",DCL 从 JDK 5 起才安全。如果面试官追问"DCL 是什么时候开始可靠的",答出"JSR-133 / JDK 5"是加分项。
最后,生产里更推荐用静态内部类(Holder),它用类加载机制天然实现"延迟 + 线程安全",连 volatile 都不用写:
class Singleton {
private Singleton() {}
private static class Holder {
private static final Singleton INSTANCE = new Singleton();
}
// 类加载是线程安全的,Holder 只在首次 getInstance 时被加载
public static Singleton getInstance() {
return Holder.INSTANCE;
}
}
- new 对象 = 分配 → 构造 → 赋值三步,无 volatile 时 ②③ 可重排 → 半初始化对象;
- volatile 的 StoreStore 屏障禁止该重排,DCL 才安全(JDK 5 / JSR-133 起);
- 生产首选静态内部类 Holder,代码更少、语义更稳。
final 域的内存语义:免同步的"安全通道"
volatile 和锁都是"要主动用"的机制,而 final 是被动生效的:final 字段在构造函数中正确赋值后,任何线程看到这个对象引用时,都一定能看到 final 字段的最终值——不需要任何同步。这就是 JSR-133 给 final 的承诺。
class ImmutablePoint {
private final int x; // final 字段
private final int y;
ImmutablePoint(int x, int y) { this.x = x; this.y = y; }
}
// Thread-1
ImmutablePoint p = new ImmutablePoint(3, 5);
cache = p; // 发布到一个共享引用(如 volatile 字段或安全容器)
// Thread-2 拿到 cache 后:p.x 一定是 3,p.y 一定是 5,绝不可能是默认值 0
为什么能做到?JSR-133 规定:final 字段的写入之后要插入 StoreStore 屏障(防止 final 写被重排到构造函数之外),读者拿到引用后还有 LoadLoad 保证。实现上 JIT 会把这些屏障安排得比 volatile 更"便宜"。
三个必须说清的边界
- 安全发布是前提:如果构造函数里就把
this泄露出去(比如在构造中new Thread(...).start()且线程里读了字段,或把 this 放进静态集合),final 的保证就失效——这叫 this 逸出(this Escape),是写框架代码时的高危点; - final 只保护引用本身:如果 final 字段指向一个可变对象,只保证"这个引用不变",不保证被引用对象内部字段的可见性——对象内部的状态更新仍需同步;
- final 不解决发布问题:final 字段的可见性针对"已经拿到引用"的线程,引用本身怎么传过去(volatile、锁、并发容器、start/join),还得靠其它规则。
String、Integer、BigDecimal、LocalDate 全是 final 字段的不可变类,所以它们天然线程安全、可以放心跨线程共享。你自己的值对象(DTO / 配置项 / 坐标)也尽量设计成 final 字段 + 不提供 setter——不可变对象配安全发布,连 volatile 都省了,这是成本最低的并发方案。
- final 字段在构造后对所有线程可见,无需同步(JSR-133 保证);
- 前提是安全发布:this 逸出会让保证失效;
- final 引用指向可变对象时,只保护引用本身;不可变对象 = 最优并发策略。
生产落地:用 happens-before 排查并发 Bug
把八条规则背下来只是第一步,真正的价值在于:遇到并发 Bug,不靠猜、不靠"跑 100 次没出问题",而是用 happens-before 逐条推理。方法论就四步:
实战 1:优雅停机的"开关不生效"(第 1 站事故)
class GracefulShutdown {
private volatile boolean running = true; // 关键:volatile
void stop() { running = false; } // 写(volatile write)
void work() {
while (running) { // 读(volatile read)
// 业务处理...
}
}
}
// 推理:stop() 的写 happens-before work() 中下一次读(规则 3)
// 结论:running 加 volatile 即可,不需要 synchronized
实战 2:主线程 join 子线程后读结果
场景:主线程起了 10 个子线程各算一块数据,join() 全部结束后汇总。推理:每个子线程的所有写 happens-before 主线程从它的 join() 返回(规则 5),所以汇总代码不需要额外加锁。很多人习惯性地加一堆 synchronized,其实是多余的——用规则推一遍就能省下来。
实战 3:线程池 submit 后 get() 为什么安全
场景:Future<Result> f = pool.submit(callable); Result r = f.get();。推理:任务内的写 happens-before get() 返回(JUC 内部:任务结束写入 AQS 的 volatile state,get() 读 state 解锁,本质是规则 3 + 规则 8 的组合)。所以 Future 的结果读取是天然安全的——这也是为什么线程池场景极少需要自己再传 volatile 标志。
反模式:用 sleep "等可见"
Thread.sleep(100) 之后再去读共享变量——sleep 不产生任何 happens-before 关系!它是纯时间等待,跟可见性毫无关系。线上很多"幽灵 Bug"就是这么来的:本地 sleep 100ms "碰巧"跑对了,生产环境线程调度一变就复现。如果代码里出现"写完等一会再读",直接判死刑——该上 volatile 上 volatile,该上锁上锁。
自查清单
| 症状 | 先用哪条规则推理 | 典型修复 |
|---|---|---|
| 一写多读的标志位不生效 | 规则 3(volatile) | 加 volatile |
| 方法内共享状态错乱 | 规则 2(监视器锁) | 同一把锁包住读写 |
| 子线程启动后看不到主线程准备的数据 | 规则 4(start) | 把准备动作移到 start() 之前 |
| join/get 之后读不到结果 | 规则 5 / AQS 发布 | 检查是否真的 join/get 成功 |
| 看到半初始化对象 | 规则 3 + 屏障 | volatile 或 Holder / 枚举单例 |
| 代码里出现 sleep 等可见 | ——(不成立) | 删掉 sleep,换真正的同步机制 |
- 排查并发 Bug = 画时序 + 标读写 + 套规则 + 补同步,四步走;
- 找不到 happens-before 关系,就是缺同步,别用 sleep / 玄学掩盖;
- 会推理之后,很多"多余的锁"能省掉,性能与正确性兼得。
JMM vs JVM 内存模型:最常见的概念混淆
这是面试中最容易混淆的概念。很多人把 JMM 和 JVM 内存模型混为一谈,其实它们解决的是完全不同的问题:
| 维度 | JMM(Java 内存模型) | JVM 内存模型(运行时数据区) |
|---|---|---|
| 规范来源 | JSR-133(JLS 第 17 章) | JVMS(JVM 规范) |
| 核心关注 | 多线程间的内存可见性 | JVM 运行时的内存布局 |
| 关键概念 | happens-before、volatile、内存屏障 | 堆、栈、方法区/元空间、程序计数器 |
| 解决的问题 | 线程 A 的写入何时对线程 B 可见 | 对象在哪分配、栈帧如何组织 |
| 类比 | 交通规则(谁先走谁后走) | 道路规划(车道、停车场、收费站) |
JMM = 多线程并发的规则(什么时候能看到别人的修改)
JVM 内存模型 = 程序运行的布局(堆在哪、栈在哪、方法区在哪)
// 面试官问:"请解释一下 Java 的内存模型"
// 先确认他问的是哪个:
// 如果问的是 JMM(JSR-133):
// → 聊 happens-before、volatile、可见性、内存屏障
// 如果问的是 JVM 运行时数据区:
// → 聊堆(新生代/老年代)、栈、方法区/元空间
// 如果不确定,可以主动区分:
// "Java 内存模型有两个层面,一个是 JSR-133 定义的
// 线程间可见性模型,另一个是 JVM 的运行时数据区划分,
// 您想了解哪方面?"
这一篇你掌握了什么
核心知识点回顾
- JMM 是什么:JSR-133 定义的跨平台并发规范 = 主内存 + 工作内存 + happens-before 八条规则 + 内存屏障;存在的意义是屏蔽 x86 / ARM / POWER 的内存模型差异,同时只给最小保证、留足优化空间;
- 8 大原子操作:lock/unlock/read/write(主内存侧)+ load/store/use/assign(工作内存侧);JSR-133 之后规范层面以 happens-before 为主,8 操作用于理解分工;
- 并发三性:可见性(volatile/synchronized/final 负责)、原子性(锁/CAS 负责,volatile 不管)、有序性(volatile/happens-before 负责);
- 重排序:编译器、CPU、内存系统三来源;有数据依赖的指令不能重排;as-if-serial 只保护单线程;
- happens-before 八条规则:程序顺序、监视器锁、volatile、start、join、interrupt、finalizer、传递性——它是"逻辑顺序保证",不是物理时间先后;
- 内存屏障:LoadLoad / LoadStore / StoreStore / StoreLoad;x86 是强模型只需 StoreLoad(mfence / lock 前缀),StoreLoad 最贵 → volatile 写在 x86 上贵一个数量级;
- volatile:写插 StoreStore + StoreLoad,读插 LoadLoad + LoadStore;解决可见性 + 有序性,不解决原子性;生产用于一写多读的标志位、DCL;
- final 域:构造完成后 final 字段对所有线程可见(无需同步),前提是安全发布(this 不能逸出);final 引用指向可变对象时只保护引用本身;
- 排查方法论:画时序 → 标读写 → 套八条规则 → 补同步;sleep 不产生 happens-before,是反模式。
面试 30 秒总结
"JMM 是 JSR-133 定义的 Java 并发内存模型,回答分三层:模型层——主内存 + 工作内存,线程操作的是副本,同步时机不确定导致可见性问题;规则层——happens-before 八条规则(程序顺序、监视器锁、volatile、start、join、interrupt、finalizer、传递性),其中 volatile 写-读 + 传递性是日常主力;实现层——内存屏障,x86 上 volatile 写靠 StoreLoad(mfence/lock)保证,读基本免费。三性对应:volatile 管可见性和有序性,原子性必须靠锁或 CAS,final 字段构造后天然可见但前提是安全发布。遇到并发 Bug,用 happens-before 逐条推理,不靠 sleep 碰运气。"
一句话总结
JMM 是 Java 并发世界的交通规则:happens-before 是规则本身,内存屏障是执法工具,volatile / synchronized / final 是三种"合规驾驶"的方式。不遵守规则,程序"碰巧能跑";遵守规则,任何硬件上都稳。
Comments · 评论