《Java 并发编程·线程与内存模型》3.1 重排序、可见性与有序性

从一段停不下来的循环入手,拆开重排序、缓存可见性与有序性三个层次,说明 as-if-serial 只对单线程负责;给出无 volatile 与有 volatile 的停机标志完整程序及本机实跑输出,并说明这类 bug 为何依赖 JIT、CPU 与运行时长方能显现。

承接的问题

2.3 用 JCStress 复现了计数器丢更新,那里的问题是两个线程的读和写交错,属于原子性。

但并发 bug 还有另一类:两个线程根本没有同时读写同一时刻,代码却仍然出错。最典型的症状是一个线程已经把 running 改成 false,另一个线程的 while (running) 却永远不退出。加一句 System.out.println 就好了,去掉又坏;在开发机上跑一百次都正常,上线后偶发卡死。

这一节回答三个问题:

  1. 一行 running = false 之后,另一个线程为什么可能看不见?
  2. 编译器、CPU、缓存各自做了什么,谁该负责?
  3. 为什么 volatile 能修好它,而 Thread.sleep、打印日志不能作为修复手段?

第 3.2 节会把这些现象收敛成 happens-before 规则,第 3.3 节处理对象发布。本节先把「看不见」这件事拆到足够具体。

三个独立但常一起出现的问题

「重排序」「可见性」「有序性」经常被混着说,其实是三个不同层次的机制。

层次谁在做表现典型手段
编译器重排序javac、C1、C2读被提到循环外、死代码被删不依赖顺序的优化
CPU 乱序流水线、Store Buffer写先进入缓冲区,读越过写内存屏障指令
缓存可见性每个核的缓存、寄存器一个核写了,另一个核还读旧值缓存一致性协议 + 屏障

编译器重排序

javac 的优化很保守,真正激进的是 JIT。C2 在方法被调用足够多次后,会做内联、循环不变量外提、公共子表达式消除、死存储消除。

对单线程来说,这些优化必须保持「好像按源代码顺序执行」的结果,这叫 as-if-serial。问题在于,这个「好像」只对当前线程成立。

int a = 1;      // 写 A
int b = 2;      // 写 B
int c = a + b;  // 读 A、B

A 和 B 谁先写,单线程都看不到区别,所以编译器可以交换。但如果另一个线程在观察这两个变量,交换就会变成可观测的差异。

更隐蔽的是循环里的读。下面这段代码:

while (running) {
    i++;
}

如果循环体没有写 running,也没有方法调用或同步点,C2 可以判断「每次读到的值都一样」,把读提升到循环外,只读一次。循环于是变成 while (true) i++。这不是编译器有 bug,而是它被允许这样做,因为原始程序存在数据竞争,JMM 不保证结果。

CPU 乱序与 Store Buffer

即使编译器不重排,CPU 也可能让写「迟到」。现代处理器为了填满流水线,会把写先放进 Store Buffer,等总线有空再刷出去;读则可以越过尚未提交的写,从缓存或寄存器直接取。

x86 是较强的内存模型(TSO),只允许 StoreLoad 一种重排;ARM、Power 是弱内存模型,四种重排都可能发生。Apple M1 Pro 属于 ARM 体系,这也是同一段 Java 代码在 M1 和 x86 服务器上表现不同的原因之一。

JMM 不绑定具体硬件,它只规定「允许看到什么结果」。实现层面用四类屏障兜底:

屏障约束常见位置
LoadLoad前面的读先于后面的读volatile 读之后
StoreStore前面的写先于后面的写volatile 写之前
LoadStore前面的读先于后面的写volatile 读写两侧
StoreLoad前面的写先于后面的读volatile 写之后,最贵

HotSpot 在 x86 上会把大部分屏障编译成 no-op,只在 volatile 写后保留 lock addl;在 ARM 上则要生成真实的 dmb ish 一类指令。所以「加 volatile 会变慢」在 ARM 上更明显,但两种架构上的语义完全相同。

缓存可见性

每个核有自己的 L1/L2,共享 L3 和内存。MESI 协议保证缓存行最终一致,但「最终」不等于「立刻」。一个核写入后,另一个核的缓存行要收到失效消息才会重新加载。如果读线程把变量缓存在寄存器里,连缓存行都不会再读。

这里要区分两件事:

  • 缓存一致性解决的是「同一地址的多个副本何时统一」;
  • 可见性解决的是「一个线程的写,何时对另一个线程的读可见」。

后者不是硬件自动给出的,需要程序用同步动作建立顺序。JMM 把这件事交给 happens-before,而不是交给 CPU。

as-if-serial 只对单线程负责

as-if-serial 的准确说法是:不管怎么重排,单线程执行的结果必须和完全按源代码顺序执行一致。

它保护的是:

double pi = 3.1415926;
double r = 2.0;
double area = pi * r * r;   // 必须读到 pi 和 r 的赋值结果

area 依赖 pi、r,存在数据依赖,不能被提到赋值之前。没有依赖的语句则可以交换。

它不保护的是:

// 线程 A
data = 42;
ready = true;

// 线程 B
if (ready) {
    System.out.println(data);
}

线程 A 内部,data = 42 和 ready = true 没有数据依赖,编译器可以交换;CPU 也可能让 ready 的写先被另一个核看到。线程 B 看到 ready == true 时,data 可能仍是 0。

ready 是普通变量,两个线程之间没有任何同步动作,这段程序存在数据竞争。JMM 对数据竞争程序的承诺只有一句:行为不确定,但不会凭空创造值。它不承诺你想要的顺序。

一句话记:as-if-serial 保证「我自己看自己」永远正确,不保证「别人看我」也正确。单线程测试、main 里的顺序执行、加了日志的调试运行,都可能完全正常;多线程视角的顺序必须由锁、volatile、线程启动/终止等同步动作建立。

停机标志:一个必须亲手跑的实验

下面是可以直接编译运行的最小程序。它故意把「读标志」和「写标志」放在两个线程里,中间只有 Thread.sleep,没有任何同步。

public class StopFlagPlain {
    private static boolean running = true;

    public static void main(String[] args) throws Exception {
        Thread worker = new Thread(() -> {
            long i = 0;
            while (running) {
                i++;
            }
            System.out.println("worker stopped, i=" + i);
        }, "worker");
        worker.setDaemon(true);
        worker.start();

        Thread.sleep(1000);
        running = false;
        System.out.println("main set running=false");

        worker.join(3000);
        System.out.println("worker alive after join(3000): " + worker.isAlive());
    }
}

编译并运行,用 10 秒看门狗兜底,避免真的挂死:

export JAVA_HOME=~/.jdks/jdk-25.0.5+8/Contents/Home
$JAVA_HOME/bin/javac -d . StopFlagPlain.java
( $JAVA_HOME/bin/java -cp . StopFlagPlain & pid=$!; \
  ( sleep 10; kill -9 $pid 2>/dev/null ) & \
  wait $pid )

本机一次运行的输出(Apple M1 Pro,10 核,32 GB,macOS 26.3,Temurin 25.0.5+8):

main set running=false
worker alive after join(3000): true

worker alive after join(3000): true 说明主线程等了三秒,工作线程仍在跑。它不是因为 join 失效,而是因为工作线程根本没看到 running 已经变成 false。

为什么它会停不下来

JIT 在 worker 方法热起来之后,常见做法是把 running 的读提升到循环外。对 worker 线程来说,循环体只改局部变量 i,没有任何东西能让它怀疑 running 会变,于是循环退化成死循环。具体是 C1 还是 C2 做的、在哪一刻做,取决于运行情况;能确定的是这段代码存在数据竞争,JMM 允许这种优化。

注意 worker.setDaemon(true) 的作用:它让 JVM 在主线程结束后能退出,否则这个程序会一直挂着。这是实验保护,不是修复。生产代码不能靠 daemon 线程「反正进程会退出」来掩盖标志位不生效。

加上 volatile 的对照

只改一个修饰符:

public class StopFlagVolatile {
    private static volatile boolean running = true;

    public static void main(String[] args) throws Exception {
        Thread worker = new Thread(() -> {
            long i = 0;
            while (running) {
                i++;
            }
            System.out.println("worker stopped, i=" + i);
        }, "worker");
        worker.setDaemon(true);
        worker.start();

        Thread.sleep(1000);
        running = false;
        System.out.println("main set running=false");

        worker.join(3000);
        System.out.println("worker alive after join(3000): " + worker.isAlive());
    }
}

同样的运行方式,本机输出:

main set running=false
worker stopped, i=1711024757
worker alive after join(3000): false

i 的具体数值每次不同,取决于这一秒里循环跑了多少次,不必对齐。

volatile 做了两件事:

  1. 每次读都从主内存/缓存一致性协议允许的最新副本读,编译器不能把读提出循环;
  2. 写 running = false 与后续读 running 之间建立 happens-before,写之前的动作对读到新值的线程可见。

它没有做的一件事是保证 i++ 原子。本实验里 i 只有一个线程写,所以没问题;如果两个线程都写 i,volatile 仍然会丢更新。

这个实验不能承诺稳定复现

必须把话说清楚:可见性 bug 是否出现,取决于 JIT 是否已经编译、编译到哪一层、CPU 架构、核数、运行时长、是否有其他同步动作。

同一个程序可能出现这些结果:

运行条件可能结果
解释执行(-Xint)通常能正常退出,因为每次都真的读
C1 编译可能退出,也可能不退出
C2 编译后更容易把读提出循环
循环里有 println打印是同步点,通常会退出
单核机器调度切换可能让写可见,现象减弱

所以:

  • 本机复现了,不代表你的机器一定复现;
  • 本机没复现,不代表代码是对的;
  • 不要用「我跑了一百次都没事」作为并发正确性的证据。

如果在一台机器上没复现,可以尝试 -Xcomp 强制编译、延长运行时间、把循环体改得更纯粹(去掉方法调用),但依然不能保证。正确做法不是想办法复现,而是用 3.2 的规则证明它为什么错。

在 libraryd-conc 里落地

把停机标志放回借阅服务:库存巡检线程每隔一段时间扫描低库存图书,主程序关闭时需要让它停下来。

错误写法是把 running 写成普通字段:

private boolean running = true;   // 错误:跨线程读写却没有同步

巡检线程被 JIT 编译后,running 的读可能被提出 while。Thread.sleep(200) 是方法调用,通常会阻止最激进的优化,但它不是同步动作,JMM 不保证它建立可见性。换句话说,它可能「碰巧能跑」,但不是正确。

正确版本只改两处:字段加 volatile,stop 之后再补一个 join 让调用方确认线程真的结束。

package com.plume.conc.core;

import java.util.List;
import java.util.Optional;

public final class LowStockMonitor implements Runnable {
    private final BookRepository repository;
    private final List<String> isbns;
    private final int threshold;
    private volatile boolean running = true;
    private volatile Thread worker;

    public LowStockMonitor(BookRepository repository, List<String> isbns, int threshold) {
        this.repository = repository;
        this.isbns = List.copyOf(isbns);
        this.threshold = threshold;
    }

    public synchronized void start() {
        if (worker != null) {
            throw new IllegalStateException("monitor already started");
        }
        worker = Thread.ofPlatform().name("low-stock-monitor").daemon(true).start(this);
    }

    public void stop() throws InterruptedException {
        running = false;
        Thread current = worker;
        if (current != null) {
            current.join(1000);
        }
    }

    @Override
    public void run() {
        while (running) {
            for (String isbn : isbns) {
                Optional<Book> found = repository.findByIsbn(isbn);
                found.filter(book -> book.stock() < threshold)
                     .ifPresent(book -> System.out.println("low stock: " + book.isbn()
                             + " left=" + book.stock()));
            }
            try {
                Thread.sleep(200);
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
                return;
            }
        }
        System.out.println("monitor stopped");
    }
}

这里依然保留 volatile,因为 stop() 和 run() 在两个线程里。start() 用 synchronized 避免重复启动,worker 本身是 volatile,stop() 才能可靠读到启动后的引用;join(1000) 是给调用方的确认,不是可见性的替代品。

如果巡检逻辑更复杂,比如需要「停止时把当前批次处理完」,就不该只靠一个布尔值,而要用第 6 章的两阶段终止:volatile 标志负责「不再接新任务」,interrupt() 负责唤醒阻塞,join() 负责等待收尾。三个动作各司其职。

常见错误与边界

写法为什么不对正确做法
普通 boolean 做跨线程标志读可能被提出循环volatile boolean
用 Thread.sleep 代替同步sleep 不建立 happens-before加 volatile 或锁
循环里 System.out.println打印是同步点,掩盖 bug去掉打印后再验证
volatile int i 然后 i++复合操作不原子AtomicInteger 或锁
volatile int[] a 然后改元素只保证引用可见,不保证元素AtomicIntegerArray 或锁
Thread.stop() 停线程JDK 25 调用抛 UnsupportedOperationException标志位 + interrupt + join

什么时候不要用 volatile

  • 需要「读-改-写」的复合操作,比如库存扣减、计数器自增;
  • 需要多个字段一起变化,比如 (x, y) 必须同时更新,volatile 只能分别保证两个字段的可见性,不能保证组合的原子性;
  • 已经在一把锁的保护范围内,再加 volatile 只会增加屏障开销,不增加正确性;
  • 需要阻塞等待条件成立,应该用 wait/notify 或 Condition(第 5 章),而不是忙等一个 volatile 标志。

另外两个容易忽略的边界:

第一,不要用 volatile 修数据竞争。 如果两个线程都写同一个变量,volatile 只保证每次读能看到「某个」最新写,不保证不丢更新。2.3 的计数器就是例子。

第二,可见性问题不会在短命程序里稳定出现。 一个只跑几十毫秒的测试,JIT 可能还没编译到 C2,读每次都真的发生,测试通过;线上跑几小时后才卡死。这类 bug 的测试策略不是「多跑几遍」,而是代码评审时检查每个跨线程共享的可变字段有没有同步动作。

小结

  • 重排序来自编译器、CPU 和缓存三个层次,as-if-serial 只保证单线程结果,不保证多线程观察到的顺序。
  • 可见性丢失的典型症状是「写已经发生,读线程还在用旧值」,停机标志是最小复现。
  • 本机用 Temurin 25.0.5+8 在 M1 Pro 上复现了无 volatile 版本不退出,加 volatile 后正常退出;但复现与否依赖 JIT、CPU 和运行时长,不能承诺稳定复现。
  • volatile 解决可见性和禁止特定重排,不解决复合操作的原子性。
  • 判断并发正确性要靠 3.2 的 happens-before 规则推导,不能靠「跑了很多次没事」。

阅读导航:上一节 2.3 用 JCStress 复现计数器竞态 | 下一节 3.2 happens-before 八条规则

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

  1. 《Java 消息中间件·事件驱动架构》附录 B 架构决策记录模板
  2. 《Java 消息中间件·事件驱动架构》附录 A 术语表
  3. 《Java 消息中间件·事件驱动架构》15.3 全书验收