Skip to content

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 的重排序

java
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)线程读写变量时使用的抽象状态每条线程私有
txt
┌──────────────┐          ┌──────────────┐
│   线程 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 之外的基本类型读写是原子的;普通的非 volatile long / double 变量在某些实现上允许采用两次 32 位操作进行读写,因此规范不保证这类访问不可撕裂,volatile 的 long / double 读写则具有原子性保证
  • synchronized 块内的操作,对持有同一把锁的线程而言是原子的
  • java.util.concurrent.atomic 里的原子类通过 CAS 提供无锁原子操作(第 7 章展开)

3.2 可见性 ​

一个线程的写入在什么条件下对另一个线程的读取可见?

反例的常见形状:

java
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-before
  • synchronized:通过解锁与随后的加锁建立 happens-before
  • final:提供构造完成后的初始化安全保证(见 §5)

三者从不同粒度解决同一件事。具体机制在第 5、6 章分别展开。

3.3 有序性 ​

程序执行顺序和代码书写顺序是否一致。

单线程视角下重排序无害(as-if-serial);多线程视角下才暴露。经典案例是 DCL:

java
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() 概念性地拆成三步(它们不对应固定的三条字节码指令,但足以说明初始化与引用发布的顺序为什么重要):

txt
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 后面的操作。

java
int x = 1;         // A
int y = x + 1;     // B  —— A hb B

保证了单线程内的语义一致性。你写单线程代码不用担心 JMM。

规则 2 · volatile 规则:对 volatile 变量的写 happens-before 后续对同一变量的读。

java
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 后续对同一把锁的加锁。

java
synchronized (lock) {
    sharedVar = 100;
}                        // A · unlock

synchronized (lock) {    // B · lock  —— A hb B
    print(sharedVar);    // 一定是 100
}

规则 4 · 线程启动:调用 Thread.start() 的动作 happens-before 被启动线程中的任何动作。

java
int x = 1;
Thread t = new Thread(() -> print(x));  // 一定看到 x = 1
t.start();

规则 5 · 线程终止:一条线程内所有操作 happens-before 其他线程感知到它已终止(join 返回、isAlive 变 false)。

java
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 加上:

java
private static volatile Singleton instance;

推导过程:

txt
线程 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 规定的初始化安全语义。实现层面可以这样理解两条约束:

  1. 构造函数中对 final 字段的写,不允许被重排到"构造函数之后"
  2. 发布对象引用(把 this 或新对象赋给共享变量)不允许被重排到"构造函数之前"

这两条约束组合起来的效果是:对象在构造过程中正确初始化 final 字段,且构造期间没有发生破坏该保证的 this 逸出时,即使对象引用的发布缺少普通的同步关系,其他线程取得该对象引用后,仍能获得 JMM 对这些 final 字段提供的初始化保证。这不是一般意义上的安全发布:普通字段仍可能不可见,final 引用指向的可变对象也不会因此自动变得线程安全。

5.2 有 final 与没有 final 的差异 ​

java
// 场景 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;
    }
}

用一个共享引用发布:

java
// 共享字段;这里仅展示 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-beforevolatile 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 保护
java
// ❌ 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 屏障约束的边界示意 ​

txt
线程中的操作流

读 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分别提供不同粒度的保证。把规则用于生产问题时,进入并发问题诊断。

Released under the MIT License.