《Java 高级编程》4.1 可见性与重排序:volatile 与 happens-before

libraryd 的缓存刷新线程写了新值,请求线程却一直读旧的。本节用停止标志与 Dekker litmus 两组可复现实验,量化可见性与重排序,讲清 happens-before 的六条规则,再落到 LifecycleFlag 与 AvailabilityCache 的 volatile 发布写法。

本节目标:把「线程 A 写了,线程 B 为什么看不见」变成一个可以复现、可以解释、可以修的问题。
适用版本:JDK 25(Temurin 25.0.5);只使用 JDK 标准库。

承接的问题

libraryd 卷二上线时有一个缓存:AvailabilityCache 每 30 秒由后台线程从数据库全量拉一次「ISBN → 可借副本数」,请求线程只读这份内存快照,避免每次借阅都打一次聚合查询。

上线第二天,一台机器上的 /api/books/{isbn} 一直返回 copies=1,而数据库里同一本书已经是 3。刷新线程的日志显示它每 30 秒都在 publish,请求线程却像停在了旧世界。重启进程后恢复正常,过一段时间又出现。

这类问题最难的地方不是修,而是它不可复现:同一份代码在开发机上跑一万次都对。原因是 Java 内存模型(JMM)只承诺一件事——存在 happens-before 关系的写,对相应的读可见。没有这层关系,读线程没有任何义务看到写线程的修改,JIT 可以把读提升到循环外,CPU 可以让读走寄存器或 store buffer。

这一节先用两个最小实验把「看不见」和「顺序反了」分别固定下来,再回到 AvailabilityCache,看 libraryd 应该怎么写。

实验一:没有 volatile 的停止标志

最朴素的例子是一个布尔标志。写线程 200ms 后把它置为 true,读线程在 while (!stop) 里自旋,期望尽快退出:

public final class StopFlag {

    static boolean stop;          // 反例:没有 volatile

    public static void main(String[] args) throws Exception {
        int runs = Integer.parseInt(args[0]);
        int hung = 0;
        for (int i = 0; i < runs; i++) {
            stop = false;
            Thread worker = new Thread(() -> {
                long spins = 0;
                while (!stop) {
                    spins++;
                }
            }, "reader-" + i);
            worker.setDaemon(true);
            worker.start();
            Thread.sleep(200);
            stop = true;
            worker.join(500);
            if (worker.isAlive()) {
                hung++;
            }
        }
        System.out.println("runs=" + runs + " hung=" + hung);
    }
}

编译并运行 10 轮,每轮给读线程 500ms 的退出窗口:

export JAVA_HOME=~/.jdks/jdk-25.0.5+8/Contents/Home
export PATH="$JAVA_HOME/bin:$PATH"
javac --release 25 -d /tmp/vol3-b/jmm/out /tmp/vol3-b/jmm/StopFlag.java
java -cp /tmp/vol3-b/jmm/out StopFlag 10

本机输出(Apple M1 Pro,macOS 26.3,Temurin 25.0.5):

runs=10 hung=10

10 轮全部没有退出。这不是「概率问题」,而是 C2 把这个循环编译成了「只读一次 stop」的形态:循环体里没有任何可能改变 stop 的操作,也没有同步点,把读提升到循环外是合法优化。读线程在自己的世界里永远看到 false。

要确认原因确实是 JIT 而不是「CPU 太慢」,用解释器模式跑同一份字节码:

java -Xint -cp /tmp/vol3-b/jmm/out StopFlag 3

本机输出:

  worker exited spins=39944282
  worker exited spins=40954297
  worker exited spins=40823223
runs=3 hung=0

-Xint 下 3 轮全部正常退出,每轮自旋约 4000 万次。同一份代码,只换执行引擎,行为就反过来了——这基本可以锁定是编译器的循环提升,而不是调度巧合。

把 stop 改成 volatile,10 轮全部退出:

static volatile boolean stop;
runs=10 hung=0

volatile 在这里做两件事:禁止读被提升到循环外(每次都必须重新读),并且让写线程的写对读线程可见。它不保证「读线程一定立刻看到」,但保证「迟早看到,且不会永久看不到」。

实验二:两个写、两个读的顺序

可见性之外还有第二个问题:编译器和 CPU 可以调整没有依赖关系的指令顺序。经典的 Dekker litmus 测试是这样的:两个线程各自先写自己的变量,再读对方的变量。

public final class Reorder2 {

    static int x;
    static int y;
    static int r1;
    static int r2;
    static volatile int start;
    static volatile int doneA;
    static volatile int doneB;

    public static void main(String[] args) throws Exception {
        int iterations = Integer.parseInt(args[0]);
        Thread a = new Thread(() -> {
            for (int i = 1; i <= iterations; i++) {
                while (start < i) { }
                x = 1;
                r1 = y;
                doneA = i;
            }
        });
        Thread b = new Thread(() -> {
            for (int i = 1; i <= iterations; i++) {
                while (start < i) { }
                y = 1;
                r2 = x;
                doneB = i;
            }
        });
        a.start();
        b.start();
        long bothZero = 0;
        for (int i = 1; i <= iterations; i++) {
            x = 0;
            y = 0;
            r1 = -1;
            r2 = -1;
            start = i;
            while (doneA < i || doneB < i) { }
            if (r1 == 0 && r2 == 0) {
                bothZero++;
            }
        }
        System.out.println("iterations=" + iterations + " bothZero=" + bothZero);
    }
}

start、doneA、doneB 是 volatile,只负责回合切换与结果发布;被测的是 x、y 的四个读写。如果两个线程严格按程序顺序执行,「A 写 x」和「B 写 y」至少有一个先发生,r1、r2 不可能同时为 0。跑 200 万轮:

javac --release 25 -d /tmp/vol3-b/jmm/out /tmp/vol3-b/jmm/Reorder2.java
java -cp /tmp/vol3-b/jmm/out Reorder2 2000000

本机两次运行的输出:

iterations=2000000 bothZero=1988302 other=11698
iterations=2000000 bothZero=1986894 other=13106

约 99.4% 的回合里,两个读都看到了 0。这是合法结果:x = 1 和 r1 = y 之间没有依赖,编译器可以把读排到写前面;即使编译器不动,两个写进入 store buffer 后,对方的读也可能先完成。JMM 不禁止这种重排,因为它没有违反任何 happens-before。

把 x、y 都改成 volatile,同样 200 万轮:

iterations=2000000 bothZero=0

volatile 写与后续 volatile 读之间建立了 happens-before,两个读不可能都落在对方的写之前。

这里要如实交代一个未复现的版本。第一版实验是「每轮新建两个线程」,跑 2 万轮:

iterations=20000 bothZero=0

本次未复现,原因是每轮要新建两个线程,t1.start() 和 t2.start() 之间隔着几十微秒,而两个线程真正执行 x=1; r1=y 的窗口只有纳秒级,两边几乎不可能同时进入。改成上面的常驻线程加 volatile 回合切换后,窗口被压到最小,才稳定复现。写并发实验时,「没复现」不等于「不存在」,要把线程创建、启动顺序这些开销算进去。

happens-before 的六条规则

JMM 用 happens-before 定义可见性,日常只需记住六条:

  1. 程序顺序:同一个线程里,前面的操作 happens-before 后面的操作。
  2. 监视器锁:对同一个 monitor 的 unlock happens-before 后续的 lock。
  3. volatile:对同一个 volatile 变量的写 happens-before 后续对它的读。
  4. 线程启动与终止:Thread.start() happens-before 新线程里的任何操作;线程里的所有操作 happens-before 其他线程从 join() 返回。
  5. final 字段:构造器里对 final 字段的写,happens-before 构造完成后引用被其他线程看到(前提是构造期间 this 没有逸出)。
  6. 传递性:A hb B、B hb C,则 A hb C。

回到 AvailabilityCache 的 bug:刷新线程写了一个普通字段,请求线程读它,两者之间只有「碰巧在同一个进程」。没有第 3 条,也没有第 2 条,JMM 不给任何保证。

顺带说清一个常见混淆:对象默认值(0、false、null)是另一回事。JVM 保证字段在分配后先被清零,读到 0 不奇怪;奇怪的是它一直看不到后来的值。

volatile 的边界

volatile 常被当成「轻量锁」,但它只解决两件事:可见性和禁止特定重排。它不解决原子性。

// 反例:volatile 不能保证自增原子
static volatile int borrowedCount;

static void recordBorrow() {
    borrowedCount++;   // 读、加一、写回,三步之间可以被别的线程插入
}

8 个线程各自增 100 万次,最终的 borrowedCount 会明显小于 800 万,且每次运行都不一样。正确的做法是 AtomicLong(4.2 节)或 LongAdder。

另外两个边界:

  • volatile 引用只保护引用本身。volatile List<Loan> loans 保证「换列表」可见,但往列表里 add 不受保护;volatile int[] counts 也只让数组引用可见,counts[0]++ 依然不是原子的。

volatile 真正适合的场景是「一个线程写、多个线程读」的状态标志,或者「发布一个不可变快照」。libraryd 的两处用法都属于后者。

在 libraryd 里落地

第一处是停机标志。卷二原来的写法是普通 boolean,在 -Xint 下能跑,开了 JIT 就有概率停不下来:

package com.plume.library.app;

public final class LifecycleFlag {

    private volatile boolean stopping;

    public boolean isStopping() {
        return stopping;
    }

    public void requestStop() {
        stopping = true;
    }
}

它被三个地方读:Javalin 的 before 过滤器(拒绝新请求)、LoanEventPipeline 的消费循环(决定是否继续取事件)、健康检查(返回 draining 状态)。写只有一处:shutdown hook。这种「单写多读、只关心最终看见」的形状,是 volatile 的标准用法。

第二处是 AvailabilityCache。修法不是给每个字段加 volatile,而是把整份快照做成不可变对象,用一次 volatile 写发布:

package com.plume.library.persistence;

import java.time.Instant;
import java.util.Map;
import java.util.Optional;

public final class AvailabilityCache {

    public record Snapshot(String isbn, int copies, Instant loadedAt) {
        public Snapshot {
            if (copies < 0) {
                throw new IllegalArgumentException("copies 不能为负: " + copies);
            }
        }
    }

    private volatile Map<String, Snapshot> current = Map.of();

    public Optional<Snapshot> get(String isbn) {
        Map<String, Snapshot> local = current;   // 只读一次 volatile
        return Optional.ofNullable(local.get(isbn));
    }

    public void publish(Map<String, Snapshot> fresh) {
        current = Map.copyOf(fresh);             // 先构造完整快照,再一次性发布
    }

}

三个细节决定了它正确:

  • Map.copyOf 生成不可变副本,发布之后没有任何人能改它,读线程不会看到「一半新一半旧」的 map。
  • volatile 写建立了 happens-before:刷新线程在 publish 之前对 map 的所有写,对之后读到这个引用的线程都可见。
  • get 先把 current 读进局部变量。一次请求里如果需要多次读取,用同一个 local,避免两次读之间缓存被换掉,出现前后不一致。

刷新任务本身也变简单了,它只负责算出一份新 map:

package com.plume.library.persistence;

import java.time.Instant;
import java.util.HashMap;
import java.util.Map;

public final class AvailabilityRefresher {

    private final AvailabilityQueries queries;
    private final AvailabilityCache cache;

    public AvailabilityRefresher(AvailabilityQueries queries, AvailabilityCache cache) {
        this.queries = queries;
        this.cache = cache;
    }

    public void refreshOnce() {
        Map<String, AvailabilityCache.Snapshot> fresh = new HashMap<>();
        Instant now = Instant.now();
        for (AvailabilityQueries.Row row : queries.loadAll()) {
            fresh.put(row.isbn(), new AvailabilityCache.Snapshot(row.isbn(), row.copies(), now));
        }
        cache.publish(fresh);
    }
}

AvailabilityQueries 是 persistence 模块的只读查询端口,返回 Row(isbn, copies)。刷新失败时不要 publish 空 map,那会让所有书瞬间变成不可借;保留上一份快照并把错误记进指标,是更安全的降级。

两个类都在 JDK 25 下用 javac --release 25 -d out -cp out 编译通过,无警告。

常见错误与边界

反例一:用 volatile 当计数器。 前面已经跑过,8 线程 100 万次会丢几十万。volatile 保证每次读是最新值,但 count++ 的「读—加—写」三步仍然可以被交错。看到 volatile int count 就应立即想到「这里是不是该用 AtomicLong」。

反例二:双重检查锁忘了 volatile。

// 反例:instance 不是 volatile
private static Singleton instance;

static Singleton get() {
    if (instance == null) {
        synchronized (Singleton.class) {
            if (instance == null) {
                instance = new Singleton();   // 可能先发布引用,再完成构造
            }
        }
    }
    return instance;
}

new Singleton() 不是原子动作:分配、初始化、赋值引用三步可能重排,另一个线程可能在第一个 if 处看到一个「引用非空但字段还是默认值」的对象。JDK 5 之后把 instance 声明为 volatile 就能修好,这也是 JMM 修订的动因之一。

反例三:以为 volatile 能替代锁。 「先检查再动作」需要原子性,volatile 给不了。库存检查 if (copies > 0) copies-- 即使 copies 是 volatile,两个线程也可能都通过检查。libraryd 的做法是把这类判断交给数据库条件更新,而不是在 JVM 里用 volatile 硬撑。

边界:volatile 的读写在 x86 上几乎免费,在 ARM 上不是。 本机是 Apple Silicon(aarch64),volatile 写会生成带内存屏障的指令。把热循环里的普通字段全改成 volatile,可能带来可测量的开销——先测量再改,第 9 章会用 JMH 做这件事。

小结

  • 没有 happens-before 关系时,读线程没有义务看到写线程的修改;这不是 bug,是 JMM 允许的。
  • 非 volatile 的停止标志在本机 10/10 卡死,-Xint 下 3/3 正常退出,说明主因是 JIT 的循环提升。
  • Dekker litmus 在 200 万轮里有约 99.4% 出现「两个读都为 0」,给 x、y 加 volatile 后降为 0。
  • happens-before 记住六条:程序顺序、monitor、volatile、线程 start/join、final 字段、传递性。
  • volatile 只给可见性和有序性,不给原子性;计数用 AtomicLong/LongAdder,复合判断用锁或数据库条件更新。
  • libraryd 用 volatile 发布不可变快照:先 Map.copyOf 构造完整对象,再一次写引用。

volatile 解决了「看见」,但没解决「同时改」。下一节从一次丢了几十万次自增的计数器开始,看 CAS 与 LongAdder 各自适合什么。

阅读导航:上一节:3.3 Instrumentation 与 Agent · 下一节:4.2 原子类与 CAS:AtomicLong 与 LongAdder 的取舍 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「java」更多文章

  1. 《Netty 高性能网络编程》15.3 linkd-v2 验收与复盘
  2. 《Netty 高性能网络编程》15.2 限流、超时与降级
  3. 《Netty 高性能网络编程》15.1 网关路由与转发