Skip to content

JMM 与 happens-before:多线程读写为什么「看不到」新值 ​

理解 Java 内存模型,重点不是「主内存和工作内存」那张图,而是:两个线程读写同一个变量时,Java 保证了什么、没保证什么,以及怎样让结果变得可推理。

先看一段在 JDK 21 上可以稳定复现的代码。主线程一秒后把 stop 设为 true,工作线程却永远停不下来:

java
// JDK 21,HotSpot 默认参数
public class StopFlag {
    private static boolean stop; // 故意不加 volatile

    public static void main(String[] args) throws InterruptedException {
        Thread worker = new Thread(() -> {
            long spins = 0;
            while (!stop) {
                spins++;
            }
            System.out.println("worker exits after " + spins + " spins");
        });
        worker.start();

        TimeUnit.SECONDS.sleep(1);
        stop = true;
        System.out.println("main set stop = true");

        worker.join(TimeUnit.SECONDS.toMillis(3));
        System.out.println("worker alive after 3s: " + worker.isAlive());
        System.exit(0);
    }
}

实测三组结果(JDK 21.0.5,每种方式运行 3 次,结果相同):

运行方式worker alive after 3s
默认参数(JIT 开启)true,循环没有退出
java -Xint(只解释执行)false,正常退出
把字段改为 volatile boolean stopfalse,正常退出

这不是 JVM 的 bug。这段代码存在数据竞争(data race),Java 语言规范对它的结果不做任何可见性承诺,JIT 编译器把循环里的 stop 当成不变量读一次就合法。理解这件事,就理解了 JMM 要回答的问题。

一、先说结论 ​

  • JMM 不是运行时内存区域。 堆、栈、方法区回答的是「数据放在哪」;Java 内存模型(Java Memory Model,JMM)回答的是「多个线程读写共享变量时,哪些读必须看到哪些写」。它定义在 JLS 第 17.4 节。
  • 核心工具是 happens-before 关系。 如果操作 A happens-before 操作 B,那么 B 一定能观察到 A 的效果。它是一种语义上的偏序约束,不等同于墙上时钟的先后。
  • 没有 happens-before 关系的读写,结果不可推理。 物理上先写了,另一个线程也可能读到旧值,上面的例子就是证据。
  • 建立 happens-before 的手段:锁、volatile、线程启动与 join、final 字段的正确构造,以及 java.util.concurrent 中各组件文档化的保证。

二、happens-before 的规则 ​

java.util.concurrent 包文档的「Memory Consistency Properties」一节列出了语言层面的五条规则,外加一条传递性:

规则含义典型用途
程序顺序同一线程内,前面的动作 happens-before 后面的动作单线程推理
监视器锁对同一个监视器的解锁 happens-before 后续的加锁synchronized 保护的读写
volatile对 volatile 字段的写 happens-before 后续对它的读状态标记、引用发布
线程启动调用 start() happens-before 新线程中的任何动作启动前准备好的数据,新线程一定能看到
线程 join线程中的所有动作 happens-before 其他线程从 join() 成功返回等子线程算完再读结果
传递性A → B 且 B → C,则 A → C组合上面的规则

回到开头的例子:主线程写 stop 与工作线程读 stop 之间,上面哪一条都不满足,所以规范不要求读到 true。加上 volatile 后命中第三条,问题消失。

用一个 volatile 字段发布其他普通字段 ​

传递性经常被忽略,但它让下面这段代码是正确的:

java
final class ResultBox {
    private int result;               // 普通字段
    private volatile boolean ready;

    void produce() {                  // 只在一个线程里调用
        result = 42;                  // (1) 普通写
        ready = true;                 // (2) volatile 写
    }

    int consume() {                   // 在另一个线程里调用
        if (ready) {                  // (3) volatile 读
            return result;            // (4) 普通读
        }
        throw new IllegalStateException("not ready");
    }
}

推理链:(1) → (2) 是程序顺序;(2) → (3) 是 volatile 规则;(3) → (4) 是程序顺序。由传递性得到 (1) → (4),所以读到 ready == true 时一定能看到 42。

生产线程消费线程(1) result = 42普通写(2) ready = truevolatile 写(3) if (ready)volatile 读(4) return result普通读程序顺序volatile 规则程序顺序由传递性得到 (1) → (4):读到 ready 为 true,就一定能看到 42
图 1 · volatile 字段 ready 在两个线程之间建立同步点,普通字段 result 借助传递性被安全发布

这里 result 并没有「变成」volatile;是 ready 在两个线程之间建立了一个同步点。这个前提很重要:只有一个线程写。如果多个线程都会调用 produce(),写与写之间就没有保护了。

三、三个性质:原子性、可见性、有序性 ​

很多资料把并发问题归纳为三类,这个框架可以保留,但要知道每类由什么机制保证:

手段可见性(读到谁写的值)有序性(重排能否被观察到)原子性(复合操作能否被拆开)
锁✓✓✓
volatile✓✓(与该字段相关的部分)✗
Atomic*✓✓✓(仅限单个 CAS 操作)
final✓(正确构造后)✓不可变,无需原子性

volatile 那一行的 ✗ 最容易被忽略:

java
private volatile int count;

void increment() {
    count++; // 读、加一、写回三步,两个线程可能读到同一个旧值,丢失一次更新
}

可见性只保证「读到最近一次写」,不保证「读和写之间没人插队」。计数要用 AtomicLong、LongAdder 或锁。

有序性:双重检查锁为什么必须加 volatile ​

java
final class Config {
    private static volatile Config instance; // 去掉 volatile 就不再正确

    private final Map<String, String> values;

    private Config(Map<String, String> values) {
        this.values = Map.copyOf(values);
    }

    static Config get() {
        Config c = instance;                  // 只读一次 volatile 字段
        if (c == null) {
            synchronized (Config.class) {
                c = instance;
                if (c == null) {
                    instance = c = new Config(loadFromDisk());
                }
            }
        }
        return c;
    }
}

new Config(...) 在概念上包含「分配内存、执行构造、把引用赋给 instance」。没有 volatile 时,其他线程在锁外读 instance,与锁内的写之间没有 happens-before,规范允许它看到一个非 null、但字段还没初始化好的对象。

实际项目中更简单的写法是静态内部类持有者(初始化由类加载的锁保证),或者直接用 enum 单例,不需要自己推理。

四、final 与安全发布 ​

JLS 17.5 对 final 字段有额外保证:对象正确构造之后(构造期间 this 没有逸出),其他线程看到这个对象时,一定能看到 final 字段的最终值,即使发布路径本身没有同步。

这就是不可变对象适合并发共享的原因。但下面几种写法会破坏「正确构造」:

java
final class PriceListener {
    private final Map<String, BigDecimal> prices;

    PriceListener(EventBus bus) {
        bus.register(this);            // this 逸出:事件线程可能在构造完成前回调
        this.prices = new ConcurrentHashMap<>();
    }
}

构造函数里注册监听器、启动线程、把 this 放入全局容器,都是逸出。修复方式是把「构造」和「发布」拆开,比如用静态工厂方法:先 new,再 register。

还要注意 final 只管引用本身。final List<String> names = new ArrayList<>() 之后,多线程对这个 ArrayList 的读写仍然需要保护。

五、JMM 与硬件:别把教学模型当实现 ​

常见的「每个线程有工作内存,从主内存拷贝变量」是一个教学模型,它帮助理解「为什么会读到旧值」,但真实原因更多样:

  • JIT 优化:开头的例子里,编译器可以把循环条件外提,根本不再访问内存。
  • CPU 缓存与寄存器:值可能留在寄存器或核心私有缓存中。
  • 指令重排:处理器和编译器可以调整执行顺序,只要单线程可观察结果不变。

所以更准确的回答是:规范没有要求可见,实现就可以利用这一点做优化。至于「volatile 通过内存屏障立即刷回主存」这类说法,把 HotSpot 在某些平台上的实现细节当成了语义,了解即可,推理时依据的仍然是 happens-before。

六、工程里怎么落地 ​

优先不共享可变状态。 不可变对象、消息传递、ConcurrentHashMap 这类并发容器,已经把 happens-before 封装好了,比手写 volatile 协议可靠得多。

发布可变对象时显式选择一种手段:

场景推荐手段
开关、停止标记volatile boolean
定期整体替换的配置快照volatile 引用 + 不可变对象
计数、统计LongAdder / AtomicLong
多个字段需要一起变锁
延迟初始化单例静态持有者类或 enum
任务结果在线程间传递CompletableFuture / BlockingQueue

排查「偶现读到旧值」: 先确认读写之间有没有 happens-before,而不是怀疑 CPU 或 JVM。一个快速信号是:问题只在压测或运行一段时间(JIT 编译后)出现,本地调试(经常处于解释执行)复现不了。

测试并发正确性: 一次跑通不能证明语义正确。使用 JCStress 这类工具让线程交错成千上万次,并在不同 JIT 编译状态下反复运行。

七、常见误区 ​

  • 「JMM 就是堆和栈」:混淆了运行时数据区与并发内存语义。
  • 「物理上先写,后读一定能看到」:没有 happens-before 就没有保证,开头的实验就是反例。
  • 「volatile 能保证线程安全」:它保证可见性,不保证复合操作的原子性。
  • 「锁只保证原子性」:锁同时建立了可见性,这正是监视器锁规则的内容。
  • 「只读的字段不用管」:如果它在构造后被修改或通过逸出的 this 发布,就不是真正只读。

小结 ​

JMM 可以浓缩成一句话:多线程读写的正确性不取决于物理时间,而取决于读写之间有没有 happens-before 关系。锁、volatile、final、线程启动与 join,以及并发工具类,都是建立这种关系的手段;没有建立关系的代码,即使大多数时候跑对了,也只是运气。

往下深入,可以沿着 volatile、CAS 与 LongAdder 和 从 AQS 看 ReentrantLock 看同步手段怎样一层层建立在 happens-before 之上;落到线上,则关注三件事:对象怎么发布、有没有数据竞争、ThreadLocal 是否泄漏。线程在服务中的数量与隔离问题,见 线程池参数怎么定。


配套实验

参考资料

文章以 CC BY-NC-SA 4.0 授权 · 代码片段以 MIT 授权