硬件的缓存与指令重排以性能为先,不保证跨线程的可见性与有序性——JMM 以什么最小规则,为程序员提供确定、可推理的并发语义?
并发的真问题是:
一个线程对共享状态的修改,是否、何时、以何种顺序,被另一个线程观察到——抽象为可见性、有序性、原子性。
若无统一约束,不同 CPU 架构、缓存一致性协议、编译器优化策略,会让同一段 Java 程序在不同机器上语义不同。
JMM 要驯服的,是破坏"程序员直觉顺序"的重排——它来自三个层,各为性能而设,单看都不改变单线程语义:
| 层 | 重排来源 | 破坏的直觉 | 关联 |
|---|---|---|---|
| 编译期 | 编译器优化:寄存器驻留、指令调度、死代码消除 | 源码顺序 ≠ 执行顺序;变量不重读主存 | 编译优化 / JIT |
| 执行期(核内) | CPU 乱序执行 / 推测执行 | 执行顺序 ≠ 程序顺序 | 处理器体系架构.html § 乱序执行 |
| 访存期(跨核) | 内存系统:store buffer(写侧)+ 失效队列(读侧) | 写不立即对他核可见、跨核观察顺序不一 | 存储器层次结构.html § MESI、多处理机系统.html |
三层单看都"正确"(不改单线程语义),叠加后却无法为高级语言提供确定的并发语义——同一段 Java 程序在不同机器上可能有不同结果。
硬件提供"能做到什么",内存模型定义"程序员能依赖什么"。
JMM 的任务是在性能与可推理性间划线:
这也解释了 volatile 为何必须定义在语言层:一个关键字要同时压制上述三层重排——禁 JIT 重排(编译期)、插 CPU 屏障(执行期)、由屏障刷 store buffer / 排空失效队列(访存期)。单靠任一层的机制都盖不全,唯有语言级契约能横跨三层统一约束。
这就是为什么 JMM 是"契约"而非"实现"——它不规定 JVM 用哪种 fence 指令,只规定"程序员写了 volatile 后能依赖什么",由 JVM 根据底层硬件(x86 的 TSO、ARM 的弱序)选择兑现方式。
没有内存模型,程序员要理解每种 CPU 的乱序规则、每种缓存协议的可见性窗口,Java 的"一次编写、到处运行"将破产。
在展开任何机制前,必须先说清 JMM 到底给程序员承诺了什么。JMM 不承诺"所有程序都可推理",而是划一条线:
| 程序 | 定义 | JMM 的承诺 |
|---|---|---|
| 正确同步(线内) | 所有 SC 执行都无数据竞争 | 最强保证:所有执行表现为顺序一致,可用单线程直觉推理 |
| 有数据竞争(线外) | 两线程访问同一变量、至少一写、且不由 happens-before 排序 | 最低兜底:读只看到真实写过的值、不落入 UB(有界,而非灾难;见 happens-before 节) |
"正确同步"的判据不是"实际执行无竞争",而是先假设程序按 SC 运行,在这个最直观、执行集合可枚举的世界里检验是否无竞争——以此避开"竞态程序执行集合本身依赖内存模型"的循环。
这条线为什么这样画:
| 候选 | 问题 |
|---|---|
| 全程顺序一致 | 禁掉缓存 / 乱序 / 重排,太慢 |
| 纯弱模型 | 允许凭空产生(out-of-thin-air)值,破坏安全性 |
| 分段契约 | 好程序好到极致,坏程序坏得有底线 |
顺序越强,代价越高,happens-before 用性能换来的因果保证
线外的"有界"是 Java 与 C++ 的分水岭,背后是安全性 vs 性能的根本取舍。第一部分(SC-DRF)是语言无关的通用契约(C++、Go、Rust 同构),其在"内存模型光谱"中的位置见 /编程语言/并发编程.html。
心智脚手架,非形式化地基。 它解释可见性问题的"现象",不定义"何种行为合法"——后者是下一节 happens-before 的职责。
模型:主内存(线程共享)+ 工作内存(线程私有的变量副本);线程只能读写自己的工作内存,通信必须经主内存中转——副本不同步即产生可见性问题。
happens-before 不是时间先后关系,而是:
可见性与有序性的因果约束关系(Partial Order)
含义只有一个:
如果 A happens-before B, 那么 A 的结果 对 B 可见,并且 A 的执行顺序 排在 B 之前。
如果两个操作之间 不存在 happens-before 关系:
这个"核心契约用一条分段保证"节所划线外的有界语义——仍保证不落入 OOTA/UB。
因果方向(纠偏):不是”先有主/工作内存交互,再有 happens-before 约束”; 而是 happens-before 为地基,缓存的刷新与失效只是它在硬件上的一种兑现方式。
这些规则看似零散,实则收敛于一条生成原理:
跨线程因果只有一个来源——成对的同步动作:一个"发布",一个"接收"。 一方做发布动作,把此前的所有写公开;另一方对同一媒介做接收动作,观察到该发布,从而收取这些写。锁只是其中一个实例,volatile、线程启停亦然。
① 造边(唯一原理):发布 → 接收(即 release/acquire 语义)
作用于同一媒介的"发布动作"happens-before 对它的"接收动作"。JLS 列的 5 条同步边,都是这一模式的实例:
| 媒介 | 发布(release 侧) | 接收(acquire 侧) |
|---|---|---|
| 锁 | unlock | lock(同一锁) |
| volatile 变量 | 写 | 读(同一变量) |
| 线程对象 | 父线程 start() |
子线程首个动作 |
| 线程对象 | 线程末动作 | join()/isAlive 检测到终止 |
| 中断标志 | interrupt() |
被中断线程检测到中断 |
| 变量初值 | 系统写默认值(0/false/null) | 每个线程首个动作 |
判据由此自明:只有成对的同步动作、作用于同一媒介时才造边——普通读写不是同步动作,故彼此无边、可被自由重排;作用于不同媒介(不同锁、不同 volatile 变量)也不造边。最后一行是"读至少看到默认值、不落入 OOTA"的规范来源。
② 线程内定序:程序顺序——单线程内,前一个动作 hb 后一个动作。
③ 连边成链:传递性——A hb B、B hb C ⟹ A hb C,把离散的边闭合成偏序。
(另有一条特例直接边:对象构造完成 → 其 finalize 开始。)
as-if-serial 与 happens-before 是一对重排边界,共同框定"JMM 允许哪些重排":
| 作用范围 | 底线 | |
|---|---|---|
| as-if-serial | 单线程内 | 结果"看起来像顺序执行",线程自己观察不到被重排 |
| happens-before | 跨线程 | 同步点之外无可见性 / 顺序保证 |
单线程语义下,执行结果必须"看起来像是顺序执行的"——它同时约束编译器与 CPU,保证单线程世界的确定性。
关键在于其边界:as-if-serial 只在单线程成立,一旦跨线程即失效。单线程的确定性会沿原子性、可见性、有序性三个方向各自破裂。
happens-before 并非 JMM 独创,而是 Lamport 1978 年为分布式系统定义的因果偏序,JMM 是它在单机并发的一次投影。同一原理在本库三个域出现:
在没有全局时钟的世界里,因果偏序是唯一可靠的"顺序"; 而"正确"的标准,是执行结果等价于某个合法的串行/顺序全序。
| 域 | 偏序的载体 | "正确"的判据(等价于某串行/顺序全序) |
|---|---|---|
| JMM | happens-before | SC-DRF:正确同步的程序表现为顺序一致 |
| 分布式 | Lamport happens-before / 逻辑时钟 | 线性一致 / 因果一致 |
| 数据库事务 | 冲突可串行化的前趋图偏序 | 可串行化 |
顺序越强,性能代价越高——需付性能去换顺序:
| 域 | 最强(最可推理,代价最高) | 中间档 | 最弱(最快,最难推理) |
|---|---|---|---|
| JMM | 顺序一致 SC | SC-DRF(Java 实际取值) | 纯弱模型 / Plain |
| 分布式 | 线性一致 | 因果一致 | 最终一致 |
| 事务 | 可串行化 | RR / RC | 读未提交 |
这是同一取舍在不同规模上的实例:都用"因果偏序"表达秩序,用"等价于某全序"定义正确,用"放松顺序"换取性能。
| 特性 | 失效面(单线程哪个前提崩了) | JMM 是否直接保证 | 修复手段 |
|---|---|---|---|
| 原子性 | 复合操作中途被介入 | 仅单次访问;64 位 long/double 非 volatile 允许拆分(撕裂) | synchronized / CAS |
| 可见性 | 写停在本地未公开 | 是(经 happens-before) | volatile / synchronized(unlock 刷主存)/ final(构造安全发布) |
| 有序性 | 重排被他线程观察到 | 是(经 happens-before) | 建立 happens-before |
关键区分:可见性与有序性由 happens-before 统辖,原子性是独立轴——这也是为什么原子性是三者里唯一大部分落在 JMM 之外的一个:JMM 只保证单次访问原子,复合操作的原子性需由锁 / CAS 自建。
JMM 只定义"程序员能依赖什么",JVM 用现实工具兑现。三种工具,一条兑现链:语义(hb 边)→ 字节码 → JVM → 硬件。
| 工具 | 语义本质 | 字节码 / 标志 | 兑现层 | 适用 |
|---|---|---|---|---|
| volatile | 建立写→读的 hb,禁相关重排 | ACC_VOLATILE |
JVM 插屏障 → 硬件 fence / lock | 状态标志、单写多读,不含复合不变式 |
| synchronized | 临界区全序视图(互斥 + hb 封闭) | monitorenter/exit |
对象监视器 → OS/CPU 原子指令 | 复合操作、复合不变式 |
| 内存屏障 | 面向 JVM,非程序员——把上两者的语义兑现到硬件 | — | LoadLoad / StoreStore / LoadStore / StoreLoad | (JVM 内部) |
synchronized 的价值不止互斥,还有内存语义的边界封装:进出临界区即一对发布/接收。四种屏障是这条链的最底层——volatile、锁的 hb 边,最终都由 JVM 按目标 ISA 选插哪几种屏障来落地。
共享可变数据的每次访问,是否被 happens-before 边覆盖? 有 → 线内、可推理;无 → 数据竞争。
JMM 的边界(它不解决):复合操作的原子性、业务不变式、高层并发策略——都在 happens-before 之上,需用锁 / CAS / 设计自行保证。
JMM 是并发世界的物理定律,不是业务逻辑的守护神:它保证"写了同步就可推理",不保证"你的同步逻辑对"。
具体工具选型(volatile / synchronized / 原子类 / 不可变对象 / 并发容器 的适用与反模式)见 /编程语言/JAVA/JAVA并发编程/JAVA并发编程.html。
一条主线贯穿三代:竞态安全(禁 OOTA)与优化自由(允许一切合法重排)的矛盾,每代用更精确的形式化推进一步,至今未根除。
| 里程碑 | 解决了上代什么 | 埋下 / 遗留的洞 |
|---|---|---|
| 老 JMM(1995) | 首次定义并发语义(主内存/工作内存操作式模型) | 形式化不自洽:既太强(禁合法优化)又太弱(final 失效、DCL 坏)→ Pugh 判 “fatally flawed”,不可补丁只能重写 |
| JSR-133(2004) | 换 happens-before 偏序作地基;立 SC-DRF 正面契约;修 volatile(发布屏障)与 final(freeze) | 竞态语义的因果性形式化(§17.4.8)重蹈覆辙——又太强(禁掉合法优化,HotSpot 实际不遵守)又不干净(OOTA 未封死) |
| JMM9(进行中) | 目标:干净禁 OOTA、给 VarHandle 弱访问模式补形式语义、重审 64 位原子性 | 根本矛盾未解、至今未定稿;API(VarHandle)已随 Java 9 发布,但其形式模型缺席 |
三代都在”安全 vs 优化”的张力上博弈,且形式化越精确,矛盾暴露得越清楚。
JMM 的第二部分保证(racy 程序仍有界)是 Java 与 C/C++ 的根本分水岭,背后是两种设计立场:
| 立场 | 对”写错的并发程序”给什么 | 动机 |
|---|---|---|
| Java | 有界语义(读到真实写过的值、类型/内存安全不破) | 沙箱安全、可调试、防安全管理器绕过 |
| C/C++ | 未定义行为(UB) | 实现简单、性能极致,竞态 = 灾难 |
这不是”Java 更安全”的简单结论,而是在语言定位上的根本取舍:
第一部分保证(DRF→SC)是语言无关的,Go、Rust、Swift 都采用同构契约。差异只在第二部分——Rust 通过类型系统在编译期大幅减少竞态(但 unsafe 块仍可能有),Go 介于 Java 与 C++ 之间。