Java 内存模型:volatile、happens-before 与有序性

FreeGuideOnline 最新 2026-06-17

Java 内存模型核心概念:volatile、happens-before 与有序性

当你开始编写多线程 Java 程序时,很快就会遇到一个看不见的对手:内存可见性。你可能修改了一个变量,但另一个线程却看不到;你以为代码会从上到下依次执行,CPU 却可能悄悄重排了指令。Java 内存模型(JMM)就是用来规范这些行为的契约。本文带你理解其中三个关键机制:volatile、happens-before 原则和有序性,让并发代码不再神秘。

为什么需要 Java 内存模型

在单线程世界里,代码执行结果与我们书写的顺序一致。但在多线程环境下,每个线程都有自己的工作内存(CPU 缓存、寄存器),主内存是所有线程共享的。线程修改了变量,只是先写到了自己的工作内存,何时刷新到主内存、何时从主内存重新读取,都不是你直接控制的。

这就导致了两个经典问题:

  • 可见性问题:一个线程修改的值,另一个线程看不见。
  • 指令重排问题:编译器或处理器为了性能,可能调整指令顺序,导致多线程下发呆看到“不可能”的状态。

JMM 定义了 happens-before 规则,只要遵守这些规则,就能保证你看到一致的内存状态。

volatile:轻量级同步机制

volatile 是 Java 提供的一种修饰符,它能保证:

  1. 可见性:对一个 volatile 变量的写,立即刷新到主内存,并让其他线程的工作内存中的该缓存失效,迫使它们重新从主内存读取。
  2. 禁止指令重排序:通过内存屏障,不允许把 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 变为 falsedoWork() 中的循环能立即看到变化,跳出循环。如果没有 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 通过内存屏障指令来约束:LoadLoadStoreStoreLoadStoreStoreLoad 等屏障,确保特定操作的顺序。

内存屏障实战感知

尽管日常开发不直接操作屏障,但理解它们有助于明白 volatile 的开销。一个 volatile 写会插入 StoreStoreStoreLoad 屏障,volatile 读会插入 LoadLoadLoadStore 屏障。这使得 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 对象时,xy 的值必定是 1 和 2,不需要额外同步。这是构建不可变对象安全发布的基础。

总结与实践建议

JMM 的核心在于 可见性、原子性、有序性 三座大山。在你的并发编程中:

  • volatile 解决简单标志和一次性发布问题,但不用于复合操作。
  • 任何多线程共享的、没有其他同步的变量,都要警惕不可见性。
  • 遵循 happens-before 规则思考:如果有两个操作,检查它们之间是否存在链式的 happens-before 关系;若没有,则为数据竞争,需要修复。
  • synchronizedjava.util.concurrent 包中的工具(如锁和原子类)来获得完整的保证,不要试图用 volatile 拼凑复杂的线程安全逻辑。

理解内存模型不是学术追求,而是写出正确、高效并发代码的基石。现在就用这些知识去检查你的项目里有哪些变量需要 volatile,又有哪些地方缺少了 happens-before 的保障吧。