本节摘要:Java 内存模型(JMM)把每根线程的读写抽象为"工作副本与主存"的交互,可见性不再靠直觉而靠 happens-before 规则裁决:两条操作若不满足任何一条规则,JVM 有权乱序与延迟同步。本节用一个能稳定复现的可见性实验推翻"写了就该看得见"的直觉,再给出一套判例式的规则清单,让你对任意两段代码能做出"是否保证可见"的判断。
第一章说过 JVM 规范只约束语义,本节的 JMM 正是并发语义的总法。为什么要专门立一部法?因为在单线程世界里"写完再读就能读到"是天经地义,到了多核并发环境,这个直觉会以各种姿势破产:编译器可能重排指令、CPU 有写缓冲与缓存一致性延迟、线程可能各自持有变量的工作副本。JMM 的任务是在"允许硬件与编译器充分优化"与"给程序员确定的可见性保证"之间划出一条清晰的线——这条线就是 happens-before。
两个线程,一个写一个读,读者会一直循环等待标志位。直觉上写者置位后读者立刻该停,实际运行却常常永远转下去:
public class VisibilityDemo { static boolean stop = false; // 故意不加 volatile static int counter = 0; public static void main(String[] args) throws Exception { Thread reader = new Thread(() -> { while (!stop) { // 可能永远读到 false counter++; } }); reader.start(); Thread.sleep(200); Thread writer = new Thread(() -> stop = true); writer.start(); System.out.println("writer done, waiting reader..."); reader.join(3000); System.out.println("reader alive: " + reader.isAlive() + " (loop count: " + counter + ")"); } }
$ javac VisibilityDemo.java $ java VisibilityDemo writer done, waiting reader... reader alive: true (loop count: 1287365182) (读者线程仍在无限循环,stop 已被写为 true 三秒之久)
写者确实执行了 stop = true,读者却始终没看见。可能的解释都在规范允许范围内:读者线程的循环把 stop 缓存进了寄存器与本地副本、编译器把 while 条件里的读取提升到循环外(只读一次)、写缓冲尚未刷出。谁对谁错不重要——JMM 说这合法,你的直觉就得让位。把声明改成 static volatile boolean stop 再跑,线程应声而停:volatile 建立了一条明确的 happens-before 边,写之后的读必须见到新值。
JMM 的裁决方式不是"解释所有底层细节",而是给出一条总原则加一组规则,像判例法。总原则:若操作 A happens-before 操作 B,则 A 的结果对 B 可见,且 A、B 不会被重排到违反此序。规则清单背下这几条,日常并发问题基本够用:
| 规则 | 内容 | 典型用法 |
|---|---|---|
| 程序次序 | 单线程内,前面的操作先于后面 | 单线程内无需担心重排语义 |
| 监视器锁 | 解锁先于后续对同一把锁的加锁 | synchronized 块之间传递数据 |
| volatile | 对 volatile 变量的写先于后续的读 | 标志位、发布引用 |
| 线程启动 | start 前的操作先于新线程的任意操作 | 构造后 start 传递数据 |
| 线程终止 | 线程的任意操作先于 join 检测到它结束 | 主线程读子线程结果 |
| 传递性 | A 先于 B 且 B 先于 C,则 A 先于 C | 链式推导,规则组合的桥梁 |
判案示例:线程甲在 synchronized 块里修改共享列表,线程乙也进入同一把锁的块里读——"甲解锁先于乙加锁"这条规则保证乙看得见甲的全部修改。而 6.3 病案三里"双检锁单例"的经典 bug,就是漏判了一条规则:构造对象的写与赋值引用的写之间没有 happens-before 边,另一个线程可能看到"引用非空但对象字段还是默认值"的半成品——结论是双检锁必须用 volatile 修饰引用,或改用静态内部类持有(类初始化的加锁机制天然满足规则)。
判案还有个重要推论:happens-before 只约束"被规则覆盖的"操作对。没有任何规则覆盖的跨线程读写,就是 5.1 说的"零值时刻"任意延长、半成品对象任意可见的法外之地——不是"可能出问题",是规范授权机器随意。
规则落在硬件上是内存屏障:volatile 写在末尾插写屏障(缓冲刷出),读在开头插读屏障(绕开副本)。JVM 的编译器优化也尊重这些语义——4.2 讲的"优化奖励稳定代码"在这里有个反面例:跨同步边界的数据不能随意提升进寄存器,所以滥用 volatile 或锁的代码会损失优化空间。可见性与性能从来是同一枚硬币。
另外两条容易混淆的辨析记一下:happens-before 不等于时间上的先后(规则是语义约束,不是钟表);它也不等于事务性(只有被覆盖的写可见,不保证原子性——volatile 的自增依然会丢更新,需要原子类或锁)。这两个误会每年都在面试现场发生。
⚠️ 常见坑:以为"跑了十亿次都没出问题"能证明并发代码正确。可见性缺陷是概率性灾难:缓冲时序、调度交错、编译器版本变化都可能让潜伏多年的问题突然显形。正确的信心来源是规则推导(这段代码满足哪几条 happens-before),不是运行历史。
💡 关键直觉:JMM 是"合同"不是"物理"。它不描述 CPU 怎么工作,只规定 JVM 必须兑现哪些保证。写并发代码时做两件事:先找出需要传递的数据对,再确保它们落在某条 happens-before 规则的覆盖下——落不进去的,就是在裸奔。
法理立完,下一节下到机器层:volatile 在字节码与 CPU 层面到底插了什么、synchronized 的 monitorenter 如何实现互斥、final 字段为什么天然安全发布——两个同步原语的解剖在下一节完成。