Skip to content

Java 内存模型(JMM)

现代 CPU 为了提高性能,引入了三个层面的优化,但都可能打破多线程程序的直觉:

1. CPU 缓存(Cache)
每个 CPU 核心有自己的 L1/L2 缓存
→ 导致不同核心对同一内存地址的值看法不一致(可见性问题)
2. 写缓冲区(Store Buffer)
CPU 将写操作先放入写缓冲区,延迟刷入内存
→ 其他核心可能看不到最新写入(可见性问题)
3. 指令重排(Reordering)
编译器和 CPU 会对无数据依赖的指令重排执行顺序以提高效率
→ 从其他线程视角看,代码执行顺序与源码不符(有序性问题)

Java 内存模型(JMM) 是 Java 规范层面对内存可见性和操作有序性的定义,屏蔽了不同 CPU 架构的差异,为开发者提供一套统一的并发编程规范。


JMM 的抽象模型:
┌─────────────────────────────────────┐
│ 主内存(Main Memory) │
│ 共享变量(实例字段、静态字段、数组元素) │
└─────────────┬───────┬───────────────┘
│ read │ write
▼ ▼
┌─────────────────┐ ┌─────────────────┐
│ 线程 A 工作内存 │ │ 线程 B 工作内存 │
│ 变量 x 的副本 │ │ 变量 x 的副本 │
└─────────────────┘ └─────────────────┘
线程只能直接操作工作内存中的变量副本
工作内存是 JMM 的抽象概念,对应 CPU 寄存器+缓存+写缓冲区的综合

线程对共享变量的操作分为 8 个原子动作(lock/unlock/read/load/use/assign/store/write),JMM 规定了这 8 个动作的执行规则,确保内存一致性。


happens-before(先行发生)是 JMM 提供的可见性保证机制。如果 A happens-before B,则 A 的所有操作结果对 B 可见,且 A 的执行顺序在 B 之前。

JMM 内置了以下 happens-before 规则(无需额外同步即可保证):

1. 程序顺序规则(Program Order Rule)
同一线程内,前面的操作 happens-before 后面的操作
(单线程内代码的表现顺序)
2. 监视器锁规则(Monitor Lock Rule)
对一个锁的 unlock happens-before 后续对同一个锁的 lock
↳ 保证:加锁后能看到上次解锁前的所有写入
3. volatile 变量规则(Volatile Variable Rule)
对 volatile 变量的写 happens-before 后续对该变量的读
↳ 保证:volatile 写后的读能看到最新值
4. 线程启动规则(Thread Start Rule)
Thread.start() happens-before 被启动线程的任何操作
↳ 保证:主线程在 start() 前写入的数据,子线程能看到
5. 线程终止规则(Thread Termination Rule)
线程的所有操作 happens-before 其他线程检测到该线程终止
(Thread.join() 返回 或 Thread.isAlive() 返回 false)
6. 线程中断规则(Thread Interruption Rule)
interrupt() 调用 happens-before 被中断线程检测到中断
(InterruptedException 或 isInterrupted())
7. 对象终结规则(Finalizer Rule)
对象构造函数结束 happens-before finalize() 方法开始
8. 传递性规则(Transitivity)
如果 A hb B,B hb C,则 A hb C

1. 可见性:对 volatile 变量的写操作会立即刷新到主内存;对 volatile 变量的读操作会从主内存重新加载(跳过 CPU 缓存)。

2. 禁止指令重排:通过插入内存屏障(Memory Barrier)实现:

  • 写 volatile 前:插入 StoreStore 屏障(前面的普通写不能重排到 volatile 写之后)
  • 写 volatile 后:插入 StoreLoad 屏障(volatile 写不能重排到后面的读写之前)
  • 读 volatile 前:插入 LoadLoad 屏障
  • 读 volatile 后:插入 LoadStore 屏障
volatile 写的内存屏障效果(以 JDK HotSpot 实现为例):
普通写 a = 1;
[StoreStore 屏障] ← 确保 a=1 已刷新
volatile 写 v = 2;
[StoreLoad 屏障] ← 确保 v=2 对后续读可见
volatile 读:
volatile 读 x = v;
[LoadLoad 屏障] ← 后续读都能看到最新值
[LoadStore 屏障] ← 后续写依赖最新读
普通读 b = a;
// 错误示例:以为 volatile 能保证线程安全的计数器
volatile int count = 0;
void increment() {
count++; // 这不是原子操作!
// 实际是:读count → 加1 → 写count,三步操作
// 两个线程同时读到相同的值,分别+1,写回相同的值,丢失一次更新
}

volatile 适合的场景:一写多读(只有一个线程写,多个线程读)或写操作不依赖当前值(如状态标志位)。


单例模式中最经典的并发问题:

// 错误!可能返回未初始化完成的对象
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;
}
}

问题根源new Singleton() 在字节码层面分三步:

  1. 分配内存空间
  2. 初始化对象(执行构造方法)
  3. 将引用指向内存地址

JIT/CPU 可能将步骤 2 和 3 重排为:先赋值引用,再初始化对象。

线程A:
1. 分配内存
3. 将 instance 指向内存地址(重排,先于步骤2)
← 线程B 此时检查 instance != null,直接返回
2. 初始化对象(还没执行!)
线程B:
拿到了 instance,但对象还没初始化完成 → 访问字段时可能 NPE 或看到默认值
class Singleton {
private static volatile Singleton instance; // ← 必须 volatile
public static Singleton getInstance() {
if (instance == null) {
synchronized (Singleton.class) {
if (instance == null) {
instance = new Singleton(); // volatile 写禁止步骤3重排到步骤2之前
}
}
}
return instance;
}
}

volatile 如何修复:volatile 写的 StoreStore 屏障确保对象初始化(步骤2)一定发生在引用赋值(步骤3)之前,其他线程通过 volatile 读能看到完整初始化的对象。

更优雅的替代方案:静态内部类

Section titled “更优雅的替代方案:静态内部类”
class Singleton {
private Singleton() {}
private static class Holder {
static final Singleton INSTANCE = new Singleton(); // 类加载时初始化,JVM保证线程安全
}
public static Singleton getInstance() {
return Holder.INSTANCE;
}
}

利用 JVM 类加载机制的线程安全性(类初始化使用 <clinit> 锁,保证只执行一次),既懒加载又线程安全,是推荐做法。


final 字段的写入有特殊的内存保证:

规则:
构造函数中对 final 字段的写入 happens-before
其他线程读取该对象的 final 字段
效果:
只要对象是正确构造的(引用没有在构造过程中逸出),
任何线程都能看到 final 字段的正确初始值,无需同步。
class ImmutablePoint {
final int x;
final int y;
ImmutablePoint(int x, int y) {
this.x = x; // ← JMM 保证:其他线程读到的 x 一定是构造完成后的值
this.y = y;
}
}
// 只要 point 引用安全发布(如 volatile 写或加锁),
// 任何读取 point.x / point.y 的线程都能看到正确值

引用逸出(Escape during Construction)——final 保证的例外:

// 危险!在构造函数中将 this 传递给其他线程
class BadExample {
final int value;
static BadExample instance;
BadExample(int v) {
instance = this; // ← this 在构造完成前就逸出了
this.value = v; // 其他线程可能看到 value = 0(默认值)
}
}

禁止重排的场景(JMM 规定的 as-if-serial 语义):
• 有数据依赖关系的操作(如:写 a → 读 a,不能重排)
• volatile 读/写的前后
允许重排的场景(单线程结果不变):
int a = 1; // ① 无数据依赖
int b = 2; // ② 可以重排为 ②①,单线程结果不变
int c = a + b; // ③ 依赖 ① 和 ②,不能排在 ①② 之前

x86 的特殊性:x86 CPU 的内存模型(TSO,Total Store Order)本身就比较强,只允许 StoreLoad 重排。大多数 Java volatile 的内存屏障在 x86 上只有 StoreLoad(MFENCELOCK ADD)是真正的开销,其他屏障在 x86 上是空操作。这就是为什么在 x86 上 Java 程序的并发问题往往比在 ARM 上更难复现。


Q:volatile 能保证原子性吗?

不能。volatile 只保证可见性(写后对其他线程立即可见)和有序性(禁止相关指令重排)。count++ 这样的复合操作不是原子的,需要使用 AtomicIntegersynchronized

Q:happens-before 和时间先后有什么区别?

时间先后是物理概念,是 Wall Clock 时间上的先后;happens-before 是逻辑概念,是 JMM 对可见性的保证。A 在时间上先于 B 执行,不代表 B 能看到 A 的修改;只有 A happens-before B,才能保证 B 看到 A 的所有写入。

Q:DCL 单例为什么需要 volatile?

new Singleton() 在指令层面是分配内存→初始化→赋值引用三步,JIT/CPU 可能将赋值引用重排到初始化之前。未使用 volatile 时,另一个线程可能看到 instance != null 但对象未初始化完成。volatile 的 StoreStore 屏障禁止了这种重排。

Q:说几个 happens-before 规则?

程序顺序规则(同线程前 hb 后)、监视器锁规则(unlock hb 后续 lock)、volatile 规则(volatile 写 hb 后续读)、线程启动规则(start() hb 子线程操作)、线程终止规则(线程操作 hb join() 返回)、传递性(A hb B,B hb C → A hb C)。

Q:为什么说 JMM 是对 CPU 内存模型的封装?

不同 CPU 架构(x86、ARM、POWER)的内存模型(允许的重排类型)不同,如果 Java 直接暴露 CPU 模型,开发者就需要针对不同硬件写不同的并发代码。JMM 通过规定 happens-before 规则和内存屏障,将这些差异屏蔽,开发者只需基于 JMM 规范编写代码,JVM 负责在不同平台插入适当的内存屏障指令。