JMM(Java 内存模型)是 Java 为跨平台并发定义的一组内存语义规范:它用主内存与工作内存的抽象描述线程之间如何通信,用 happens-before 规则回答「一个线程的写何时对另一个线程可见」,并在尽量少牺牲性能的前提下约束编译器和处理器的重排序行为。

它解决什么问题

并发程序的所有诡异 Bug,追到底层往往只剩两个硬件事实。

其一是 CPU 多级缓存带来的可见性问题。CPU 主频远高于内存,处理器普遍配有 L1/L2/L3 缓存:线程读共享变量时先拷入缓存,算完再择机写回主存。两个线程跑在不同核上执行 i++,各自基于缓存里的旧值加一后写回,结果就丢失了一次更新。硬件层用 MESI 这类缓存一致性协议兜底,但协议只保证单个缓存行的最终一致,不管程序语义。

其二是 指令重排序带来的有序性问题。编译器(含 JIT)在不改变单线程语义的前提下会调整语句顺序,处理器也会把不存在数据依赖的指令重叠执行。重排序对单线程是透明的,但它没有任何义务照顾其他线程的观察顺序——一个线程看到的「执行顺序」可能和另一个线程写代码的顺序完全不同。

Java 要跨平台,不可能直接复用某个操作系统或某种 CPU 的内存模型,否则同一段字节码换个平台语义就变了。于是 Java 在语言层定义了自己的内存模型(Java 5 起由 JSR-133 定型),向上给程序员一套简单规则,向下由 JVM 映射到具体平台的屏障指令。这就是 JMM 存在的意义:屏蔽硬件差异,让「正确编写的并发程序」在任何平台都可推理。

核心原理

主内存与工作内存的抽象

JMM 把内存划分为两块逻辑区域:

  • 主内存:所有线程共享的区域,实例字段、静态字段、数组元素都存在这里。它是规范层面的概念,不对应某块物理内存。
  • 工作内存:每个线程私有的区域,保存该线程读写过的共享变量副本。线程不能直接读写别人的工作内存,线程间传递数据必须经过主内存中转。

工作内存同样是抽象,它涵盖了寄存器、CPU 缓存、写缓冲区等一切「让线程暂时持有副本」的硬件与编译器优化。JMM 规定:没有建立同步关系时,一个线程的写不保证被另一个线程看到;只有满足特定规则,可见性才被承诺。

happens-before:判断可见性的标尺

JSR-133 用 happens-before 描述两个操作间的内存可见性关系:若操作 A happens-before 操作 B,则 A 的结果对 B 可见。注意它表达的是可见性承诺而非时序承诺——只要重排后的结果与按 happens-before 执行一致,JMM 允许实现层自由优化。

常用规则共八条:

  1. 程序顺序规则:同一线程内,书写在前的操作 happens-before 书写在后的操作。
  2. 监视器锁规则:对同一把锁的 unlock happens-before 后续对该锁的 lock。
  3. volatile 规则:对 volatile 变量的写 happens-before 后续对同一变量的读。
  4. 线程启动规则:Thread.start() happens-before 新线程内的任何动作。
  5. 线程终止规则:线程内的所有动作 happens-before 其他线程从该线程的 join() 返回。
  6. 线程中断规则:interrupt() 调用 happens-before 被中断线程检测到中断事件。
  7. 对象终结规则:对象构造完成 happens-before 其 finalize() 开始。
  8. 传递规则:A happens-before B、B happens-before C,则 A happens-before C。

推理方法很机械:两个操作之间若能沿这些规则串出一条链,可见性成立;串不出来就是数据竞争,结果不可预期。

指令重排与内存屏障

重排序来自三层:编译器优化重排、处理器指令级并行、内存子系统的缓存与写缓冲。JMM 的对策分两类:对编译器,直接禁止会破坏 happens-before 语义的特定重排;对处理器,由 JVM 在关键位置插入内存屏障——一类约束屏障两侧内存访问顺序与可见性的指令,具体形态因平台而异,不能简单理解成「把缓存刷回主存」。

深入机制

volatile 到底保证了什么

volatile 是 JMM 暴露给程序员的最轻量同步手段,语义有两条:

  1. 可见性:写 volatile 变量后立即对其他线程的读可见。更进一步,由于 happens-before 的传递性,写 volatile 之前的所有普通写,对之后读到该 volatile 的线程同样可见——这是很多人忽略的「顺带效应」。
  2. 有序性:禁止与 volatile 读写相关、会破坏语义的特定重排。实现上,JVM 会在 volatile 写之后、volatile 读之后插入相应的屏障。

volatile 不保证原子性。count++ 是读-改-写三步复合操作,volatile 只能保证每步读到的是最新值,拦不住两个线程读到同一个旧值各自加一:

public class VolatileCounter {
private volatile int count = 0;

public void increment() {
count++; // 非原子:读 count、加 1、写回 count
}
}

两个线程并发调用一万次,最终结果几乎必然小于两万。需要原子计数请用 AtomicInteger 或锁。

重排的危害用一个经典例子看更清楚:

int a = 0;
boolean flag = false;

// 线程 1
void writer() {
a = 1; // 1
flag = true; // 2
}

// 线程 2
void reader() {
if (flag) { // 3
int x = a; // 4:可能读到 0
}
}

语句 1 和 2 没有数据依赖,允许重排。一旦 2 先于 1 执行,线程 2 在 3 处看到 flag == true,在 4 处却读到 a == 0。把 flag 声明为 volatile 后,1 happens-before 2、3 happens-before 4、2 happens-before 3,传递出 1 happens-before 4,读到 0 的路径被规则封死。

双重检查锁的完整推演

延迟初始化的单例是 volatile 教科书级场景:

public class Singleton {
private static volatile Singleton instance;

private Singleton() {}

public static Singleton getInstance() {
if (instance == null) { // 第一次检查:避免已初始化后还进锁
synchronized (Singleton.class) {
if (instance == null) { // 第二次检查:排队期间可能已被初始化
instance = new Singleton();
}
}
}
return instance;
}
}

两次检查缺一不可,但真正的杀招在 volatile 上。instance = new Singleton() 并非原子操作,它近似于三个步骤:

  1. 为对象分配内存;
  2. 执行构造器,初始化字段;
  3. 让 instance 引用指向这块内存。

步骤 2 和 3 之间没有数据依赖,允许重排。若没有 volatile,执行顺序可能变成 1 → 3 → 2:线程 A 刚把引用指过去、构造器还没跑完,线程 B 在第一次检查处看到引用非 null,直接返回一个字段全是默认值的半成品对象。加了 volatile 后,步骤 3 是对 volatile 变量的写,步骤 2 不得重排到它之后;而线程 B 读到的若是已发布的引用,根据 volatile 规则,构造器里的所有写入对它全部可见。这就是 DCL 必须配 volatile 的完整因果链(JDK 5 之前的旧内存模型下 volatile 语义不足,此写法会失效,这也是 JSR-133 修订的动机之一)。

工程实践

  • volatile 适用面:状态开关(volatile boolean shutdown)、一次性安全发布、DCL 这类「一写多读、读不依赖当前值做运算」的场景。凡是复合操作(自增、check-then-act)都必须换原子类或锁。
  • 优先用 JUC 而非手写协议:JMM 规则是给你推理用的,不是鼓励你用裸 volatile 搭并发协议。Atomic 类、锁、并发容器内部已把 happens-before 安排妥当,业务代码站在其上即可。
  • 别把 JMM 和 JVM 内存区域混为一谈:堆、栈、方法区是运行时数据布局;主内存、工作内存是并发语义抽象。说「工作内存就是栈」是典型误区,两者回答的问题完全不同。
  • 排障思路:遇到「改了他看不见」「偶尔拿到半初始化对象」这类玄学 Bug,先检查共享变量之间是否存在数据竞争,再按 happens-before 规则逐条推导,而不是盲目加锁。

对比与权衡

维度 volatile synchronized ReentrantLock Atomic 原子类
可见性 保证 保证 保证 保证
原子性 不保证 保证(互斥) 保证(互斥) 单变量复合操作保证
有序性 约束相关重排 通过互斥间接保证 同左 依赖底层语义
阻塞 不阻塞 阻塞 可阻塞/可超时/可中断 不阻塞(自旋)
典型场景 标志位、安全发布 简单互斥临界区 需要公平锁、条件队列 计数器、无锁更新

选型原则:只需可见性用 volatile;单变量复合操作优先原子类;临界区涉及多个变量或复杂不变量时,锁是唯一稳妥答案。

面试视角

JMM 和 JVM 内存区域有什么区别? JVM 内存区域(堆、栈等)描述运行时数据存在哪,是物理布局问题;JMM 描述多线程读写共享变量的可见性与顺序,是并发语义问题。两者名字像,回答的问题完全不同。

happens-before 是时间上的先后吗? 不是。它承诺的是前一个操作的结果对后一个操作可见,只要结果一致,实现层允许重排。它是 JMM 给程序员的推理工具,而非对执行顺序的规定。

volatile 为什么保证不了 i++? volatile 只保证单次读或写的可见性与有序性,i++ 是读、加、写三个动作的组合,两个线程可能读到同一个旧值,各自加一后写回,丢一次更新。原子性需要互斥或 CAS 提供。

双重检查锁为什么必须加 volatile? new Singleton() 会分解为分配内存、初始化、引用赋值三步,后两步允许重排。不加 volatile,其他线程可能拿到引用已赋值但构造器未执行完的对象。volatile 的写屏障禁止该重排,并通过 happens-before 保证初始化结果对读者可见。

内存屏障就是刷新缓存吗? 不准确。屏障约束的是屏障两侧内存访问的顺序与可见性,JVM 会按目标平台选择满足语义的指令(可能是 lock 前缀指令等),不同架构实现不同,不能一概理解为「刷缓存到物理内存」。

小结

  • JMM 是为跨平台并发而生的语言级规范:向上提供 happens-before 规则供程序员推理,向下由 JVM 映射为屏障与禁重排。
  • 主内存/工作内存是语义抽象;线程间通信必须经过主内存,未建立 happens-before 的写对读者不可见。
  • happens-before 八大规则覆盖程序顺序、锁、volatile、线程生命周期与传递性,是判定数据竞争的标尺。
  • volatile 给可见性与有序性,不给原子性;DCL 的 volatile 是为了封死对象发布时的初始化重排。