7.1 JMM 与 happens-before:可见性的判例法


7.1 JMM 与 happens-before:可见性的判例法

本节摘要: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 边,写之后的读必须见到新值。

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 规则的覆盖下——落不进去的,就是在裸奔。

要点回顾

  • 可见性问题的根源:工作副本、缓冲与重排都合法,"写了就该看见"在规范面前不成立;
  • happens-before 总原则:有边的操作对保证可见且不违反序,无边则机器自由裁量;
  • 六条常用规则:程序次序、监视器、volatile、线程启动、线程终止、传递性;
  • 判案法:找出跨线程的数据对,为它们构造规则覆盖,构造不出来就是缺陷;
  • 双检锁教训:发布引用需要 volatile 或类初始化锁,半成品对象是漏判规则的典型后果;
  • 边界三条:规则不是钟表、不提供原子性、屏障有性能代价。

法理立完,下一节下到机器层:volatile 在字节码与 CPU 层面到底插了什么、synchronized 的 monitorenter 如何实现互斥、final 字段为什么天然安全发布——两个同步原语的解剖在下一节完成。


作者与出处
原作者: 灏天文库
来源:灏天文库
整理: 灏天文库整理
由灏天文库平台收录,内容或由平台用户上传,仅供学习交流
发布者: 作者: 灏天文库 转发
评论区 (0)
U