Java 内存模型实战:volatile、happen-before 与可见性陷阱

你是不是遇到过这样的诡异 bug:一个线程改了 flag,另一个线程却永远读不到新值,循环怎么都停不下来?这背后不是玄学,而是 Java 内存模型(JMM)在起作用。理解 JMM、volatilehappen-before 规则,是写出正确并发代码的地基。

一、JMM 的核心抽象:主内存与本地内存

JMM 并不要求所有变量都放在同一块物理内存里。它抽象出两层:主内存(Main Memory)存放变量的真实值;每个线程在运行时拥有自己的 工作内存(Working Memory / 本地内存),线程读写变量时,其实是先把主内存的值拷贝到本地副本,在副本上操作,最后再把结果写回主内存。

问题就出在这个”拷贝—计算—写回”的间隙。如果线程 A 改了值却还没刷回主内存,线程 B 从主内存读到的就还是旧值——这就是 可见性(Visibility)问题。更隐蔽的是,编译器和 CPU 出于性能会做指令重排序,在单线程下结果不变,但在多线程下可能破坏你预期的执行顺序。

二、一个最经典的可见性陷阱

下面这段看似无害的代码,在 Server 模式(JIT 充分优化)下可能永远停不下来:

public class VisibilityBug {
    private boolean running = true;

    public void start() {
        new Thread(() -> {
            while (running) {
                // 做点事
            }
            System.out.println("stopped");
        }).start();
    }

    public void stop() {
        running = false; // 主线程改了,工作线程可能永远看不到
    }
}

子线程把 running 缓存进了自己的工作内存,主线程对它的修改无法被感知,JIT 甚至会把 while(running) 优化成 while(true)。修复方式之一就是给 runningvolatile

三、volatile 到底解决了什么

volatile 在 JMM 层面做两件事:

  • 保证可见性:对该变量的写操作会立即刷回主内存,读操作会绕过本地副本、直接从主内存取最新值。
  • 禁止指令重排序:在读写前后插入内存屏障,防止编译器和 CPU 的优化打乱”初始化”与”发布引用”的顺序。

但它 不保证原子性。给 volatile int countcount++ 依然不安全,因为 ++ 是”读—改—写”三步复合操作,并非不可分割。此外在 Java 5 之前,对 64 位的 long/double 读写可能拆成两个 32 位操作,volatile 从 Java 5 起也顺带保证了其读写原子性。

// 错误示范:volatile 救不了原子性
private volatile int count = 0;
public void unsafe() {
    count++; // 仍可能与其他线程的更新互相覆盖
}

// 正确做法:用 AtomicInteger
private final AtomicInteger count = new AtomicInteger(0);
public void safe() {
    count.incrementAndGet(); // CAS 保证真正的原子自增
}

四、happen-before:可见性的”秩序法则”

JMM 用 happen-before 关系来定义”什么操作一定对什么操作可见”。只要 A happen-before B,那么 A 的所有修改对 B 一定可见,且 B 不会”看到”重排序后的乱序。几条最常用的规则:

规则含义
程序次序规则同一线程内,靠前的操作 happen-before 靠后的操作
volatile 规则对 volatile 变量的写 happen-before 后续对该变量的任意读
监视器锁规则unlock happen-before 后续对同一锁的 lock
线程启动/终止Thread.start() 前的操作 happen-before 新线程的任何操作;线程所有操作 happen-before 其他线程的 join() 返回
传递性若 A hb B 且 B hb C,则 A hb C

五、安全发布:用 volatile 修好双重检查锁

单例的”双重检查锁(DCL)”是 volatile 的经典用武之地。instance = new Singleton() 在字节码层面并非原子:先分配内存、再调用构造器初始化、最后把引用赋给 instance。没有 volatile 时,重排序可能让”引用赋值”先于”构造完成”,另一个线程于是拿到一个”半初始化”的对象。

public class Singleton {
    // 关键:volatile 禁止对象初始化与引用赋值的重排序
    private static volatile Singleton instance;

    public static Singleton getInstance() {
        if (instance == null) {            // 第一次检查(无锁,快路径)
            synchronized (Singleton.class) {
                if (instance == null) {    // 第二次检查(加锁,慢路径)
                    instance = new Singleton();
                }
            }
        }
        return instance;
    }
}

六、volatile 与 synchronized 怎么选

维度volatilesynchronized
可见性✅ 保证✅ 保证
原子性❌ 仅限单一读写✅ 代码块整体原子
重排序✅ 禁止✅ 隐式保证
是否阻塞否(无锁)是(锁竞争时阻塞)
开销较高
适用场景状态标志、安全发布、读多写少复合操作、临界区

七、内存屏障:volatile 背后的硬约束

JVM 在 volatile 读写处插入的其实是四类内存屏障(以 JSR-133 语义为准):

  • LoadLoad:确保后续读不会重排到当前读之前;
  • StoreStore:确保后续写不会重排到当前写之前;
  • LoadStore:确保后续写不会重排到当前读之前;
  • StoreLoad:最重的一条,确保后续读能看到当前写的结果,也是 volatile 写后必须插入的屏障。

正是这些屏障让”写线程的修改”对”读线程”有序可见。代价是它在一定程度上抑制了 CPU 的乱序执行优化,所以 volatile 不应被滥用在高频计数器上。

八、读多写少与状态标志:volatile 的最佳战场

volatile 最合适的使用场景有两个:状态标志(如上面的 running、配置热开关)和读多写少的共享状态(如一个偶尔更新的配置对象引用)。前者靠可见性及时通知,后者靠”一次性写 + 多次安全读”避免每次加锁。

// 配置热更新:写少读多,volatile 比锁更轻量
private volatile AppConfig config = loadConfig();

public AppConfig getConfig() {
    return config; // 读无锁,且总能拿到最新一次完整发布
}

public void refresh() {
    config = loadConfig(); // 一次性原子发布新引用
}

九、现代并发下的内存可见性

到了 Java 21 虚拟线程 时代,JMM 的可见性规则并没有变,但调度模型变了:海量轻量线程并发运行,共享可变状态的可见性错误会被成倍放大。配合合理的 线程池参数调优结构化并发,把可变状态收敛到明确的边界(如每个任务独占的局部变量),才是根治之道。如果你的”内存问题”表现为堆飙升而非可见性,可参考 Java 内存泄漏排查复盘 定位对象未释放的根因。

十、并发可见性排雷清单

  • 状态开关、配置热更新一律用 volatileAtomicBoolean,别裸用普通布尔;
  • 复合读写(i++、check-then-act)用锁或原子类,不要迷信 volatile
  • 发布对象优先”安全发布”:final 字段、volatile 引用、或锁保护;
  • 不要依赖线程优先级或 Thread.sleep() 来”碰巧”读到最新值;
  • 用 jcstress 等专业工具做并发压力测试,别靠肉眼和运气。

可见性陷阱最阴险的地方在于:它”测试环境一切正常,上线半夜暴雷”。把 JMM 和 happen-before 刻进肌肉记忆,再用 volatile/锁/原子类各司其职,你的并发代码才算真正”线程安全”。

上一篇 SQL 窗口函数实战:排名、累计与分组统计
下一篇 Human-in-the-loop 实战:让 AI Agent 在关键节点等人确认