Java 内存模型
1. 为什么需要 Java 内存模型 (JMM)
硬件内存架构的演进与痛点
在现代计算机中,CPU 的指令执行速度远远超过了主内存(RAM)的读写速度,为了消除这种由于速度不对等造成的性能瓶颈,硬件工程师在 CPU 与主内存之间引入了高速缓存(L1、L2、L3 Cache)。
在多核 CPU 架构下,每个 CPU 核心都有自己私有的 L1/L2 缓存,这就带来了并发编程中的第一个核心痛点:缓存一致性问题(Cache Coherence)。
场景
- 假设两个线程分别在两个 CPU 核心上运行,它们同时将主内存中的变量
count = 0读入各自的本地缓存。 - 核心 A 将
count改为1,但尚未刷回主内存。 - 此时核心 B 再次读取
count,拿到的依然是自己缓存里的过期数据0,这就是“可见性”问题的物理根源。
性能优化的副产物:指令重排序
为了极致压榨 CPU 的性能,除了引入缓存,编译器和处理器还会对我们编写的代码进行指令重排序(Instruction Reordering)。
- 编译器优化的重排序:在不改变单线程程序语义的前提下,重新安排语句的执行顺序。
- 指令级并行的重排序:现代处理器采用了指令级并行技术(ILP),如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。
- 内存系统的重排序:由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去可能是在乱序执行。
重排序在单线程下遵循 as-if-serial 语义(怎么重排都不能改变执行结果),但在多线程环境下,乱序执行可能会导致灾难性的后果。
JMM 的诞生意义
不同的硬件平台(如 x86、ARM)和操作系统,对缓存一致性协议(如 MESI)和内存屏障的实现方式各不相同。如果 Java 程序员直接和这些底层打交道,那“一次编写,到处运行”就成了一句空话。
因此,JMM 诞生了,它是一套由 Java 虚拟机规范定义的规则和协议,旨在屏蔽各类底层硬件和操作系统的内存访问差异,为 Java 开发者提供一套统一的、可预测的并发内存访问模型。
2. JMM
JMM 并没有真实的物理内存结构,它是一种逻辑上的抽象模型。

JMM 规定了程序中所有共享变量(实例变量、静态字段和数组元素)的访问规则,并将内存划分为两个层级:
- 主内存(Main Memory):所有线程共享的内存区域,包含了所有的共享变量。它在物理上主要对应计算机的主存(RAM)。
- 工作内存(Working Memory / Local Memory):每个线程私有的逻辑区域。它保存了该线程读写共享变量的主内存副本。注意:工作内存是一个纯粹的抽象概念,它涵盖了缓存,写缓冲区,寄存器以及其他的硬件和编译器优化。
基本规则: 线程对共享变量的所有操作都必须在自己的本地内存中进行,不能直接从主内存中读取。线程间通信必须经过主内存。
为了实现主内存与工作内存之间的数据同步,JMM 定义了 8 种原子操作。

lock(锁定):作用于主内存,把变量标识为线程独占。unlock(解锁):作用于主内存,释放锁定状态。read(读取):作用于主内存,把变量值传输到工作内存。load(载入):作用于工作内存,把read到的值放入工作内存的副本中。use(使用):作用于工作内存,把变量值传递给执行引擎。assign(赋值):作用于工作内存,把执行引擎收到的值赋给工作内存变量。store(存储):作用于工作内存,把变量值传送到主内存。write(写入):作用于主内存,把store到的值放入主内存变量中。
3. 代码实战剖析:并发编程的三大特性
并发编程 Bug 的源头,都可以归结为原子性、可见性和有序性问题。JMM 的所有规则都是为了保障这三大特性。我们通过实战代码来逐一剖析:
3.1 原子性 (Atomicity)
一个或多个操作,要么全部执行且不被中断,要么全都不执行。
场景再现(i++ 的陷阱):
import java.util.concurrent.TimeUnit;
public class AtomicTest {
private static int counter = 0;
public static void main(String[] args) throws InterruptedException {
for (int i = 0; i < 10; i++) {
new Thread(() -> {
for (int j = 0; j < 10000; j++) {
// 并非原子操作
counter++;
}
}).start();
}
TimeUnit.SECONDS.sleep(3);
// 结果大概率小于 100000
System.out.println(counter);
}
}counter++ 在底层包含了读、改、写三个步骤。多线程下不采取保障措施,必然导致数据被相互覆盖。
保障方案:synchronized、Lock、或 CAS(如 AtomicInteger)。
3.2 可见性 (Visibility)
定义:一个线程修改了变量的值,其他线程能够立即看得到修改的值。
场景再现(死循环的秘密):
public class VisibilityTest {
private boolean flag = true;
public static void main(String[] args) throws Exception {
VisibilityTest test = new VisibilityTest();
new Thread(test::load).start();
Thread.sleep(1000);
new Thread(test::refresh).start();
}
public void load() {
System.out.println("数据加载开始...");
while (flag) {
// 业务逻辑加载数据...
}
System.out.println("数据加载完成!");
}
public void refresh() {
flag = false;
System.out.println("修改 flag 为 false");
}
}运行后你会发现,即使 refresh 线程修改了 flag = false,load 线程依然在死循环。这就是因为 load 线程一直在读取自己工作内存中的缓存,而没有感知到主内存的变化。
JMM 内存语义的拯救:
- volatile 内存语义:写
volatile变量时,JMM 会把该线程本地内存的共享变量刷新到主内存;读volatile变量时,JMM 会把该线程本地内存置为无效,强制从主内存读取。 - 锁的内存语义:释放锁时,会将共享变量刷新到主内存;获取锁时,会将本地内存置为无效。
3.3 有序性 (Ordering)
定义:程序执行的顺序按照代码的先后顺序执行。
场景再现(诡异的重排序):
public class ReOrderTest {
private static int x = 0, y = 0, a = 0, b = 0;
public static void main(String[] args) throws Exception {
while (true) {
x = 0;
y = 0;
a = 0;
b = 0;
Thread t1 = new Thread(() -> {
a = 1;
x = b;
});
Thread t2 = new Thread(() -> {
b = 1;
y = a;
});
t1.start();
t2.start();
t1.join();
t2.join();
if (x == 0 && y == 0) {
System.out.println("发生了重排序!x=0, y=0");
break;
}
}
}
}正常逻辑推导,无论两个线程怎么交替执行,x 和 y 都不可能同时为 0。但实际运行一段时间后,在大部分现代多核处理器和 JVM 上会打印 "发生了重排序!x=0, y=0",这意味着 CPU 对 a=1; x=b; 进行了重排(变成了先执行 x=b,再执行 a=1)。
4. happens-before 关系
JSR-133 使用 happens-before 的概念来指定两个操作之间的执行顺序。由于这两个操作可以在一个线程之内,也可以在不同的线程之内。因此,JMM 可以通过 happens-before 关系向程序员提供跨线程的内存可见性保证。

JSR-133 规范对 happens-before 关系的定义如下:
- 对程序员的承诺:如果一个操作 happens-before 另一个操作,那么第一个操作的执行结果将对第二个操作可见,而且第一个操作的执行顺序排在第二个操作之前。(注意,这只是 JMM 向程序员做出的保证)
- 对编译器和处理器的约束:两个操作之间存在 happens-before 关系,并不意味着 Java 平台的具体实现必须要按照 happens-before 关系指定的顺序来执行。如果重排序之后的执行结果,与按 happens-before 关系来执行的结果一致,那么这种排序并不非法,也就是说,JMM 允许这种排序。
JMM 遵循一个基本原则:只要不改变程序的执行结果,编译器和处理器怎么优化都行。
as-if-serial语义保证单线程内程序的执行结果不被改变。happens-before关系保证正确同步的多线程程序的执行结果不被改变。
这么做的目的是为了在不改变程序执行结果的前提下,尽可能地提高程序执行的并行度。
happens-before 规则
JSR-133 规范定义了如下 happens-before 规则:
- 程序顺序规则:一个线程中的每个操作,happens-before 于该线程中的任意后续操作;
- 锁定规则:对一个锁的解锁,happens-before 于随后对这个锁的加锁;
- volatile 变量规则:对一个 volatile 变量的写操作,happens-before 于任意后续对这个 volatile 变量的读操作;
- 传递规则:如果 A happens-before B,并且 B happens-before C,则 A happens-before C;
- 线程启动规则:如果线程 A 调用线程 B 的
start()方法来启动线程 B,则start()操作 happens-before 于线程 B 中的任意操作; - 线程中断规则:对线程
interrupt()方法的调用 happens-before 于被中断线程的代码检测到中断事件的发生; - 线程终结规则:如果线程 A 执行操作
ThreadB.join()并成功返回,那么线程 B 中的任意操作 happens-before 于线程 A 从ThreadB.join()操作成功返回; - 对象终结规则:一个对象的初始化完成 happens-before 于它的
finalize()方法的开始。
5. 经典实战:从 DCL 单例看 volatile 的妙用
前面提到,happens-before 规则的存在,就是为了让程序员免于陷入到底层指令排查的泥潭。最后,我们结合最经典的双重检查锁定(DCL)单例模式,用刚刚学到的视角,来看看 volatile 是如何工作的。
public class Singleton {
// 关键:必须使用 volatile 修饰
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;
}
}instance = new Singleton(); 这一行代码在编译后,主要包含三步机器指令:
- 分配对象内存空间
- 在内存空间上初始化对象
- 将 instance 引用指向这块内存
在没有 volatile 的情况下,CPU 为了优化性能,可能会将其重排序为 1 -> 3 -> 2。此时如果另一个线程执行到最外层判断,发现 instance != null 直接拿走使用,就会由于对象尚未初始化而抛出 NullPointerException。
加入了 volatile 后发生了什么?
在 JMM 中,为了实现 volatile 变量的语义(即禁止重排和保证可见性),编译器会在生成字节码时,在 volatile 的读写前后插入内存屏障(Memory Barrier)。你可以将内存屏障理解为底层硬件的一种“路障”指令。
当加上 volatile 修饰后,JMM 会在给 instance 赋值(第3步)的前后安插内存屏障,这道屏障明确告知 CPU:禁止将步骤2(初始化对象)重排到步骤3(赋值)之后执行! 同时也强制将写入后的新值立刻刷新回主内存。
通过这道底层屏障,volatile 完美堵住了 DCL 模式的最后一块漏洞。
6. 总结
JMM 本质上是 Java 在底层硬件性能(高速缓存、指令乱序)与上层编程门槛之间,为我们建立的一套标准化规范。
它屏蔽了不同操作系统的底层差异,并通过简单易懂的 Happens-Before 规则,将复杂的可见性与有序性问题,转换为了我们熟悉的 volatile、synchronized 等关键字。理解了 JMM,我们就拥有了排查并发疑难杂症的理论基石。