Java 内存模型:volatile、happens-before 与有序性
Java 内存模型核心概念:volatile、happens-before 与有序性
当你开始编写多线程 Java 程序时,很快就会遇到一个看不见的对手:内存可见性。你可能修改了一个变量,但另一个线程却看不到;你以为代码会从上到下依次执行,CPU 却可能悄悄重排了指令。Java 内存模型(JMM)就是用来规范这些行为的契约。本文带你理解其中三个关键机制:volatile、happens-before 原则和有序性,让并发代码不再神秘。
为什么需要 Java 内存模型
在单线程世界里,代码执行结果与我们书写的顺序一致。但在多线程环境下,每个线程都有自己的工作内存(CPU 缓存、寄存器),主内存是所有线程共享的。线程修改了变量,只是先写到了自己的工作内存,何时刷新到主内存、何时从主内存重新读取,都不是你直接控制的。
这就导致了两个经典问题:
- 可见性问题:一个线程修改的值,另一个线程看不见。
- 指令重排问题:编译器或处理器为了性能,可能调整指令顺序,导致多线程下发呆看到“不可能”的状态。
JMM 定义了 happens-before 规则,只要遵守这些规则,就能保证你看到一致的内存状态。
volatile:轻量级同步机制
volatile 是 Java 提供的一种修饰符,它能保证:
- 可见性:对一个
volatile变量的写,立即刷新到主内存,并让其他线程的工作内存中的该缓存失效,迫使它们重新从主内存读取。 - 禁止指令重排序:通过内存屏障,不允许把
volatile写之前的操作重排到写之后,也不允许把volatile读之后的操作重排到读之前。
但要记住:volatile 不保证原子性。像 count++ 这种复合操作(读-改-写)仍然是线程不安全的,必须使用 synchronized 或原子类。
volatile 典型用法:状态标志
public class TaskRunner {
private volatile boolean running = true;
public void stop() {
running = false; // 写 volatile 变量
}
public void doWork() {
while (running) { // 读 volatile 变量
// 工作内容
}
}
}
这里 stop() 方法被调用后,running 变为 false,doWork() 中的循环能立即看到变化,跳出循环。如果没有 volatile,可能永远停在循环里。
happens-before:可见性的秩序
happens-before 不是“时间上先发生”,而是 JMM 保证的一个规则:如果操作 A happens-before 操作 B,那么 A 的结果对 B 可见,且 A 在排序上先于 B。 这是一个偏序关系。
核心规则包括:
- 程序次序规则:同一个线程内,书写在前面的操作 happens-before 后面的操作。(但请注意,这只针对单线程的“串行”一致性,多线程下可能被重排序打破)
- volatile 变量规则:对一个
volatile变量的写,happens-before 后续对这个volatile变量的读。 - 锁规则:一个锁的释放 happens-before 随后对这个锁的获取。
- 线程启动 and 终止规则:
Thread.start()happens-before 新线程中的任何动作;线程的所有动作 happens-before 其他线程检测到该线程已终止(如join()返回)。 - 传递性:如果 A happens-before B,B happens-before C,那么 A happens-before C。
这些规则组合起来就能构建安全的并发结构。例如:
int data = 0;
volatile boolean ready = false;
// 线程1
data = 42; // (1)
ready = true; // (2)写 volatile
// 线程2
if (ready) { // (3)读 volatile
int r = data; // (4)
}
这里(1)happens-before(2)由于程序次序规则。
(2)写 volatile happens-before(3)读 volatile,根据 volatile 规则。
通过传递性,(1)happens-before(4),所以线程2读到的 data 一定为 42。这种模式被称为 volatile 保证的发布。
有序性:指令重排序的真相
为了提高性能,编译器和 CPU 可能重排指令,在单线程内保证 as-if-serial 语义(即执行结果好像按顺序执行),但在多线程中可能产生问题。例如经典的“双重检查锁定”单例,在没有 volatile 时可能因为重排导致错误。
public class Singleton {
private static volatile Singleton instance; // 必须有 volatile
public static Singleton getInstance() {
if (instance == null) {
synchronized (Singleton.class) {
if (instance == null) {
instance = new Singleton(); // 分三步:分配内存→初始化对象→把引用赋给 instance
}
}
}
return instance;
}
}
如果没有 volatile,步骤2和3可能被重排:先赋值引用,后初始化对象。那么另一个线程可能在 instance 非 null 时进入,取到一个未完全初始化的对象。volatile 通过内存屏障禁止了这种重排序,保证对象完全创建后引用才可见。
重排序的场景
常见重排序类型:
- 编译器重排序:在不改变单线程语义下调整代码执行顺序。
- 处理器内存重排序:由于写缓冲区、缓存一致性协议,不同线程看到的写顺序可能不同。
JMM 通过内存屏障指令来约束:LoadLoad、StoreStore、LoadStore、StoreLoad 等屏障,确保特定操作的顺序。
内存屏障实战感知
尽管日常开发不直接操作屏障,但理解它们有助于明白 volatile 的开销。一个 volatile 写会插入 StoreStore 和 StoreLoad 屏障,volatile 读会插入 LoadLoad 和 LoadStore 屏障。这使得 volatile 读写比普通变量读写要慢一些,但远轻于 synchronized。
因此,在只需要保证可见性且操作是简单赋值时,volatile 是高效的选择。
有序性扩展:final 域的特殊性
JMM 对 final 域也给予了特殊的有序性保证。只要在构造函数中 final 域被正确初始化(不把 this 引用逸出),其他线程通过该对象引用看到 final 域时,一定是初始化完成的值,不会被重排序到构造函数之外。
public class SafeImmutable {
private final int x;
private final int y;
public SafeImmutable() {
x = 1;
y = 2;
// 未被 this 逸出
}
}
当一个线程通过引用看到 SafeImmutable 对象时,x 和 y 的值必定是 1 和 2,不需要额外同步。这是构建不可变对象安全发布的基础。
总结与实践建议
JMM 的核心在于 可见性、原子性、有序性 三座大山。在你的并发编程中:
- 用
volatile解决简单标志和一次性发布问题,但不用于复合操作。 - 任何多线程共享的、没有其他同步的变量,都要警惕不可见性。
- 遵循 happens-before 规则思考:如果有两个操作,检查它们之间是否存在链式的 happens-before 关系;若没有,则为数据竞争,需要修复。
- 用
synchronized或java.util.concurrent包中的工具(如锁和原子类)来获得完整的保证,不要试图用volatile拼凑复杂的线程安全逻辑。
理解内存模型不是学术追求,而是写出正确、高效并发代码的基石。现在就用这些知识去检查你的项目里有哪些变量需要 volatile,又有哪些地方缺少了 happens-before 的保障吧。