Java 内存模型(JMM):线程如何看到数据
线程 A 写完
x = 42,线程 B 却读到0——出错的是 CPU、编译器、还是 Java?volatile、synchronized、final到底各自保证了什么?没有 JMM,"多线程正确"这件事就没有可推理的地基。
前面三章解决了"线程是什么"、"怎么创建"、"线程私有的 ThreadLocal 如何回避竞争"。真正无法回避的情况——多个线程访问同一份数据——从这一章开始。JMM 是 Java 给"共享数据"这件事定的规则本身。理解它,才能推理为什么 volatile 够用、为什么 synchronized 必要、为什么 final 也是一种并发工具。
1. 为什么需要 JMM
1.1 单线程直觉在多核上失效
单线程世界的两条直觉——"代码从上往下执行"、"写入的值立刻能读到"——都在多核硬件上被打破了。
第一处:CPU 缓存
现代 CPU 采用多级缓存架构。每个核心有私有的 L1 / L2,多个核心共享 L3,最后才是主内存:
Core 0 把 x = 1 写到自己的 L1 时,这个修改并不会立刻写回主内存。此时 Core 1 从自己 L1 读到的可能仍是旧值 0。可见性问题的硬件根源就在这里。
第二处:编译器和 CPU 的重排序
int a = 1; // ①
int b = 2; // ②
int c = a + b; // ③编译器和 CPU 会重排序为 ② → ① → ③——单线程下 ① 和 ② 无依赖,交换不影响结果。这在单线程语义下是安全的(as-if-serial),在多线程环境下则可能违反同步预期(DCL 案例见 §3.3)。
1.2 JMM 的定位
面对缓存和重排序,Java 需要一份规范契约回答三个问题:
- 一个线程的写入,在什么情况下对另一个线程可见
- 什么样的重排序是被允许的,什么不允许
- 通过哪些语言层机制(
volatile、synchronized、final)能主动建立保证
这份规范就是 Java 内存模型(Java Memory Model, JMM),由 JSR-133 在 Java 5 时代重新定义。它同时约束程序员、JIT 编译器、JVM 实现和 CPU——四方共同遵守才有"多线程正确"这回事。
2. JMM 的抽象
2.1 JMM 不描述物理内存
一个高频误解:JMM 讨论的是 L1/L2/L3 缓存的组织方式。并不是。JMM 描述的是 Java 语言层面的并发访问规则——物理内存怎么组织是硬件的事。
JMM 只回答一个问题:
在什么条件下,线程 A 对变量 V 的写入,对线程 B 的后续读取是可见的?
2.2 主内存与工作内存的教学抽象
解释跨线程可见性时,教程经常使用“主内存”和“工作内存”两个概念。它们帮助建立直觉,但不是 JMM 规定的物理结构,也不能直接对应 L1/L2/L3、寄存器或某个固定的刷新动作:
| 概念 | JMM 中的抽象含义 | 归属 |
|---|---|---|
| 主内存(Main Memory) | 共享变量的抽象存储位置 | 所有线程共享 |
| 工作内存(Working Memory) | 线程读写变量时使用的抽象状态 | 每条线程私有 |
┌──────────────┐ ┌──────────────┐
│ 线程 A │ │ 线程 B │
│ │ │ │
│ ┌────────┐ │ │ ┌────────┐ │
│ │工作内存 │ │ │ │工作内存 │ │
│ │ x = ? │ │ │ │ x = ? │ │
│ └────┬───┘ │ │ └────┬───┘ │
│ │ load │ │ │ load │
└───────┼──────┘ └───────┼──────┘
│ │
│ ┌───────────────┐ │
└───►│ 主内存 │◄───┘
│ x = 0 │
└───────────────┘这张图要表达的是:
- 线程可能观察到自己此前缓存或计算出的旧值
- 线程之间不能靠彼此的私有缓存隐式建立同步
- 必须通过
volatile、锁、启动/结束任务等关系建立 happens-before,推理写入何时对读取可见
JMM 不要求实现“先写主内存、再从主内存读取”。具体可能涉及缓存一致性、寄存器、编译器重排和指令屏障,但这些属于 JVM 与硬件实现;程序员需要依据的仍是 happens-before 规则。
2.3 JMM 是谁的契约
JMM 不是给程序员参考的软性约定,是四方都要遵守的硬规则:
- JIT 编译器:不允许把有 happens-before 关系的操作重排到破坏语义的位置
- JVM 实现:必须在合适的位置插入相应的内存屏障指令
- CPU:屏障指令映射到硬件的缓存一致性行为
- 程序员:正确使用
volatile、synchronized、final、java.util.concurrent建立语义
四方协同才能让 happens-before 规则跨过 JVM 边界,最终在多核硬件上落地。
3. 三个核心问题
JMM 处理的所有并发问题都可以归到三类:原子性、可见性、有序性。
3.1 原子性
一个操作或一组操作要么全部完成、要么全部不发生,中间不能被打断。
第 1 章展示过 count++ 的失败——它在字节码层面是三步 LOAD → ADD → STORE,两条线程同时读到同一个旧值,各自加 1 后写回,丢一次自增。"读-改-写"这类复合操作的非原子性,是 JMM 从规范层要回答的第一件事。
JMM 保证的原子性范围:
- 除
long和double之外的基本类型读写是原子的;普通的非volatilelong/double变量在某些实现上允许采用两次 32 位操作进行读写,因此规范不保证这类访问不可撕裂,volatile的long/double读写则具有原子性保证 synchronized块内的操作,对持有同一把锁的线程而言是原子的java.util.concurrent.atomic里的原子类通过 CAS 提供无锁原子操作(第 7 章展开)
3.2 可见性
一个线程的写入在什么条件下对另一个线程的读取可见?
反例的常见形状:
boolean flag = false;
int value = 0;
// 线程 A
value = 42;
flag = true;
// 线程 B
while (!flag) {} // 可能死循环
print(value); // 即便跳出,也可能打印 0两个失败点叠加:
- 缺少可见性保证:线程 A 对
flag的写与线程 B 对flag的读之间没有建立 happens-before - 缺少有序性保证:
value的写与flag的写、读之间没有建立足够的顺序约束
再叠加一层编译器优化:while (!flag) {} 的 flag 读取可能被 JIT 提升到循环外(因为它在单线程视角看是"循环不变量"),一次也不再重新读——变成真死循环。
常见的可见性保证来自 volatile 读写、锁的释放与获取等同步关系,final 另外提供构造期间正确初始化的特殊保证,不能替代一般性的同步机制:
volatile:通过 volatile 写与后续 volatile 读建立 happens-beforesynchronized:通过解锁与随后的加锁建立 happens-beforefinal:提供构造完成后的初始化安全保证(见 §5)
三者从不同粒度解决同一件事。具体机制在第 5、6 章分别展开。
3.3 有序性
程序执行顺序和代码书写顺序是否一致。
单线程视角下重排序无害(as-if-serial);多线程视角下才暴露。经典案例是 DCL:
public class Singleton {
private static Singleton instance; // ⚠️ 没有 volatile
public static Singleton getInstance() {
if (instance == null) {
synchronized (Singleton.class) {
if (instance == null) {
instance = new Singleton(); // ← 问题在这
}
}
}
return instance;
}
}从理解对象创建与发布的角度,可以把 instance = new Singleton() 概念性地拆成三步(它们不对应固定的三条字节码指令,但足以说明初始化与引用发布的顺序为什么重要):
a. 分配内存
b. 执行构造函数,初始化字段
c. 把引用赋值给 instance编译器可能把顺序重排成 a → c → b(这在单线程 as-if-serial 下是合法的——反正没别的线程能看到)。如果发生了这个重排,第二条线程从外层 if 拿到的 instance 就是"引用已赋值、字段还没初始化"的半成品:读它的字段会读到默认值(null / 0)。
这个 DCL 实现的关键修复,是把 instance 声明为 volatile。volatile 写具有发布语义,volatile 读具有获取语义;当读取线程观察到相应的 volatile 写时,可以依据 volatile 同步语义和 happens-before 的传递性,推导构造期间的初始化写入对读取线程可见。完整机制在第 5 章 §5.3 展开。
有序性的工具和可见性完全重合:
volatile:内存屏障禁止其前后的重排synchronized:临界区内的顺序对持锁线程稳定final:构造函数中对final字段的写不允许重排到"引用发布"之后
4. happens-before:JMM 的判断规则
原子性、可见性、有序性只是问题的分类。JMM 给出的判断工具是 happens-before。
4.1 定义
来自 JLS §17.4.5:
如果操作 A happens-before 操作 B,那么 A 的结果对 B 可见,且 A 排在 B 之前发生。
一句提醒:happens-before 不是"时间上先执行"。它是 JMM 定义的一种偏序关系,用于推理可见性——即使两个操作在物理时间上重叠,只要它们不存在 happens-before 关系,JMM 也不保证一个能看到另一个的写入。
4.2 六条主要规则
下面选取日常并发分析中最常用的六条关系。JLS §17.4.5 还规定了默认初始化、中断等其他同步关系,因此这不是 happens-before 规则的完整清单。
规则 1 · 程序顺序:同一条线程里,按照程序顺序排在前面的操作 happens-before 后面的操作。
int x = 1; // A
int y = x + 1; // B —— A hb B保证了单线程内的语义一致性。你写单线程代码不用担心 JMM。
规则 2 · volatile 规则:对 volatile 变量的写 happens-before 后续对同一变量的读。
volatile boolean flag = false;
int value = 0;
// 线程 A
value = 42;
flag = true; // A · volatile 写
// 线程 B
while (!flag) {} // B · volatile 读 —— A hb B
print(value); // 一定看到 42结合规则 1 的传递性,value = 42 的可见性也被 A 那次 volatile 写"顺带"带到了 B。
规则 3 · 锁规则:一个锁的解锁 happens-before 后续对同一把锁的加锁。
synchronized (lock) {
sharedVar = 100;
} // A · unlock
synchronized (lock) { // B · lock —— A hb B
print(sharedVar); // 一定是 100
}规则 4 · 线程启动:调用 Thread.start() 的动作 happens-before 被启动线程中的任何动作。
int x = 1;
Thread t = new Thread(() -> print(x)); // 一定看到 x = 1
t.start();规则 5 · 线程终止:一条线程内所有操作 happens-before 其他线程感知到它已终止(join 返回、isAlive 变 false)。
int result = 0;
Thread t = new Thread(() -> result = 42);
t.start();
t.join();
print(result); // 一定看到 42规则 6 · 传递性:A hb B、B hb C,则 A hb C。
传递性是 happens-before 真正强大的地方——它让你可以跨多个操作串出可见性链条。
4.3 用规则推导 DCL 的正确性
回到 §3.3 的 DCL,把 volatile 加上:
private static volatile Singleton instance;推导过程:
线程 A(首次创建):
内存分配 & 字段初始化 ── 程序顺序 hb ──► instance = new Singleton()(volatile 写)
线程 B(后续获取):
if (instance != null)(volatile 读) ── 程序顺序 hb ──► 访问 instance 字段
由 volatile 规则:A 的 volatile 写 hb B 的 volatile 读
由传递性:A 的字段初始化 hb B 对字段的访问
结论:B 看到的 instance 一定是完成构造的volatile 在这里做的事,是让"新对象初始化完成"这个事实沿着 happens-before 链条传给读端。这个推导过程也是理解"为什么 volatile 能修复 DCL"的正统路径——不是"因为 volatile 强制刷缓存"这类模糊说法。
5. final 的并发语义与安全发布
原子性、可见性、有序性都可以由 volatile 和 synchronized 显式建立。JMM 还有第三根支柱:final 字段的初始化安全保证。它常被忽略,却是 String、Integer、ImmutableList 这类不可变类线程安全的根基。
5.1 final 字段的初始化安全语义
final 字段具有 JSR-133 规定的初始化安全语义。实现层面可以这样理解两条约束:
- 构造函数中对
final字段的写,不允许被重排到"构造函数之后" - 发布对象引用(把
this或新对象赋给共享变量)不允许被重排到"构造函数之前"
这两条约束组合起来的效果是:对象在构造过程中正确初始化 final 字段,且构造期间没有发生破坏该保证的 this 逸出时,即使对象引用的发布缺少普通的同步关系,其他线程取得该对象引用后,仍能获得 JMM 对这些 final 字段提供的初始化保证。这不是一般意义上的安全发布:普通字段仍可能不可见,final 引用指向的可变对象也不会因此自动变得线程安全。
5.2 有 final 与没有 final 的差异
// 场景 1:普通字段,需要外部同步
public class NonFinalHolder {
int value; // 普通字段
public NonFinalHolder(int v) {
this.value = v;
}
}
// 场景 2:final 字段,JMM 提供发布保证
public class FinalHolder {
final int value; // final 字段
public FinalHolder(int v) {
this.value = v;
}
}用一个共享引用发布:
// 共享字段;这里仅展示 final 字段的初始化边界
private static NonFinalHolder h1;
private static FinalHolder h2;
// 线程 A
h1 = new NonFinalHolder(42);
h2 = new FinalHolder(42);
// 线程 B 观察共享引用
NonFinalHolder observed1 = h1;
FinalHolder observed2 = h2;
if (observed1 != null) print(observed1.value); // 可能读到 0
if (observed2 != null) print(observed2.value); // final 字段应为 42这段代码故意让共享引用处于数据竞争中,只用于对比 final 的初始化安全保证,不应作为生产发布方式。普通字段的写入与读取缺少同步时,读端可能观察到未完全构造的值;final 字段则由构造结束时的 freeze 保证其初始化值。实际代码仍应通过 volatile、锁、并发容器或正确的生命周期管理发布引用。
5.3 安全发布:把"对象可见"讲清楚
安全发布(safe publication) 是并发编程的常用术语,指"让另一条线程能安全地看到一个正确构造的对象"。工程中常见的安全发布方式包括以下几种:
| 发布方式 | 语义来源 | 使用形态 |
|---|---|---|
通过 static 初始化器 | 类初始化的同步语义 | private static final Holder INSTANCE = new Holder(); |
通过 volatile 字段发布 | volatile 写-读的 happens-before | volatile Config current; |
| 通过锁保护的字段发布 | 解锁-加锁的 happens-before | 在 synchronized 里写、在 synchronized 里读 |
| 通过并发容器发布 | 容器自身的内部同步 | ConcurrentHashMap.put → get |
注意:final 字段的初始化安全保证是 "构造完成后引用被拿到的那一刻可见",而不是"对象在任意时刻都能被任意线程安全访问"。如果构造后又通过普通字段发布对象引用,中间没有任何同步——发布本身仍然是不安全的。
5.4 final 保证的边界
final 提供的初始化安全不是万能盾。它有明确的边界:
- 只保护
final字段本身:非final字段没有这层保证 - 不保护构造期间
this逸出的场景:如果构造函数里把this传出去,另一条线程可能拿到"构造未完成"的引用,此时final保证同样落空 - 不保护
final引用指向的可变对象:final List<String> tags = new ArrayList<>();里的tags引用是安全发布的,但tags内部的元素依然是可变的、不受final保护
// ❌ this 逸出:final 也救不了
public class UnsafeThisEscape {
final int value;
public UnsafeThisEscape(EventBus bus) {
bus.register(this); // ← 构造尚未完成,this 就发布出去了
this.value = 42; // 其他线程可能在这一行之前访问 value
}
}
// ✅ 构造完成后再发布
public class SafeThisEscape {
final int value;
private SafeThisEscape() {
this.value = 42;
}
public static SafeThisEscape createAndRegister(EventBus bus) {
SafeThisEscape self = new SafeThisEscape();
bus.register(self); // 构造已完成才发布
return self;
}
}this 逸出是并发场景里非常隐蔽的一类 bug——构造函数看上去很正常,问题却出在被调用的外部方法里。识别方法很直接:构造期间应避免把 this 发布给其他线程,包括通过注册监听器、启动线程或调用未知回调等间接路径发布。关键不是"构造函数不能调用任何方法",而是不能让其他线程在对象初始化完成之前观察到它。
5.5 为什么不可变对象天然线程安全
结合 §4.5.1–4.5.4,可以给出"不可变对象为什么线程安全"的完整推导:
- 所有字段都是
final→ 初始化安全保证生效 - 构造完成后字段不再变化 → 不需要为"后续修改"建立同步
- 引用发布本身通过合适的通道(
static final、volatile、ConcurrentHashMap)完成安全发布 → 引用的可见性也解决了
这三条一起构成了 String、Integer、LocalDate、Guava ImmutableList 等不可变类无需加锁、无需 volatile、任意线程随便共享的语义地基。
6. JMM 与硬件的连接
前面五节讨论的是 Java 层规范。JVM 实现通常借助编译器屏障和 CPU 内存屏障等手段落实 happens-before,但具体指令、位置和优化方式由 JVM、JDK 版本与目标 CPU 决定。
6.1 内存屏障要做的四件事
内存屏障(memory barrier / memory fence)是约束内存访问重排序的机制。实现 JMM 时,JVM 可能同时利用编译器屏障、处理器内存屏障和目标架构本身提供的顺序保证。它的作用可以概括成四点:
- 约束某些读写不能跨越屏障重排
- 约束某些写入必须先完成再执行后续操作
- 让部分写入更快地对其他处理器可见
- 让部分读取从可靠的位置重新获取数据
没有这层约束,happens-before 规则很难真正落到硬件上——语言层要求"前面的写对后面的读可见",硬件层却可能因为缓存和乱序,让另一个核心暂时看不到这次写入。
6.2 四类内存屏障的常见模型
分析实现时,常把屏障归为以下四类。它们是讨论重排边界的模型,不表示 JMM 规定每个 volatile 操作都必须生成四条 CPU 指令:
| 屏障类型 | 抽象约束 | 说明 |
|---|---|---|
| LoadLoad | 限制前面的读与后面的读之间的重排 | 一种读操作顺序约束 |
| StoreStore | 限制前面的写与后面的写之间的重排 | 一种写操作顺序约束 |
| LoadStore | 限制前面的读与后面的写之间的重排 | 约束读写之间的顺序 |
| StoreLoad | 限制前面的写与后面的读之间的重排 | 通常较强的顺序约束,具体成本依架构而异 |
这些分类用于讨论实现层面的顺序约束,不与 volatile、锁或 final 语义一一对应。JVM 可以根据目标架构选择不同的实现方式,只要最终满足 JMM 的要求即可。
StoreLoad 通常同时约束后续读越过此前的写,在许多架构上成本较高;实际代价取决于 CPU 架构、JVM 优化和访问模式,因此不能仅凭屏障名称给出固定开销。
6.3 屏障约束的边界示意
线程中的操作流
读 A ── 读 B ── 写 C ── 写 D
↑
插入屏障后
↓
读 A ── [Barrier] ── 读 B ── 写 C ── 写 D屏障本身不承担业务逻辑——它做的是约束边界:告诉编译器和 CPU,某些读写不能跨过这条线。
约束落实后的程序仍按 JMM 的 happens-before 语义可推理;JVM 可以在不改变语义的前提下合并、消除或调整屏障。
6.4 CPU 架构差异
不同 CPU 架构对内存一致性的默认保证差别很大,这也是 JMM 必须存在的原因:
| CPU 架构 | 内存模型强度 | 特点 |
|---|---|---|
| x86 / x86-64 | 较强(TSO) | 硬件默认已经处理了一部分顺序问题,但不是全部 |
| ARM / AArch64 | 较弱 | 更依赖显式屏障 |
| RISC-V | 较弱(可配置) | 通过显式 fence 指令控制约束 |
并发 bug 容易出现平台差异的原因也在这里:同一段代码在 x86 上看起来稳定,迁到 ARM 后可能暴露。稳定的、可推理的保证来自 JMM 语义以及 JVM 正确实现这些语义,不来自“这台机器恰好帮你兜住了”。
6.5 三层关系一图收尾
JMM 定义并发语义、JVM 翻译为屏障、CPU 执行屏障——三层协作是"Java 并发能被推理"的物理基础。
7. 本章小结
| 问题 | 根源 | 解决方案 |
|---|---|---|
| 原子性缺失 | 复合操作可被中断 | synchronized / AtomicXxx / CAS |
| 可见性缺失 | CPU 缓存 + 编译器优化 | volatile / synchronized / final |
| 有序性缺失 | 编译器与 CPU 的重排 | volatile(屏障)/ synchronized |
| 单线程内可见性 | 程序顺序规则 | 天然保证 |
| 跨线程可见性 | happens-before 链 | volatile 规则 / 锁规则 / 传递性 |
| 对象发布不安全 | 构造与发布之间可能重排 | final 字段的初始化安全 + 安全发布通道 |
| 构造未完成引用逸出 | this 提前发布 | 构造函数里禁止 this 逸出 |
| CPU 架构导致的平台差异 | 内存模型强度不同 | 依赖 JVM 插入的屏障,不依赖硬件默认 |
8. 下一步
JMM 给出正确性的抽象规则,volatile和 synchronized分别提供不同粒度的保证。把规则用于生产问题时,进入并发问题诊断。