跳到主要内容

Java 内存模型(JMM)

Java 内存模型(Java Memory Model,简称 JMM)是理解并发编程的核心基础。它定义了多线程环境下线程如何通过内存进行交互,以及编译器和处理器如何优化代码。不理解 JMM,就无法真正理解 volatile、synchronized、final 等关键字的工作原理,也无法编写正确的并发程序。

什么是内存模型?

在单线程程序中,代码的执行顺序就是我们编写的顺序——先执行第一行,再执行第二行,这是理所当然的。但在多线程环境下,情况变得复杂:一个线程对变量的修改,另一个线程可能看不到;代码的实际执行顺序可能与编写顺序不同。

这些问题的根源在于现代计算机的架构和优化策略。Java 内存模型就是为了解决这些问题而制定的规范,它定义了:

  • 可见性:一个线程对共享变量的修改何时对其他线程可见
  • 有序性:指令执行的顺序如何保证
  • 原子性:哪些操作是不可分割的

为什么需要内存模型?

在没有统一内存模型的情况下,不同的编译器和处理器可能对同一段并发代码产生不同的执行结果,这会导致程序的行为不可预测,也无法移植。

JMM 的核心目标是:在保证程序正确性的前提下,尽可能允许编译器和处理器进行优化

它通过定义一系列规则(happens-before),在"严格顺序执行"和"完全自由重排"之间找到平衡点。

内存模型抽象结构

从抽象角度看,JMM 定义了线程与主内存之间的关系:

主内存:所有线程共享的内存区域,存储所有共享变量的实际值。

工作内存:每个线程独有的内存区域,存储该线程使用到的变量的副本。线程对变量的所有操作都必须在工作内存中进行,不能直接读写主内存。

这种抽象模型解释了为什么会出现可见性问题:线程 A 修改了工作内存中的副本,但没有及时写回主内存,线程 B 从主内存读取的还是旧值。

实际硬件模型

JMM 是一个抽象规范,实际运行时映射到硬件架构:

  • 主内存 对应物理内存(RAM)
  • 工作内存 对应 CPU 缓存(L1、L2、L3 Cache)和寄存器
  • 线程间通信需要跨越缓存一致性协议

现代 CPU 为了提高性能,每个核心都有独立的多级缓存。当一个核心修改数据后,需要通过缓存一致性协议(如 MESI)同步到其他核心,这个过程不是即时的。

三大特性

JMM 围绕三个核心特性展开:原子性、可见性、有序性。

原子性

原子性是指一个操作是不可中断的,要么全部执行成功,要么全部不执行。

哪些操作是原子的?

Java 中,以下操作具有原子性:

  • 基本类型(除了 longdouble)的读写操作
  • 所有 volatile 变量的读写操作
  • 引用类型的赋值操作

注意longdouble 在 32 位 JVM 上不是原子的,因为它们是 64 位,需要两次 32 位操作。但在 64 位 JVM 上,通常会保证其原子性。

哪些操作不是原子的?

int count = 0;
count++; // 不是原子操作!

count++ 看起来是一行代码,实际包含三步:

  1. 读取 count 的值
  2. 将值加 1
  3. 将新值写回 count

在多线程环境下,这三步可能被其他线程打断,导致数据不一致。

可见性

可见性是指一个线程对共享变量的修改,能够被其他线程及时看到。

可见性问题示例

public class VisibilityDemo {
static boolean running = true;

public static void main(String[] args) throws InterruptedException {
Thread worker = new Thread(() -> {
while (running) {
// 空循环,可能会一直运行
}
System.out.println("工作线程结束");
});

worker.start();
Thread.sleep(1000);
running = false; // 主线程修改标志位
System.out.println("主线程设置 running = false");

worker.join();
}
}

这段程序可能永远不会结束!工作线程可能看不到主线程对 running 的修改,因为它可能一直使用工作内存中的缓存值。

解决可见性问题

// 方式1:使用 volatile
static volatile boolean running = true;

// 方式2:使用 synchronized
synchronized (lock) {
running = false;
}

// 方式3:使用 AtomicBoolean
static AtomicBoolean running = new AtomicBoolean(true);

有序性

有序性是指程序执行的顺序按照代码的先后顺序执行。

但在实际执行中,编译器和处理器为了优化性能,可能会对指令进行重排序。这种重排序在单线程下不会影响结果,但在多线程下可能导致问题。

指令重排序示例

public class ReorderDemo {
static int a = 0;
static int b = 0;
static int x = 0;
static int y = 0;

public static void main(String[] args) throws InterruptedException {
for (int i = 0; i < 100000; i++) {
a = 0; b = 0; x = 0; y = 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();

// 正常顺序下,x=0 或 y=0 至少有一个成立
// 但由于重排序,可能出现 x=0 且 y=0
if (x == 0 && y == 0) {
System.out.println("发生重排序: x=" + x + ", y=" + y);
}
}
}
}

正常情况下,如果按照代码顺序执行,不可能出现 x=0y=0 的情况。但由于指令重排序,线程 1 可能先执行 x = b 再执行 a = 1,线程 2 可能先执行 y = a 再执行 b = 1,导致两个变量都读到了 0。

Happens-Before 原则

Happens-before 是 JMM 的核心概念。如果操作 A happens-before 操作 B,那么 A 的结果对 B 可见,且 A 的执行顺序在 B 之前。

happens-before 关系具有传递性:如果 A happens-before B,B happens-before C,那么 A happens-before C。

完整的 Happens-Before 规则

JMM 定义了以下 happens-before 规则:

1. 程序顺序规则(Program Order Rule)

一个线程内,按照代码顺序,书写在前面的操作 happens-before 书写在后面的操作。

int a = 1;  // 操作 A
int b = 2; // 操作 B
// A happens-before B

这条规则保证了单线程内程序的执行顺序符合预期。

2. 监视器锁规则(Monitor Lock Rule)

一个 unlock 操作 happens-before 后续对同一个锁的 lock 操作。

synchronized (lock) {
// 线程 A 修改共享变量
count = 100;
} // unlock happens-before 下一个 lock

synchronized (lock) {
// 线程 B 能够看到 count = 100
System.out.println(count);
}

这就是为什么 synchronized 既能保证原子性,也能保证可见性。

3. Volatile 变量规则(Volatile Variable Rule)

对一个 volatile 变量的写操作 happens-before 后续对这个 volatile 变量的读操作。

volatile boolean ready = false;
int value = 0;

// 线程 A
value = 42;
ready = true; // 写 volatile

// 线程 B
while (!ready) {} // 读 volatile
System.out.println(value); // 一定输出 42

由于 ready = true happens-before !ready 的读取,根据传递性,value = 42 也 happens-before 读取 value。

4. 线程启动规则(Thread Start Rule)

Thread 对象的 start() 方法 happens-before 该线程的每一个动作。

int value = 0;

Thread t = new Thread(() -> {
// 一定能看到 value = 42
System.out.println(value);
});

value = 42;
t.start(); // start() happens-before 线程内的操作

5. 线程终止规则(Thread Termination Rule)

线程中的所有操作都 happens-before 其他线程从该线程的 join() 方法成功返回。

Thread t = new Thread(() -> {
value = 100;
});
t.start();
t.join(); // join 返回后,value = 100 一定可见
System.out.println(value); // 一定输出 100

6. 线程中断规则(Thread Interruption Rule)

对线程 interrupt() 方法的调用 happens-before 被中断线程的代码检测到中断事件。

Thread t = new Thread(() -> {
while (!Thread.interrupted()) {
// 工作中
}
// 检测到中断
});
t.start();
t.interrupt(); // interrupt() happens-before 检测到中断

7. 对象终结规则(Finalizer Rule)

一个对象的初始化完成(构造函数执行结束)happens-before 它的 finalize() 方法的开始。

8. 传递性(Transitivity)

如果 A happens-before B,且 B happens-before C,那么 A happens-before C。

使用 Happens-Before 解决问题

回顾之前的可见性问题:

public class VisibilityFixed {
static volatile boolean running = true;

public static void main(String[] args) throws InterruptedException {
Thread worker = new Thread(() -> {
while (running) {
// 循环
}
System.out.println("工作线程结束");
});

worker.start();
Thread.sleep(1000);
running = false; // volatile 写
}
}

根据 volatile 规则,主线程的 running = false happens-before 工作线程读取 running,因此工作线程一定能看到修改后的值。

Volatile 关键字详解

volatile 是 Java 提供的轻量级同步机制,它比 synchronized 更轻量,但功能也受限。

Volatile 的语义

当一个变量被声明为 volatile,JMM 会确保:

  1. 可见性:对 volatile 变量的写操作会立即刷新到主内存,读操作会从主内存读取最新值
  2. 有序性:禁止指令重排序优化
  3. 不保证原子性:volatile 变量的复合操作仍需额外同步

Volatile 的内存语义

写语义:当写一个 volatile 变量时,JMM 会把该线程对应的本地内存中的共享变量值刷新到主内存。

读语义:当读一个 volatile 变量时,JMM 会把该线程对应的本地内存置为无效,从主内存读取共享变量。

Volatile 的使用场景

场景一:状态标志位

public class ShutdownFlag {
private volatile boolean shutdown = false;

public void shutdown() {
shutdown = true;
}

public void doWork() {
while (!shutdown) {
// 执行任务
}
}
}

这是 volatile 最经典的用法。状态标志位只需要保证可见性,不需要原子性。

场景二:单例模式的双重检查

public class Singleton {
private static volatile Singleton instance;

public static Singleton getInstance() {
if (instance == null) { // 第一次检查
synchronized (Singleton.class) {
if (instance == null) { // 第二次检查
instance = new Singleton();
}
}
}
return instance;
}
}

为什么需要 volatile?new Singleton() 不是原子操作,包含三步:

  1. 分配内存空间
  2. 初始化对象
  3. 将引用指向内存

如果不加 volatile,步骤 2 和 3 可能重排序(1-3-2),导致其他线程看到一个未初始化的对象。

场景三:一次性安全发布

public class OneTimeSafePublication {
private volatile Resource resource;

public Resource getResource() {
if (resource == null) {
synchronized (this) {
if (resource == null) {
resource = new Resource();
}
}
}
return resource;
}
}

Volatile 不适用的场景

volatile 不适用于需要原子性的复合操作:

public class VolatileNotAtomic {
private volatile int count = 0;

public void increment() {
count++; // 不是原子操作!即使 count 是 volatile
}
}

count++ 包含读-改-写三步,volatile 只保证每一步的可见性,不保证整个操作的原子性。

正确做法:

public class AtomicCounter {
private AtomicInteger count = new AtomicInteger(0);

public void increment() {
count.incrementAndGet(); // 原子操作
}
}

内存屏障

内存屏障(Memory Barrier)是 JMM 实现可见性和有序性的底层机制。JVM 在生成字节码时,会在适当位置插入内存屏障来禁止特定类型的重排序。

四种内存屏障

屏障类型说明
LoadLoad确保Load1的数据先于Load2及后续Load操作
StoreStore确保Store1数据先于Store2及后续Store操作对其他处理器可见
LoadStore确保Load1数据先于Store2及后续Store操作
StoreLoad确保Store1数据先于Load2及后续Load操作,开销最大

Volatile 的内存屏障插入策略

JMM 对 volatile 变量的内存屏障插入策略:

  • 在每个 volatile 写操作前插入 StoreStore 屏障
  • 在每个 volatile 写操作后插入 StoreLoad 屏障
  • 在每个 volatile 读操作后插入 LoadLoad 屏障
  • 在每个 volatile 读操作后插入 LoadStore 屏障
volatile 写:
StoreStore 屏障
volatile 写
StoreLoad 屏障

volatile 读:
volatile 读
LoadLoad 屏障
LoadStore 屏障

Final 字段的内存屏障

final 字段也有特殊的内存语义。在构造函数中写入 final 字段后,构造函数返回前会插入一个 StoreStore 屏障,确保 final 字段的写入在对象引用对其他线程可见之前完成。

public class FinalFieldExample {
final int x;
int y;

public FinalFieldExample() {
x = 3; // final 字段写入
y = 4; // 普通字段写入
// 这里插入 StoreStore 屏障
}
}

这保证了只要对象是正确构造的(没有 this 引用逃逸),其他线程就能看到 final 字段的正确值。

安全发布

安全发布是指确保对象在发布(使其对其他线程可见)时,其状态是完全构造的。

不安全的发布

public class UnsafePublication {
public Holder holder;

public void initialize() {
holder = new Holder(42); // 不安全发布!
}
}

class Holder {
private int n;

public Holder(int n) {
this.n = n;
}

public void assertSanity() {
if (n != n) { // 可能抛出异常!
throw new AssertionError("This statement is false.");
}
}
}

由于指令重排序,其他线程可能看到部分构造的 Holder 对象,导致 n != n 这种奇怪的情况。

安全发布的方式

方式一:在静态初始化函数中初始化

public class EagerInitialization {
public static final Holder holder = new Holder(42);
}

静态初始化由 JVM 在类加载时执行,JVM 内部有同步机制保证安全性。

方式二:将引用保存到 volatile 字段或 AtomicReference

public class SafePublication {
public volatile Holder holder;

public void initialize() {
holder = new Holder(42);
}
}

方式三:将引用保存到正确构造对象的 final 字段

public class SafePublication {
public final Holder holder;

public SafePublication() {
holder = new Holder(42);
}
}

方式四:将引用保存到由锁保护的字段

public class SafePublication {
private final Object lock = new Object();
private Holder holder;

public void initialize() {
synchronized (lock) {
holder = new Holder(42);
}
}

public Holder getHolder() {
synchronized (lock) {
return holder;
}
}
}

实战案例

延迟初始化

public class LazyInit {
private volatile ExpensiveObject instance;

public ExpensiveObject getInstance() {
ExpensiveObject result = instance;
if (result == null) {
synchronized (this) {
result = instance;
if (result == null) {
instance = result = new ExpensiveObject();
}
}
}
return result;
}
}

class ExpensiveObject {
// 耗费资源的对象
}

这里使用局部变量 result 是为了减少对 volatile 变量的访问次数,提高性能。

状态机模式

public class StateMachine {
private volatile int state;
private static final int IDLE = 0;
private static final int RUNNING = 1;
private static final int STOPPED = 2;

public void start() {
state = RUNNING;
}

public void stop() {
state = STOPPED;
}

public int getState() {
return state;
}
}

状态转换只需要可见性保证,volatile 完全满足需求。

小结

Java 内存模型是并发编程的理论基础:

  1. 内存模型概念:定义了线程与主内存的交互方式
  2. 三大特性:原子性、可见性、有序性
  3. Happens-Before 规则:确定操作之间的可见性和顺序关系
  4. Volatile 关键字:轻量级同步机制,保证可见性和有序性
  5. 内存屏障:JMM 的底层实现机制
  6. 安全发布:确保对象正确构造后再对其他线程可见

理解 JMM 有助于编写正确的并发程序,避免难以调试的并发 bug。在实际开发中,应该优先使用高级并发工具(如 java.util.concurrent 包中的类),只有在确实需要时才手动使用 volatile 或 synchronized。