JVM基础:Java 内存模型(JMM)全解析
一、引言:为什么需要 Java 内存模型?
在单线程程序中,变量的读取和写入是直观且一致的。但一旦进入多线程环境,情况就复杂得多——一个线程对变量所做的修改,另一个线程可能看不到,也可能看到不一致的中间状态。更令人困惑的是,这种问题即使在没有显式锁的代码中也可能悄然发生。你写下的代码语义清晰、逻辑无误,但却因为“看不见的顺序”而出现诡异的 bug。
这背后正是 Java 内存模型(Java Memory Model, JMM) 所要解决的问题。
并发编程中的三个核心问题
Java 内存模型主要为了解决三个问题:
- 原子性(Atomicity):一个操作是否不可被打断
- 可见性(Visibility):一个线程对变量的修改对其他线程是否可见
- 有序性(Ordering):程序的执行顺序是否和代码顺序一致
JMM 的设计目的,就是在保证这三者之间合理权衡的同时,让 Java 程序在多线程下依然能够跨平台正确运行。
为什么不能只依赖硬件或操作系统?
在现代硬件架构中:
- CPU 有多级缓存,线程可能只读写自己的缓存而非主内存
- 编译器会优化代码执行顺序,比如将某些语句重排序以提高性能
- 不同 CPU 有不同的内存一致性模型(如 x86 vs ARM)
这些差异导致,如果没有统一的规范,那么相同的 Java 代码在不同平台上会表现出不同的并发行为。这对 Java 的“跨平台”哲学是个巨大威胁。
JMM:Java 层的并发“契约”
为了解决上述问题,Java 在语言级别提出了一套规范——Java 内存模型,它并不是 CPU 的物理内存模型,而是:
- 一套 行为规范(specification):描述多线程之间读写共享变量时应遵循的规则
- 一种 抽象语义模型:屏蔽硬件差异,在语义层面保证程序执行的一致性
- 并通过
volatile、synchronized、final等语言关键字来进行控制
JMM 使得开发者可以用统一的方式来思考并发问题,而不需要关心底层平台的指令级差异。
举个实际例子:变量失效问题
class Flag {
static boolean stop = false;
public static void main(String[] args) {
new Thread(() -> {
while (!stop) {
// do something
}
}).start();
try { Thread.sleep(1000); } catch (InterruptedException e) {}
stop = true;
}
}
你期望 1 秒后主线程将 stop 设置为 true,子线程跳出循环。但很可能子线程永远不会结束!
为什么?因为子线程可能只从 CPU 缓存中读取 stop,而根本不知道主线程已经修改了它。这种现象,正是 JMM 中可见性问题的典型代表。
解决方法之一就是将 stop 声明为 volatile,告诉 JVM:这个变量不能缓存,必须直接从主内存读取。
二、JMM 的抽象模型与基本规则
Java 内存模型的核心是一种抽象的、面向线程与主内存交互的模型。理解这个模型,是掌握 JMM 的关键第一步。
主内存(Main Memory)与工作内存(Working Memory)
JMM 将每一个线程的执行环境分为两个部分:
- 主内存(Main Memory):所有线程共享的内存区域,存储所有的实例字段、静态字段等(即共享变量)
- 工作内存(Working Memory):每个线程私有的内存区域,用于保存该线程使用的变量的副本(类似 CPU 的寄存器或高速缓存)
线程对变量的所有操作(读取、写入),必须先从主内存复制到工作内存中再操作,并在操作后刷新回主内存。
注意:局部变量不在 JMM 控制之内,它们存储在栈帧中,不共享,因此不存在并发问题。
工作内存与主内存的交互指令(8个)
Java 内存模型定义了 8 个用于线程与主内存交互的低级指令:
| 指令 | 作用 |
|---|---|
load | 从主内存中读取变量值到工作内存 |
store | 将工作内存中的变量值写回主内存 |
read | 从主内存中复制变量到工作内存中(依赖于 load) |
write | 将变量从工作内存写入主内存(依赖于 store) |
use | 将工作内存中的变量值传递给执行引擎 |
assign | 执行引擎将值赋给变量,存入工作内存 |
lock | 对变量加锁(用于 synchronized) |
unlock | 对变量解锁(释放锁的过程会强制刷新到主内存) |
虽然我们在写代码时看不到这些指令,但 JVM 会在字节码层隐式使用它们。
内存访问规则
JMM 对上述指令有以下规则:
- 一个变量在没有
load之前不能use或assign - 一个变量在
assign之后必须store才能write到主内存 - 同一个变量的操作必须串行进行(不允许并发
load/store) - 每个变量在每个线程中都有独立的副本
示意图:JMM 的线程与主内存模型
下面是一张简化的示意图:
主内存(Main Memory)
-------------------------
| 变量x 变量y |
-------------------------
↑ ↑
| |
↓ ↓
线程 A 的工作内存 线程 B 的工作内存
------------------- -------------------
| 副本x_A 副本y_A | | 副本x_B 副本y_B |
------------------- -------------------
操作顺序:
1. 线程A -> load(x) -> read(x) -> use/assign(x)
2. assign(x) -> store(x) -> write(x) -> 主内存
每个线程从主内存读取变量的副本放入自己的工作内存中,在那里进行修改,然后再同步回主内存。如果不刷新,其他线程是看不到这个修改的,这就是可见性问题的来源。
小结
- 线程对共享变量的修改,默认不会立刻对其他线程可见
- 想要让别的线程看到你的修改,必须显式同步(如 volatile、锁)
- JMM 的模型可以看作是:线程=操作缓存;主内存=真实世界
三、Happens-Before 原则
为了解决这些看不见的问题,Java 内存模型引入了一个关键的“可见性”规范 —— happens-before(先行发生)关系。
什么是 happens-before?
如果操作 A happens-before 操作 B,那么:
- 操作 A 的结果对于操作 B 是可见的
- 操作 A 的执行顺序在操作 B 之前
注意:happens-before 并不是“物理时钟上的先后”,而是一种程序行为的逻辑顺序保证
JMM 中的 happens-before 规则(重点)
- 程序顺序规则:一个线程内,代码的执行顺序就是 happens-before 顺序。
- 锁规则:一个线程对锁的 unlock 操作,happens-before 之后另一个线程对同一锁的 lock 操作。
- volatile 变量规则:对一个 volatile 变量的写操作,happens-before 于后续对该变量的读操作。
- 线程启动规则:线程 A 启动线程 B(即
Thread.start()),则 A 中的 start happens-before 于 B 线程的任意操作。 - 线程终止规则:线程中的所有操作 happens-before 于其他线程检测到它已经终止(比如
Thread.join()返回)。 - 对象构造规则:构造函数中的所有操作在对象被另一个线程看到之前都必须完成(除非 this 泄露)。
这些规则共同构成了 Java 并发程序在内存层面的行为保障。
volatile 如何保证可见性?
JVM 对 volatile 做了两件事:
- 禁止重排序:对于
volatile写操作,编译器和处理器不允许把它与前后的读写操作重排序; - 强制刷新主内存:写入
volatile的变量会立刻写入主内存,而读操作会从主内存中重新读取。
这就避免了工作内存缓存变量所造成的可见性失效问题。
volatile 是否保证原子性?
不保证。
例如:
volatile int count = 0;
count++;
虽然 count 是 volatile,但 ++ 不是原子操作。它包含了读、加一、写三个步骤,仍可能在多线程环境下发生竞态条件。
想保证原子性,需使用锁(如 synchronized)或原子类(如 AtomicInteger)。
四、有序性与指令重排序
为什么有序性重要?
在单线程程序中,我们天然相信代码是从上到下、顺序执行的。
但在并发程序中,CPU、编译器、JVM 为了优化性能,可能会调整代码的执行顺序。这并不是 bug,而是设计使然。但它会破坏我们对代码行为的直觉认知,从而引发并发错误。
例如,在没有同步保障的情况下,以下代码的执行顺序可能被改变:
int a = 1;
int b = 2;
int c = a + b;
可能会被重排序成:
int b = 2;
int a = 1;
int c = a + b;
在单线程下这不会出问题,但在多线程下就可能造成严重后果。
以下示例可以复现这种现象
public class InstructionReorder {
public static void main(String[] args) {
for (int i = 0; i < 1000000; i++) {
final ReorderMe reorderMe = new ReorderMe();
new Thread(() -> {
reorderMe.a = 1;
reorderMe.b = 2;
reorderMe.c = reorderMe.a + reorderMe.b;
}).start();
new Thread(() -> {
int tmpa = reorderMe.a, tmpb = reorderMe.b, tmpc = reorderMe.c;
if(tmpb == 2 && tmpa == 0) {
System.out.println("Instruction Reordering: a = " + tmpa + ", b = " + tmpb + ", c = " + tmpc);
}
if(tmpc > 0 && tmpa == 0) {
System.out.println("Instruction Reordering: a = " + tmpa + ", b = " + tmpb + ", c = " + tmpc);
}
if(tmpc > 0 && tmpb == 0) {
System.out.println("Instruction Reordering: a = " + tmpa + ", b = " + tmpb + ", c = " + tmpc);
}
}).start();
}
}
static class ReorderMe {
int a = 0, b = 0, c = 0;
}
}
输出: Instruction Reordering: a = 1, b = 0, c = 3
什么是指令重排序?
JMM 下的指令重排序分为三个层次:
- 编译器重排序:Java 编译器在生成字节码时,为了优化性能,可能会重排语句顺序;
- 指令级重排序:JVM 指令到机器指令的转换过程中,JIT 编译器可能改变执行顺序;
- CPU 重排序:处理器出于流水线和乱序执行等目的,也会对指令顺序进行动态调整。
注意:这些重排序都不影响单线程语义,但会对多线程造成影响。
JMM 如何限制重排序?
Java 内存模型通过 happens-before 关系和特定的关键字(如 volatile、synchronized)来约束重排序的范围,从而维持程序在多线程中的正确性。
以下操作具有内存屏障效果,用来限制或禁止重排序:
synchronized
synchronized 的进入和退出,JVM 会插入MonitorEnter 和 MonitorExit指令。这些操作会触发内存屏障:
- 锁的释放(unlock)前:线程必须将工作内存中对共享变量的修改刷新到主内存。
- 锁的获取(lock)后:线程必须从主内存中重新读取共享变量的值。
因此,synchronized 能同时保证原子性、可见性、有序性。
volatile
JVM 在访问 volatile 变量时会插入特定的读写屏障:
- volatile 写入之前:会插入
storestore屏障,禁止与之前的写重排序; - volatile 写入之后:插入
storeload屏障,禁止之后的读写指令提前执行; - volatile 读取之前:插入
loadload屏障; - volatile 读取之后:插入
loadstore屏障;
这些屏障使得 volatile 变量的写对其他线程“立刻可见”,并阻止读写操作的重排序。
指令重排序带来的并发错误示例
以下代码存在严重的重排序风险:
class Singleton {
private static Singleton instance;
public static Singleton getInstance() {
if (instance == null) { // ①
synchronized (Singleton.class) {
if (instance == null) { // ②
instance = new Singleton(); // ③
}
}
}
return instance;
}
}
上述“DCL”(双重检查锁)写法是错误的,因为 new Singleton() 实际上分为三步:
- 分配内存;
- 调用构造函数初始化;
- 将引用赋值给
instance;
如果发生指令重排序,使得 3 在 2 前执行,另一个线程可能拿到一个“未初始化完成”的对象。
解决方案:
private static volatile Singleton instance;
加入 volatile 后,禁止了对象初始化的指令重排序,从而保证了线程安全。
五、原子性与 Java 中的原子操作
什么是原子性?
原子性(Atomicity)是指一个操作不可分割,即使在多线程环境下,也不会被中断或看到“中间状态”。
一个操作如果具有原子性,那么对其他线程来说,要么操作已经完成,要么尚未开始,绝不会看到“操作一半”的状态。
Java 中的非原子操作示例
最经典的非原子性示例是 i++ 操作,看似一个语句,实际包含多个步骤:
i++; // 实际包含:
1. 读取 i 的值到工作内存
2. 执行 +1
3. 写回 i 到主内存
在多线程下,若两个线程同时执行 i++,可能造成如下情况:
- 线程 A 读到 i = 1;
- 线程 B 也读到 i = 1;
- 两者都 +1,写回结果为 2;
- 最终结果为 2,而不是期望的 3。
这种行为就违反了原子性。
如何在 Java 中保证原子性?
1. 使用 synchronized
synchronized 是最基本的原子性保障手段,通过对象锁或类锁,在进入临界区前保证互斥访问,避免线程之间交叉执行。
public synchronized void increment() {
i++;
}
或
public void increment() {
synchronized (this) {
i++;
}
}
synchronized 同时保障原子性、可见性和有序性,但性能可能较低。
2. 使用 ReentrantLock
java.util.concurrent.locks.ReentrantLock 是比 synchronized 更灵活的可重入锁,也能保证原子性。
Lock lock = new ReentrantLock();
lock.lock();
try {
i++;
} finally {
lock.unlock();
}
它支持尝试锁、定时锁、公平锁等扩展能力。
3. 使用 volatile?不能保证原子性!
一个常见误区是使用 volatile 变量来避免竞态。但 volatile 仅能保证可见性和禁止重排序,不能保证原子性。
例如以下代码并不线程安全:
private volatile int count = 0;
public void increment() {
count++; // 非原子操作
}
即便每个线程都能“看见”最新的值,但它们还是可能在读取到旧值的基础上计算,从而丢失更新。
4. 使用原子类(Atomic*)
JDK 提供了一组基于 CAS(Compare And Swap) 的原子类,可以无锁地保证单个变量的原子更新。
如:
AtomicInteger counter = new AtomicInteger(0);
counter.incrementAndGet(); // 原子性 +1
常用原子类包括:
AtomicInteger/AtomicLong/AtomicBooleanAtomicReferenceAtomicStampedReference(解决 ABA 问题)AtomicIntegerArray/AtomicLongArray等
底层依赖于 JVM 的 Unsafe.compareAndSwapInt() 等方法实现。
CAS 的底层原理
CAS 是一条原子性 CPU 指令,流程为:
如果当前值 == 预期值:
则更新为新值
否则:
什么也不做
这类指令由硬件提供,可以无需加锁就实现并发更新,性能更优。
CAS 三大问题:
- ABA 问题:值 A → B → A,CAS 无法感知变化;
- 自旋开销:CAS 会自旋重试,可能耗 CPU;
- 只能保障单变量:多个变量的一致更新无法用 CAS 保证,需要
AtomicReferenceFieldUpdater等更复杂手段。
总结
| 手段 | 可见性 | 原子性 | 有序性 | 适用场景 |
|---|---|---|---|---|
volatile | ✅ | ❌ | ✅ | 状态标志、单变量只读写 |
synchronized | ✅ | ✅ | ✅ | 互斥操作,临界区控制 |
ReentrantLock | ✅ | ✅ | ✅ | 复杂并发控制(如公平锁) |
AtomicInteger | ✅ | ✅ | ❌ | 单变量频繁更新,高性能场景 |
六、可见性与内存屏障机制
什么是可见性?
可见性(Visibility)是指一个线程对共享变量的修改,能被其他线程立即看到。
在多线程环境中,Java 会为每个线程分配一块 工作内存(线程本地缓存)。当线程读取或写入变量时,可能只作用于自己的工作内存,不会立刻刷新到主内存。
因此,某线程对变量的更改可能对其他线程不可见,就会出现“读到旧值”的问题。
可见性的实现机制:内存屏障(Memory Barrier)
Java 的可见性保障离不开底层 内存屏障(memory barrier)/内存栅栏 的支持。
当你使用:
volatilesynchronizedfinal(构造完成前的只读语义)
JVM 会在合适的位置插入内存屏障,以限制指令重排序并强制刷新主内存。
常见屏障类型:
StoreStore Barrier:确保前面的写操作先于后面的写操作;LoadLoad Barrier:确保前面的读操作先于后面的读操作;LoadStore Barrier:确保前面的读操作先于后面的写操作;StoreLoad Barrier:最强屏障,确保前面的写操作对其他线程可见,必须在其后的读操作之前完成。
volatile 的内存语义(JMM 语义)
对 volatile 变量的读写,在字节码中表现为 volatile 指令(如 getfield volatile 和 putfield volatile),JVM 在这些操作前后会插入内存屏障:
写操作时:
putfield volatile -> StoreStore + StoreLoad
- 写入前,先将前面的所有写刷新;
- 写完后,禁止之后的读写操作重排序。
读操作时:
getfield volatile -> LoadLoad + LoadStore
- 读之前,确保之后的读不会重排序;
- 读之后,禁止后续的写操作提前。
这样确保了写入操作对其他线程可见,也禁止了某些重排序,保证线程间的数据一致性。
使用 synchronized 是否也保证可见性?
是的。
synchronized 的进入和退出操作分别对应:
- 进入临界区:读取主内存中的变量值到工作内存中;
- 退出临界区:强制将工作内存中的修改刷新到主内存中。
因此它天然具有可见性保障。
小结
| 可见性保障手段 | 是否保证可见性 | 是否保证原子性 | 是否禁止重排序 |
|---|---|---|---|
volatile | ✅ | ❌ | ✅(部分) |
synchronized | ✅ | ✅ | ✅ |
| 原子类(CAS) | ✅(依赖内存屏障) | ✅ | ✅(部分) |
可见性是并发编程中最容易被忽视的问题,很多程序在单线程或低并发环境中运行良好,一旦在多核高并发环境中部署,就会出现“莫名其妙”的读错数据的问题,根本原因就是可见性丧失。