跳到正文

JMM 内存模型 ​

一句话:JMM 用 happens-before 给多线程程序定义「哪些写对哪些读可见、哪些重排不允许」,volatile 买的是可见性和有序性,不是原子性;双重检查锁必须靠它封住构造逸出。

适用 JDK 17 / JDK 21。本文只讲 JMM 的可见性、有序性和 DCL;锁的实现见 03-并发编程-AQS与锁,堆的分代布局见 01-JVM内存模型与GC。

为什么需要 ​

单线程里「先写后读」看起来天经地义,多线程里却不成立。原因有三层:CPU 有缓存,写可能还留在 store buffer;编译器和 CPU 会重排没有依赖的指令;对象构造也可能被重排。

如果没有统一的契约,同一份 Java 代码在不同 CPU、不同 JIT 版本上可能表现不同。JMM 就是这份契约:它不规定硬件怎么做,只规定程序能看到什么结果。

核心机制 ​

happens-before 的八条规则 ​

如果 A happens-before B,那么 A 的结果对 B 可见,且 A 在 B 之前发生。常用规则:

规则含义
程序顺序同一线程内,前面的操作先于后面的操作
监视器锁解锁先于后续对同一锁的加锁
volatile对 volatile 变量的写先于后续对它的读
线程启动Thread.start() 先于新线程中的任何操作
线程终止线程中的所有操作先于其他线程从 join() 返回
中断interrupt() 先于被中断线程检测到中断
终结器对象构造完成先于 finalize() 开始
传递性A 先于 B、B 先于 C,则 A 先于 C

happens-before 不等于「代码按时间顺序执行」。它只保证可见性和有序性;没有这条链的两个操作,即使墙上时钟一前一后,也不能假设对方能看到。

volatile 到底保证了什么 ​

  • 可见性:volatile 写会把值发布到主内存,volatile 读会从主内存重新读取;实现上通过内存屏障阻止读写重排。
  • 有序性:volatile 写之前的操作不能被重排到写之后,volatile 读之后的操作不能被重排到读之前。
  • 不保证原子性:count++ 是读、加、写三步,两个线程各加一次仍可能只加一次。计数用 AtomicInteger、LongAdder 或锁。

volatile 适合「一个线程写、多个线程读」的状态标志、配置快照、DCL 的实例引用。它不适合需要「读-改-写」的复合操作。

DCL 为什么必须加 volatile ​

错误写法:

java
private static Singleton instance;
public static Singleton getInstance() {
    if (instance == null) {
        synchronized (Singleton.class) {
            if (instance == null) instance = new Singleton();
        }
    }
    return instance;
}

new Singleton() 在字节码层面至少分三步:分配内存、调用构造器初始化、把引用赋给 instance。编译器和 CPU 可以把「赋值」提前到「初始化」之前。线程 A 执行到赋值后被切走,线程 B 在第一次检查时看到 instance != null,直接返回一个字段还没初始化完的对象。

正确写法:

java
public class Singleton {
    private static volatile Singleton instance;
    private Singleton() {}

    public static Singleton getInstance() {
        if (instance == null) {
            synchronized (Singleton.class) {
                if (instance == null) instance = new Singleton();
            }
        }
        return instance;
    }

    public static void main(String[] args) {
        System.out.println(getInstance() != null); // true
    }
}

volatile 把「构造完成」和「引用发布」之间建立了 happens-before,B 要么看到 null 继续等锁,要么看到完整构造的对象。

LongAdder 不是 AtomicLong 的全面替代 ​

AtomicLong 所有线程 CAS 同一个变量,竞争激烈时大量失败重试。LongAdder 继承 Striped64,维护一个 base 和一组 Cell,不同线程更新不同 Cell,求和时把 base 和所有 Cell 加起来。

代价是:

  • sum() 不是原子快照,求和期间其他线程仍在更新,返回值是近似值。
  • 内存占用比单个 AtomicLong 高。
  • 依赖 sum() 做条件判断、生成严格递增序列、实现 CAS 语义时,LongAdder 不适用。

它适合统计计数、监控指标这类「高并发写、偶尔读总数」的场景,不适合 ID 生成和需要精确比较的计数器。

最小上手 ​

bash
javac --release 17 Singleton.java && java Singleton
# true

把 volatile 去掉,程序通常仍会打印 true,因为重排不是每次都发生;这正是并发 bug 难复现的原因。验证不能靠「跑几次没出问题」,要用 jcstress 这类专门工具构造交错。

生产动作 ​

  • 状态标志用 volatile:volatile boolean running 配合 while (running) 可以安全停止线程;但停止后仍需 join() 等线程退出。
  • 复合操作加锁或原子类:i++、check-then-act、put-if-absent 不要只用 volatile;用 AtomicInteger、ConcurrentHashMap.compute 或 synchronized。
  • 安全发布:对象构造完成后通过 volatile 字段、锁、并发容器或 final 字段发布;不要把 this 在构造器中传给其他线程。
  • final 字段:正确构造且没有 this 逸出时,final 字段的可见性由 JMM 保证,不需要额外 volatile;但 DCL 的引用本身仍建议 volatile,避免其他非 final 状态被提前看到。
  • 并发测试:用 jcstress 写交错测试,或用 -XX:+UnlockDiagnosticVMOptions -XX:+PrintAssembly 观察屏障;不要用 Thread.sleep 碰运气。
  • 不要用 System.out 证明可见性:println 内部有同步,可能掩盖问题;验证要基于共享变量和明确的同步点。

边界 ​

  • JMM 不是 JVM 内存布局:JMM 讲的是多线程可见性,堆/栈/元空间讲的是运行时区域,两者不是一回事。
  • volatile 不是轻量锁:它不提供互斥,两个线程同时 count++ 仍会丢更新。
  • happens-before 不是全序:没有同步关系的操作可以任意交错,不能靠「先启动线程」推断顺序。
  • final 的安全发布有前提:构造器不能把 this 逸出,否则 final 语义可能失效。
  • LongAdder 的 sum 不是精确值:需要强一致计数时用 AtomicLong 或加锁。

讲清楚 ​

JMM 的核心是 happens-before:程序顺序、锁、volatile、线程启动/终止、中断、终结器和传递性,保证有关系的操作之间可见且有序。volatile 提供可见性和有序性,但不提供原子性,所以 i++ 仍然要用原子类或锁。

DCL 的错误不是「锁没加」,而是对象引用可能先于构造完成被看到;volatile 在构造和发布之间建立 happens-before,才让第一次无锁检查安全。LongAdder 用多个 Cell 分散 CAS 竞争,适合高并发统计,但 sum() 是弱一致快照,不能拿来做严格条件判断。

把一门技术讲透到既懂也会用