内存模型
本文从硬件层的 CPU 内存模型出发,逐层深入到 Java 内存模型(JMM),系统讲解内存可见性、重排序、happens-before、volatile、synchronized 的底层原理,以及各层次之间的关联。是理解 Java 并发编程|Java 并发编程 的必备基础。
目录
| 章节 | 说明 |
|---|---|
| 概念名词解释 | 全文高频术语速查 |
| 为什么需要内存模型 | 多核 + 优化带来的混乱 |
| CPU 内存模型 | 强模型 vs 弱模型,x86 TSO,ARM |
| 内存重排序的四种类型 | 编译器重排 + CPU 乱序执行 |
| 内存屏障 | 硬件层的解决方案 |
| Java 内存模型(JMM) | 抽象内存模型,主内存与工作内存 |
| happens-before 规则 | JMM 的核心承诺 |
| volatile 深度解析 | 可见性 + 禁止重排序的完整实现 |
| synchronized 与内存模型 | 获取/释放语义 |
| final 与安全发布 | 初始化安全性 |
| 双重检查锁定(DCL) | 经典陷阱与正确写法 |
| 各层对照总结 | 硬件 → JVM → Java 的映射 |
概念名词解释
硬件 / CPU 层
| 术语 | 简释 |
|---|---|
| Load / Store(内存读写操作) | 概念层术语,指 CPU 对内存地址的读 / 写请求(x86 上由 MOV 指令承担,无单独 LOAD/STORE 指令名,见 Intel SDM Vol.2 — MOV)。数据路径:读(Load)= 内存地址 → Cache 层次(L3/L2/L1)→ 寄存器;写(Store)= 寄存器 → Store Buffer →(Retire 时)L1 Cache → L2/L3 → 主内存。 跨核可见性:写在 Store Buffer 阶段对他核完全不可见("cannot see the write queues on other processors",来源:research.swtch.com/hwmm);当 Store Buffer flush 到 Cache 的那一刻,MESI 总线嗅探(Bus Snooping)机制通知其他核使其副本失效,他核的后续读才能获得新值(来源:Wikipedia — MESI protocol)。寄存器是每个核私有的落脚点,不参与跨核共享。LoadLoad / StoreLoad 等内存屏障命名均以此为基础。⚠️ 与 JMM 原子操作中的 load / store 同名但含义不同,见下方 JMM 层 |
| Store Buffer(存储缓冲区) | 缓冲已发射(Issue)但尚未退休(Retire)的写操作,即"飞行中"的 Store 指令。数据在 Retire 时才写入 L1 Cache,Retire 之前对其他核完全不可见(Store-Load 重排序的根源);但本核可以读取自己的 Store Buffer(Store-to-Load 转发),其他核不行。来源:research.swtch.com/hwmm、Wikipedia — Memory Disambiguation |
| Load Buffer(加载缓冲区) | 缓冲已发射但尚未退休的读操作,与 Store Buffer 对称,同为飞行中指令的缓冲器。Load 发射后在此处登记,执行时读 Cache(或由 Store Buffer 转发数据),Retire 时数据提交到架构寄存器。⚠️ 不是数据线性流过的通路(数据从 Cache 直接返回,不经 Load Buffer 中转);核心用途:① 追踪飞行中的 Load;② 被 Store 搜索以检测 RAW 违规(Memory Disambiguation)。来源:Wikipedia — Memory Disambiguation |
| Cache(CPU 高速缓存) | 位于 CPU 与主内存之间的多级缓存(L1/L2/L3);读写速度远快于内存,但多核间存在副本不一致问题 |
| Invalidate Queue(失效队列) | 收到其他核发来的"请失效该缓存行"消息后,先放入此队列异步处理;是 Load 延迟感知他核写入的原因 |
| MESI 协议 | 四状态缓存一致性协议:Modified(已修改)/ Exclusive(独占)/ Shared(共享)/ Invalid(已失效);保证多核 Cache 最终一致 |
| Out-of-Order Execution(乱序执行) | CPU 在不违反数据依赖的前提下,动态调整指令执行顺序以填满流水线空泡;对单线程透明,但多线程可见 |
| TSO(Total Store Order) | x86 的内存模型;仅允许 Store-Load 一种重排(Store Buffer 导致),其余三种顺序均由硬件保证 |
| 内存屏障(Memory Barrier / Fence) | CPU 提供的特殊指令,强制约束其前后内存操作的执行顺序,并可冲刷 Store Buffer;分 LoadLoad / StoreStore / LoadStore / StoreLoad 四类 |
| Acquire 语义 | 屏障之后的所有读写,不能被重排到该操作之前;对应"进入临界区,先看到最新数据" |
| Release 语义 | 屏障之前的所有读写,不能被重排到该操作之后;对应"离开临界区,先把修改刷出去" |
"飞行中"(in-flight)补充说明:Load Buffer 和 Store Buffer 都是缓冲飞行中操作的结构,称其为"缓冲器"完全准确。"飞行中"指指令已从发射队列(Issue Queue)发出、但尚未退休(Retire)——退休是指令按程序顺序提交、结果真正生效的时刻。两者的关键区别:Store Buffer 里的写尚未写入 Cache(Retire 时才写),对其他核不可见;Load Buffer 里的读已经发出了 Cache 访问请求(或正在等 Cache 返回数据),等 Retire 后才提交到寄存器。两者都不是数据线性流经的通路,而是乱序执行引擎用来追踪飞行中操作状态的结构。
JMM 层
| 术语 | 简释 |
|---|---|
| JMM(Java Memory Model) | Java 语言规范(JLS §17.4)定义的抽象内存模型;屏蔽硬件差异,统一规定可见性与重排序规则 |
| 主内存(Main Memory) | JMM 中所有线程共享变量的逻辑存储区域;对应硬件的 Cache + RAM |
| 工作内存(Working Memory) | 每个线程私有的变量副本;对应硬件的寄存器、Store Buffer、L1 Cache |
| 内存可见性(Visibility) | 一个线程对共享变量的写,何时能被其他线程的读所感知 |
| 重排序(Reordering) | 编译器或 CPU 改变指令执行顺序;在单线程内结果等价,但可能破坏多线程程序的正确性 |
| 数据竞争(Data Race) | 两个线程并发访问同一变量、至少一个为写、且没有同步手段;行为未定义 |
| 顺序一致性(Sequential Consistency) | 理想模型:所有操作按某个全局顺序执行,且每个线程内顺序与代码一致;JMM 仅对正确同步的程序提供此保证 |
| happens-before | JMM 的核心可见性关系:若 A happens-before B,则 A 的结果对 B 可见、A 在 B 之前执行;是推理并发正确性的基础工具 |
| 8 种原子操作(lock / unlock / read / load / use / assign / store / write) | JLS 第二版 §17.1(旧版 JMM,适用于 Java 1.0–1.4;Java 5 起被 happens-before 模型取代)定义的线程与内存交互的最小原语,详见 8 种原子操作详解。⚠️ 其中 load / store 与硬件 Load / Store 同名不同义(硬件 Load / Store 的准确数据路径见上方 Load / Store(内存读写操作) 条目):JMM load = 将 read 传来的值装入工作内存副本;JMM store = 将工作内存副本的值取出准备传给主内存 |
Java 语言层
| 术语 | 简释 |
|---|---|
| volatile | 关键字;保证变量读写的可见性,并禁止与前后操作发生重排序;不保证复合操作(如 i++)的原子性 |
| synchronized | 关键字;提供互斥(同一时刻只有一个线程执行临界区)+ 内存可见性(进入时刷新,退出时写回) |
| final | 关键字;JMM 保证对象构造完成后 final 字段对所有线程可见,无需额外同步;前提是构造函数中不能泄露 this |
| monitorenter / monitorexit | synchronized 编译后的字节码指令;分别对应 Acquire 屏障(进入临界区)和 Release 屏障(退出临界区) |
为什么需要内存模型
三大破坏因素
单线程程序里,代码按顺序执行,结果可预测。多核并发场景下,有三股力量会打乱这种直觉:
1. 编译器重排序 — 编译期为优化生成的指令顺序与源码不一致
2. CPU 乱序执行 — CPU 动态调整指令执行顺序(Out-of-Order Execution)
3. 存储缓冲区 — Store Buffer / Invalidate Queue 导致写操作延迟可见
经典问题:
// 初始值: x = 0, y = 0, flag = false
// 线程 A
x = 42;
flag = true; // 编译器/CPU 可能把这行提前到 x=42 之前
// 线程 B
while (!flag) {}
print(x); // 可能打印 0,不是 42!
这就是内存可见性问题的典型表现:线程 A 写的值,线程 B 读不到最新版本。
内存模型的定义
内存模型(Memory Model)是一套规范,定义:
- 多线程程序中,一个线程对共享变量的写操作,在什么条件下对其他线程可见
- 哪些重排序是被允许的,哪些是被禁止的
有了内存模型,程序员才能推理多线程程序的正确性。
CPU 内存模型
强模型 vs 弱模型
不同 CPU 架构对内存操作重排序的宽松程度不同:
| 架构 | 内存模型 | 允许的重排序 |
|---|---|---|
| x86 / x86-64 | TSO(Total Store Order) | 仅 Store-Load 重排(Store Buffer 导致) |
| ARM / AArch64 | 弱模型(Relaxed) | 几乎所有重排序都允许 |
| RISC-V | 弱模型 | 类似 ARM,需显式 FENCE |
| SPARC (TSO 模式) | TSO | 同 x86 |
| PowerPC | 弱模型 | 允许大量重排序 |
规律:x86 是现代主流服务器/桌面 CPU,内存模型较强;ARM 是移动端/新型服务器(如苹果 M 系列)的主流,内存模型弱,需要更多显式屏障。
x86 TSO 模型详解
TSO(Total Store Order)的核心规则:
Load → Load ✅ 保序(后面的 Load 不会越过前面的 Load)
Store → Store ✅ 保序(后面的 Store 不会越过前面的 Store)
Load → Store ✅ 保序(Load 不会越过后面的 Store)
Store → Load ❌ 允许重排(Store Buffer 导致)
唯一允许的重排:Store 越过后续 Load。
这是因为 存储体系|Store Buffer 的存在:写操作先放入 Store Buffer,CPU 继续执行后续读操作,Store Buffer 异步刷入 Cache(多核间通过 存储体系|MESI 协议 保持缓存一致性)。其他核在 Store 刷入 Cache 之前,读到的是旧值。
Core A: Core B:
x = 1 → Store Buffer(未刷) y = 1 → Store Buffer(未刷)
r1 = y → 从 Cache 读,得到 0 r2 = x → 从 Cache 读,得到 0
结果:r1=0, r2=0 ← 在 x86 上实际可能发生!
ARM 弱内存模型
ARM 允许几乎所有重排,包括:
- Load-Load 重排:后面的读可以越过前面的读
- Store-Store 重排:后面的写可以越过前面的写
- Load-Store 重排:读越过后面的写
这意味着在 ARM 上写正确的多线程代码,需要大量显式屏障指令,Java 程序员通常不需要关心(JVM 负责插入),但在理解 JVM 实现时很关键。
内存重排序的四种类型
站在 Java 程序员视角,重排序分四类:
LoadLoad 重排序:Load1 → Load2 变为 Load2 → Load1
StoreStore 重排序:Store1 → Store2 变为 Store2 → Store1
LoadStore 重排序:Load1 → Store2 变为 Store2 → Load1
StoreLoad 重排序:Store1 → Load2 变为 Load2 → Store1 ← 危害最大
StoreLoad 重排序危害最大:Core A 写地址 X(停在 Store Buffer 未刷出),随即读不同地址 Y(直接从 Cache 取旧值)。对其他核的观察者来看,Core A 的读 Y 发生在写 X 之前——即使程序顺序是先写后读。本质是跨核的顺序可见性问题,而非"本核看不见自己的写"——同地址写后读由 Store-to-Load Forwarding 保证,本核永远能看到自己的写。
各类型经典示例(x86 vs ARM)
用双线程 litmus test 演示四种重排序,并对比两大主流平台的行为。 来源:Weak vs. Strong Memory Models — Jeff Preshing、Hardware Memory Models — Russ Cox
StoreLoad — Store Buffering(SB)
初始: x = 0, y = 0
Core A: Core B:
x = 1 → Store Buffer(未刷) y = 1 → Store Buffer(未刷)
r1 = y → 从 Cache 读,得到 0 r2 = x → 从 Cache 读,得到 0
结果 r1=0, r2=0:
x86(TSO) ✅ 可能发生 ← Store Buffer 导致,TSO 唯一允许的重排
ARM(弱模型)✅ 可能发生
两核的写各自停在 Store Buffer 中,彼此的读看不到对方未刷出的写。
StoreStore — Message Passing 写者侧(MP)
初始: x = 0, flag = 0
Core A(写者): Core B(读者):
x = 42 ←─┐ StoreStore 重排 r1 = flag // 自旋直到读到 1
flag = 1 ←─┘ flag=1 先写出 r2 = x // ARM 上可能读到 0 !
结果 r1=1, r2=0:(flag=1 已可见,但 x=42 尚未写出)
x86(TSO) ❌ 不会发生 ← TSO 保证同核写操作的可见顺序
ARM(弱模型)✅ 可能发生
修复:在 x=42 与 flag=1 之间插入 StoreStore 屏障(ARM:DMB ISH)。x86 TSO 已在硬件层面保证 StoreStore 顺序,无需额外屏障(SFENCE 仅用于 Non-Temporal Store,如 MOVNTI)。
LoadLoad — Message Passing 读者侧(MP)
初始: x = 0, flag = 0
(Core A 已用 StoreStore 屏障保证 x=42 先于 flag=1 写出)
Core B(读者):
r1 = flag // 读到 1(flag 的写已传播)
r2 = x // LoadLoad 重排:ARM 上可能仍读到 0 !
// Invalidate Queue 中对 x 的失效通知还未处理
结果 r1=1, r2=0:(看到了 flag=1,但 x 的失效还堵在队列里)
x86(TSO) ❌ 不会发生 ← TSO 保证同核读操作的顺序
ARM(弱模型)✅ 可能发生
修复:在两次读之间插入 LoadLoad 屏障(ARM:DMB ISH)。
LoadStore — Load Buffering(LB)
初始: x = 0, y = 0
Core A: Core B:
r1 = x ← Load r2 = y ← Load
y = 1 ← Store x = 1 ← Store
LoadStore 重排后:Store 先于本核的 Load 发出
Core A:y=1 先写出,r1=x 后读(此时 Core B 的 x=1 已写出)→ r1=1
Core B:x=1 先写出,r2=y 后读(此时 Core A 的 y=1 已写出)→ r2=1
结果 r1=1, r2=1:
x86(TSO) ❌ 不会发生 ← TSO 禁止 LoadStore 重排
ARM(弱模型)✅ 理论上允许,实际极罕见
两核的 Store 提前写出,后续 Load 各自读到了对方的 Store 结果,形成"循环可见"。
平台对比速查
| 重排序类型 | 典型 litmus test | 根因 | x86(TSO) | ARM(弱模型) |
|---|---|---|---|---|
| StoreLoad | Store Buffering | Store Buffer 未刷 | ✅ 可能 | ✅ 可能 |
| StoreStore | Message Passing 写者 | 写可见顺序乱序 | ❌ 不会 | ✅ 可能 |
| LoadLoad | Message Passing 读者 | Invalidate Queue 延迟 | ❌ 不会 | ✅ 可能 |
| LoadStore | Load Buffering | Store 先于 Load 写出 | ❌ 不会 | ✅ 理论可能 |
编译器重排序 vs CPU 重排序
| 来源 | 发生时机 | 解决方式 |
|---|---|---|
| 编译器重排序 | 编译期,javac/JIT 优化 | 编译器屏障(禁止指令移动) |
| CPU 与指令执行|CPU 乱序执行 | 运行期,CPU 流水线调度 | 内存屏障指令 |
| Store Buffer | 运行期,写操作延迟可见 | Store 屏障(冲刷 Store Buffer) |
| Invalidate Queue | 运行期,失效通知延迟处理 | Load 屏障(处理 Invalidate Queue) |
内存屏障
内存屏障(Memory Barrier / Memory Fence)是 CPU 提供的特殊指令,强制约束其前后的内存操作顺序。
四种屏障语义
| 屏障类型 | 禁止的重排序 | 作用 |
|---|---|---|
| LoadLoad Barrier | Load1 不能越过 Load2 | 确保 Load1 的结果在 Load2 之前可见 |
| StoreStore Barrier | Store1 不能越过 Store2 | 确保 Store1 在 Store2 之前写出 |
| LoadStore Barrier | Load 不能越过后续 Store | 确保读操作在后续写之前完成 |
| StoreLoad Barrier | Store 不能越过后续 Load | 最强屏障,冲刷 Store Buffer,代价最高 |
x86 上的实现
MFENCE ; 全屏障 = StoreLoad + 冲刷 Store Buffer
LFENCE ; Load 屏障 = LoadLoad(x86 上通常是 no-op)
SFENCE ; Store 屏障 = StoreStore(x86 上通常是 no-op)
实践:x86 上 JVM 的 volatile 写使用 LOCK ADD $0, (%rsp) 或 MFENCE 充当全屏障;由于 TSO 模型已经保证了大多数顺序,只有 StoreLoad 需要显式屏障。
ARM 上的实现
DMB ISH ; Data Memory Barrier, Inner Shareable Domain
; 全屏障:barrier 前的访问对其他核可见后,才执行 barrier 后的访问
DSB ISH ; Data Synchronization Barrier,更强,还等待 Cache/TLB 操作完成
ISB ; Instruction Synchronization Barrier,冲刷流水线
ARM 需要大量 DMB 指令,这是为什么 Java volatile 在 ARM 上性能开销明显高于 x86 的原因。
各类型重排序的修复方案
对应前文四个 litmus test,以伪汇编展示屏障插入位置。
StoreLoad — Store Buffering(SB)
两核都需要插入屏障,仅一侧加无效。
; Core A ; Core B
MOV [x], 1 MOV [y], 1
MFENCE ← x86 MFENCE ← x86
DMB ISH ← ARM DMB ISH ← ARM
MOV r1, [y] MOV r2, [x]
屏障冲刷 Store Buffer,使 x=1 / y=1 在后续读执行前对所有核可见,
排除 r1=0, r2=0 的结果。
StoreStore — Message Passing 写者侧(MP)
只需在写者的两次写之间插入屏障。
; Core A(写者)
MOV [x], 42
; MFENCE 不需要 ← x86 TSO 已保证 StoreStore 顺序
DMB ISH ← ARM
MOV [flag], 1
屏障确保 x=42 在 flag=1 对外可见之前已写出,读者看到 flag=1 时 x 一定是 42。
LoadLoad — Message Passing 读者侧(MP)
只需在读者的两次读之间插入屏障。
; Core B(读者)
SPIN: MOV r1, [flag]
TEST r1, r1
JZ SPIN ; 自旋直到 flag=1
; LFENCE 不需要 ← x86 TSO 已保证 LoadLoad 顺序
DMB ISH ← ARM
MOV r2, [x] ; 有屏障后,此时 x 一定是 42
屏障清空 Invalidate Queue,确保读 flag=1 之后,x 的失效通知已处理完毕。
LoadStore — Load Buffering(LB)
两核各自在 Load 与 Store 之间插入屏障。
; Core A ; Core B
MOV r1, [x] MOV r2, [y]
; 不需要 ← x86 TSO 已保证 ; 不需要 ← x86 TSO 已保证
DMB ISH ← ARM DMB ISH ← ARM
MOV [y], 1 MOV [x], 1
屏障阻止 Store 先于 Load 写出,消除 r1=1, r2=1 的"循环可见"。
汇总对比
| 重排序类型 | 屏障插入位置 | x86 是否需要 | ARM 指令 |
|---|---|---|---|
| StoreLoad | Store 与 Load 之间(两侧各插) | ✅ MFENCE |
DMB ISH |
| StoreStore | 两次 Store 之间(写者侧) | ❌ TSO 已保证 | DMB ISH |
| LoadLoad | 两次 Load 之间(读者侧) | ❌ TSO 已保证 | DMB ISH |
| LoadStore | Load 与 Store 之间(两侧各插) | ❌ TSO 已保证 | DMB ISH |
x86 只有 StoreLoad 需要显式屏障,这正是 TSO(Total Store Order)名称的由来——除 Store-Load 外,其余三种顺序由硬件总序保证。
Acquire / Release 语义
这是一对重要的屏障语义,常见于 Lock/Unlock 场景:
Acquire 语义(Load-Acquire):
屏障后的所有读写,不能移到 Acquire 操作之前
→ "进入临界区后看到的数据都是最新的"
Release 语义(Store-Release):
屏障前的所有读写,不能移到 Release 操作之后
→ "离开临界区前写的数据,其他线程进入临界区后都能看到"
Thread A (writer) Thread B (reader)
write x = 42
[Release] ←对应→ [Acquire]
write flag = true while (!flag) {}
read x → 保证看到 42
Java 内存模型(JMM)
设计目标
JMM 是 Java 语言规范(JLS §17.4)定义的抽象内存模型,目标是:
- 屏蔽不同 CPU 架构的差异,提供统一的并发语义
- 允许 JVM 做合理优化(不过度限制性能)
- 给程序员明确的可见性保证
主内存与工作内存
JMM 定义了一个抽象的内存结构:
关键规则:
- 线程只能直接操作自己的工作内存
- 对主内存共享变量的读写,通过 8 种原子操作完成(lock/unlock/read/load/use/assign/store/write)
- 工作内存对主内存的修改,何时刷回、何时对其他线程可见,由 JMM 规定
这个模型是抽象概念,不是 JVM 的实际实现,实际上对应 CPU 的寄存器、Store Buffer、Cache 等硬件结构。
8 种原子操作详解
JMM 规定线程与内存之间的交互,通过 8 种原子操作来描述(来源:JLS 第二版 §17.1)。每种操作的作用域和数据流方向如下:
| 操作 | 作用域 | 数据流向 | 一句话含义 |
|---|---|---|---|
| read(读取) | 主内存 | 主内存 → 松耦合传输 | 把变量值从主内存取出,与后续 load 松耦合配对(JLS 原文:"transmits master copy to working memory for use by a later load action") |
| load(载入) | 工作内存 | 松耦合传输 → 工作内存副本 | 把 read 传来的值装入工作内存,形成变量副本(JLS 原文:"puts value transmitted from main memory by a read action into working copy") |
| use(使用) | 工作内存 | 工作内存副本 → 执行引擎 | 把工作内存的副本值传给执行引擎(JLS 原文:"transfers the contents of the thread's working copy of a variable to the thread's execution engine") |
| assign(赋值) | 工作内存 | 执行引擎 → 工作内存副本 | 把执行引擎的运算结果写回工作内存副本(JLS 原文:"transfers a value from the thread's execution engine into the thread's working copy of a variable") |
| store(存储) | 工作内存 | 工作内存副本 → 松耦合传输 | 把工作内存副本的值取出,与后续 write 松耦合配对(JLS 原文:"transmits the value of a variable from the thread's working memory to main memory, for use by a subsequent write operation") |
| write(写入) | 主内存 | 松耦合传输 → 主内存 | 把 store 传来的值写入主内存变量(JLS 原文:"puts a value transmitted by a store action from the thread's working memory into a shared variable in main memory") |
| lock(锁定) | 主内存 | — | 将主内存变量标记为某线程独占,其他线程不可操作 |
| unlock(解锁) | 主内存 | — | 释放主内存变量的独占标记,允许其他线程重新锁定 |
"松耦合传输"说明:read 与 load、store 与 write 必须成对出现,但两步之间存在时延(JLS 原文:"there may be some transit time between main memory and a working memory"),因此称为松耦合(loosely coupled)。"传输通道"并非 JLS 术语,是对这段时延的通俗化抽象。
两条数据通路:
从主内存读到执行引擎:read ──→ load ──→ use
主内存 工作内存 执行引擎
从执行引擎写回主内存:assign ──→ store ──→ write
工作内存 工作内存 主内存
常见混淆点:
read≠load:read是"从主内存取出",load是"装入工作内存副本"。两步必须连续配对使用,不能单独出现。store≠write:store是"从工作内存取出",write是"写入主内存"。同样必须配对。use和assign是执行引擎的入口/出口,只作用于工作内存,不直接触碰主内存。
拆成两步的设计是刻意的:JMM 可以在 read/load 之间、store/write 之间插入其他操作(如屏障),为可见性规则留出空间。
数据竞争与顺序一致性
- 数据竞争(Data Race):两个线程并发访问同一变量,至少一个是写,且没有同步手段
- 顺序一致性(Sequential Consistency):所有操作按某个全局顺序执行,且每个线程内操作顺序与代码一致
JMM 不保证顺序一致性,但保证:正确同步的程序(无数据竞争)具有顺序一致性的执行结果。
happens-before 规则
happens-before 是 JMM 的核心承诺,定义了操作之间的可见性保证。
定义
若操作 A happens-before 操作 B,则:
- A 的执行结果对 B 可见
- A 的执行顺序在 B 之前
注意:happens-before 不等于 时间上的先后,而是可见性和顺序保证。
八条基本规则
| 规则 | 内容 |
|---|---|
| 程序顺序规则 | 同一线程内,前面的操作 happens-before 后面的操作 |
| 监视器锁规则 | unlock 操作 happens-before 后续对同一锁的 lock 操作 |
| volatile 变量规则 | 对 volatile 变量的写 happens-before 后续对该变量的读 |
| 线程启动规则 | Thread.start() happens-before 被启动线程的所有操作 |
| 线程终止规则 | 线程的所有操作 happens-before Thread.join() 的返回 |
| 线程中断规则 | 对线程的 interrupt() happens-before 被中断线程检测到中断 |
| 对象终结规则 | 对象构造完成 happens-before finalize() 方法 |
| 传递性规则 | 若 A hb B,B hb C,则 A hb C |
传递性的力量
传递性规则让 happens-before 可以"借力":
// 线程 A
x = 42; // ① 写 x
flag = true; // ② 写 volatile flag(hb ③)
// 线程 B
if (flag) { // ③ 读 volatile flag
print(x); // ④ 读 x
}
推导链:① hb ②(程序顺序规则)→ ② hb ③(volatile 规则)→ ③ hb ④(程序顺序规则) → 由传递性:① hb ④,线程 B 读到的 x 一定是 42。
规则的重要性层次
8 条规则并非同等重要,可按角色分三层:
| 层次 | 规则 | 角色 |
|---|---|---|
| 地基 | 程序顺序规则 | 线程内顺序的基础,缺失则单线程推理崩溃 |
| 跨线程入口 | volatile 变量规则、监视器锁规则 | 真正建立跨线程 hb 边;没有它们,线程间没有任何同步语义 |
| 放大器 | 传递性规则 | 将孤立的 hb 边焊接成推导链;没有它,volatile 只能保证直接相关的变量可见 |
| 特定场景的桥 | 线程启动/终止/中断、对象终结规则 | 覆盖具体同步场景,可从上两层推导,属于便利规则 |
传递性是放大器而非核心:上例中如果没有 ② hb ③(volatile 规则建立的跨线程边),传递性无从发挥;如果没有传递性,volatile 只能证明 flag 可见,推断不出 x 可见。三者缺一不可,互为依托。
规则的最小化视角
从能否被其他规则推导来看:
4 条核心规则(不可消除):程序顺序、监视器锁、volatile 变量、传递性
4 条便利规则(可从核心推导):
| 便利规则 | 等价于 |
|---|---|
| 线程启动规则 | Thread.start() 内部有隐式 Release 语义,新线程第一条指令有隐式 Acquire 语义 |
| 线程终止规则 | 线程结束处有隐式 Release,Thread.join() 返回处有隐式 Acquire |
| 线程中断规则 | 中断标志具有 volatile 语义 |
| 对象终结规则 | 终结队列内部使用了同步机制,是监视器锁规则的应用 |
JSR-133 正式论文(Manson、Pugh、Adve,POPL 2005)将整个 JMM 精简为 3 个概念:
happens-before = 传递闭包(程序顺序 ∪ synchronizes-with)
synchronizes-with 一个关系统一了所有跨线程同步机制(volatile、monitor、thread start/join 等)。JLS 展开成 8 条,是为了让程序员直接查表,而非每次从头推导。
volatile 深度解析
两个语义
volatile 提供两个保证:
- 可见性:对 volatile 变量的写立即对其他线程可见(写刷主内存,读从主内存取)
- 禁止重排序:对 volatile 变量的读写不能与前后的读写发生重排序
JMM 规定的屏障插入规则
JMM 要求编译器在 volatile 读写的前后插入特定屏障(这是 JMM 层面的规范,JVM 实现会根据具体 CPU 优化):
volatile 写之前:StoreStore Barrier(前面的写不能越过 volatile 写)
volatile 写之后:StoreLoad Barrier(volatile 写不能越过后面的读)
volatile 读之后:LoadLoad Barrier(volatile 读后的读不能越过 volatile 读)
volatile 读之后:LoadStore Barrier(volatile 读后的写不能越过 volatile 读)
图示:
StoreStore
↓
[volatile 写]
↓
StoreLoad(最重的屏障)
[volatile 读]
↓
LoadLoad + LoadStore
x86 上的实现
x86 TSO 已保证 LoadLoad、StoreStore、LoadStore 的顺序,JVM 只需在 volatile 写后插入一条 LOCK ADD 指令(充当 StoreLoad 屏障),volatile 读则不需要额外指令。
; volatile 写:flag = true
MOV [flag], 1
LOCK ADD [rsp], 0 ; StoreLoad 屏障,冲刷 Store Buffer
; volatile 读:r = flag
MOV EAX, [flag] ; 无额外屏障指令(x86 TSO 已保证)
ARM 上的实现
ARM 需要更多屏障:
; volatile 写:使用 STLR(Store-Release)
STLR W0, [flag] ; 隐含 Release 语义
; volatile 读:使用 LDAR(Load-Acquire)
LDAR W0, [flag] ; 隐含 Acquire 语义
volatile 的限制
volatile 不能替代 synchronized 的场景:
// 错误!volatile 不保证复合操作的原子性
volatile int count = 0;
// 线程 A 和 B 同时执行:
count++; // 这是 read-modify-write,三步操作,volatile 无法保证原子性
count++ 实际是三步:① 读 count → ② count+1 → ③ 写 count
两个线程可能同时读到同一个旧值,各自 +1 后写回,结果只增加了 1。
volatile 适用场景:
- 状态标志(boolean flag,只有写和读,无复合操作)
- 安全发布(配合 final 字段)
- 双重检查锁定(见后文)
synchronized 与内存模型
获取/释放语义
synchronized 在 JMM 层面提供监视器锁规则:
进入 synchronized 块(monitorenter)= Acquire 语义
→ 清空工作内存,从主内存重新读取变量
退出 synchronized 块(monitorexit)= Release 语义
→ 将工作内存中的修改刷回主内存
这比 volatile 更强:不仅保证可见性,还保证互斥(同一时刻只有一个线程能执行临界区)。
底层实现
synchronized (lock) {
// 临界区
}
编译后:
monitorenter ← 对应 lock 前的 Acquire 屏障(LoadLoad + LoadStore)
临界区代码
monitorexit ← 对应 unlock 前的 Release 屏障(StoreStore + StoreLoad)
x86 上,JVM 实现 synchronized 使用 LOCK CMPXCHG(CAS)尝试获取锁,底层隐含了全屏障语义。
synchronized vs volatile
| 维度 | synchronized | volatile |
|---|---|---|
| 可见性 | ✅ | ✅ |
| 原子性 | ✅(整个临界区原子) | ❌(仅单次读/写原子) |
| 禁止重排序 | ✅ | ✅ |
| 阻塞线程 | 是(竞争时挂起) | 否(非阻塞) |
| 性能 | 较重 | 轻量 |
| 适用场景 | 复合操作、互斥 | 状态标志、简单发布 |
final 与安全发布
初始化安全性
JMM 对 final 字段有特殊规定:只要对象被正确构造(构造函数中不发布 this 引用),final 字段的值对所有线程可见,无需额外同步。
class ImmutablePoint {
final int x;
final int y;
ImmutablePoint(int x, int y) {
this.x = x; // final 写
this.y = y; // final 写
} // 构造函数结束:插入 StoreStore 屏障
}
// 发布(通过 volatile 或 synchronized)
volatile ImmutablePoint point;
point = new ImmutablePoint(1, 2); // 其他线程读到 point 后
// 保证看到 x=1, y=2
为什么 final 需要特殊处理
没有 final 保证时,可能发生构造函数写入和对象引用发布的重排序:
正常顺序: x = 1 → y = 2 → this 引用写到共享变量
重排序后: this 引用写到共享变量 → x = 1 → y = 2
其他线程可能通过共享变量读到对象引用,此时 x, y 还是默认值 0。final 的屏障保证了这种重排序不会发生。
不安全发布的例子
this 逸出发生在 x = 42 之前——构造函数还没完成,final 字段的"冻结"动作尚未触发:
class Unsafe {
final int x; // 即便是 final,也救不了
Unsafe() {
GlobalHolder.ref = this; // ① this 逸出:x 尚未赋值!
x = 42; // ② final 写——对已看到 ① 的线程已经太晚了
} // ③ 构造函数结束:freeze 才在此刻触发
}
// 其他线程在 ① 之后、② 之前读到 ref != null,此时 x 还是默认值 0
// final 的可见性保证依赖 freeze(③),this 提前逸出绕过了它
对比正确写法——this 只能在构造函数返回后通过安全发布机制(volatile / synchronized)传递给其他线程:
// ✅ 安全:构造函数内不泄露 this
volatile Unsafe ref;
ref = new Unsafe(); // 构造完成后再发布,freeze 已触发,x == 42 对所有线程可见
双重检查锁定(DCL)
问题背景
单例模式的懒加载中,为避免每次 getInstance() 都加锁:
// 错误的 DCL(Java 5 之前)
class Singleton {
private static Singleton instance;
public static Singleton getInstance() {
if (instance == null) { // 第一次检查(无锁)
synchronized (Singleton.class) {
if (instance == null) { // 第二次检查(有锁)
instance = new Singleton();
}
}
}
return instance;
}
}
为什么错误?
instance = new Singleton() 在字节码层面是三步:
- 分配内存空间
- 初始化对象(调用构造函数)
- 将引用赋给
instance
步骤 2 和 3 可能被重排序:先执行 3(instance 不为 null),再执行 2(对象未初始化)。另一个线程在第一次检查时看到 instance != null,直接使用了一个未初始化的对象。
正确的 DCL:volatile
class Singleton {
// volatile 禁止步骤 2 和 3 的重排序
private static volatile Singleton instance;
public static Singleton getInstance() {
if (instance == null) {
synchronized (Singleton.class) {
if (instance == null) {
instance = new Singleton();
}
}
}
return instance;
}
}
volatile 的 StoreStore 屏障保证:构造函数中的写操作(步骤 2)一定在 instance 赋值(步骤 3)之前完成。
更优雅:静态内部类(推荐)
class Singleton {
private Singleton() {}
private static class Holder {
static final Singleton INSTANCE = new Singleton();
}
public static Singleton getInstance() {
return Holder.INSTANCE;
}
}
利用类加载机制保证初始化的线程安全,无需 volatile,无需 synchronized,延迟加载。
各层对照总结
从硬件到 Java 的完整映射
Java 代码
volatile 写 / synchronized 退出
↓ JIT 编译
JVM 字节码指令 + 屏障语义
↓ 平台相关实现
x86: LOCK ADD / MFENCE
ARM: STLR / LDAR / DMB ISH
↓ 硬件执行
冲刷 Store Buffer → Cache 一致性(MESI)→ 其他核可见
各机制保证对比
| 机制 | 可见性 | 原子性 | 有序性 | 性能代价 |
|---|---|---|---|---|
volatile |
✅ | 仅单次读/写 | ✅ 禁止重排 | 低 |
synchronized |
✅ | ✅ 整块临界区 | ✅ | 中(无竞争时 JIT 优化后低) |
final |
✅ 初始化安全 | — | ✅ 构造完成前不泄露 | 极低 |
| CAS 与 ABA 问题|AtomicXxx | ✅ | ✅ CAS 原子操作 | 部分(取决于 memory order) | 低(CAS 自旋) |
| AQS 原理深度解析|ReentrantLock | ✅ | ✅ | ✅ | 中 |
常见误区
| 误区 | 正确理解 |
|---|---|
| volatile 保证原子性 | ❌ 只保证单次读/写原子,count++ 不原子 |
| synchronized 只是互斥锁 | ❌ 还有内存可见性语义(Acquire/Release) |
| happens-before = 时间先后 | ❌ 是可见性和顺序的逻辑保证,不是时钟顺序 |
| x86 不需要内存屏障 | ❌ 编译器重排序仍需屏障,StoreLoad 仍需 MFENCE |
| volatile 可以替代锁 | ❌ 复合操作(check-then-act)必须用锁 |
在线工具
MESI 协议动态模拟器
VivioJS MESI 模拟器(Trinity College Dublin,Jeremy Jones 制作)
可视化演示 3 个 CPU + 主内存的缓存一致性协议,支持手动触发 read/write 操作,实时观察缓存行状态(I/E/M/S)和总线事务。
操作方式:
- 点击图表区域激活动画
- 点击各 CPU 上的 read / write 按钮,触发对
a0~a3的内存操作 - 观察缓存行颜色(绿色 = 命中,灰色 = stale)和状态标签的变化
推荐入门序列(从 RESET 开始):
- CPU0 read a0 → 状态变 E(Exclusive,仅 CPU0 有且与内存一致)
- CPU0 write a0 → 状态变 M(Modified,内存已过期)
- CPU1 read a0 → CPU0 缓存介入,同步给 CPU1 和内存 → 状态变 S(Shared)
- CPU1 write a0 → 使其他缓存中的 a0 失效 → CPU1 状态变 E
参考资料
- JSR-133: Java Memory Model FAQ — Jeremy Manson, Brian Goetz
- Java Language Specification §17: Threads and Locks (Java SE 21)
- Weak vs. Strong Memory Models — Jeff Preshing
- Memory Ordering at Compile Time — Jeff Preshing
- Acquire and Release Semantics — Jeff Preshing
- 《深入理解 Java 虚拟机》— 周志明,第 12 章
- 《Java 并发编程实战》— Brian Goetz 等,附录 JMM
- 《深入浅出计算机组成原理》— 郑晔,极客时间
评论 (0)
发表评论