并发编程

Java 内存模型(JMM):可见性、有序性与安全发布

从原子性、可见性、有序性出发,系统理解 happens-before、volatile、final 字段语义和对象安全发布。

难度:深入更新:2026-07-15

Java 内存模型(Java Memory Model,JMM)描述的是多线程如何正确读写共享变量。它规定了什么样的执行结果是合法的,以及程序需要通过哪些同步机制建立线程间的可见性与顺序保证。

一句话概括:

JMM 解决的不是“数据放在哪里”,而是“一个线程写入的数据,另一个线程何时、以什么顺序能够看到”。

JMM 不是 JVM 运行时内存区域

这两个概念名称相似,但关注的问题完全不同。

概念关注点典型内容
JVM 运行时内存区域数据存放在哪里堆、虚拟机栈、方法区、程序计数器
Java 内存模型 JMM多线程如何访问共享数据原子性、可见性、有序性、happens-before

JMM 中的“主内存”和“工作内存”也是规范层面的抽象。可以借助 CPU 缓存、寄存器和编译器临时值来帮助理解,但不能把它们机械地等同于 Java 堆或某一级 CPU Cache。

为什么需要 JMM

现代 JVM 和处理器会使用缓存、寄存器、指令重排序等手段提高性能。只要单线程结果不变,编译器和 CPU 就可以调整部分操作的执行方式;但在缺少同步的多线程程序中,其他线程可能观察到旧值或不符合源码直觉的顺序。

class Switch {
    private boolean ready = false;
    private int value = 0;

    void write() {
        value = 42;
        ready = true;
    }

    void read() {
        if (ready) {
            System.out.println(value);
        }
    }
}

如果没有任何同步措施,读线程不一定及时看到 ready = true;即使看到了 ready,也不能仅凭源码顺序推断它一定看到了 value = 42。JMM 通过同步动作和 happens-before 规则为这种跨线程通信建立保证。

并发正确性的三个维度

原子性

一个操作不可分割,要么完整执行,要么不执行。

count++;

它至少包含读取、加一、写回三个步骤,多个线程并发执行时可能丢失更新。即使把 count 声明为 volatile,也不能让这组复合操作具备原子性。

常用保证方式包括:

  • synchronizedLock
  • CAS 和原子类,如 AtomicInteger
  • 将共享状态封装到线程安全的数据结构中

可见性

一个线程修改共享变量后,其他线程能否及时看到修改结果。

常见的可见性保证来自:

  • volatile 读写
  • 同一把锁的释放和获取
  • Thread.start()Thread.join() 等线程生命周期规则
  • 并发容器和线程安全队列提供的内存一致性语义

有序性

编译器和处理器可以在不改变单线程结果的前提下重排序,这就是 as-if-serial 语义。并发程序需要通过 volatile、锁等机制约束那些会影响跨线程观察结果的重排序。

happens-before:判断可见性的核心规则

如果操作 A happens-before 操作 B,那么 A 的结果必须对 B 可见,并且 JMM 保证 A 在 B 之前有序。

它是一种内存可见性和顺序保证,不应简单理解成现实时间上的“先发生”。

常见规则如下:

规则含义
程序次序规则单线程内,按程序顺序位于前面的操作 happens-before 后面的操作
监视器锁规则对一把锁的解锁 happens-before 后续对同一把锁的加锁
volatile 规则对一个 volatile 变量的写 happens-before 后续对它的读
线程启动规则调用 start() 前的操作 happens-before 新线程中的操作
线程终止规则线程内的操作 happens-before 其他线程从 join() 成功返回
中断规则调用 interrupt() happens-before 目标线程检测到中断
传递性A happens-before B,B happens-before C,则 A happens-before C

例如:

int data = 0;
volatile boolean ready = false;

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

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

data = 42 位于 volatile 写之前;线程 B 读取到 ready == true 后,借助程序次序、volatile 规则和传递性,也能看到此前对 data 的写入。

volatile 的能力与边界

volatile 主要提供两类保证:

  1. 对该变量的写入能够被后续读取它的线程看到。
  2. 禁止影响发布与读取语义的特定重排序。

可以将它理解为写端的 release 和读端的 acquire:

线程 A 的普通写
       ↓
volatile 写(发布)
       ↓ happens-before
volatile 读(获取)
       ↓
线程 B 的普通读

volatile 不提供复合操作的互斥性:

volatile int count = 0;
count++; // 仍然不是线程安全的

适合 volatile 的典型场景是状态标志、不可变快照的引用和读多写少的整体配置切换。

DCL 单例为什么需要 volatile

public final 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;
    }
}

对象创建可以近似看作分配内存、初始化对象、赋值引用。缺少 volatile 时,引用赋值可能被其他线程观察到,而对象初始化结果尚未通过正确的同步关系对它可见。volatile 既限制关键重排序,也安全发布了构造完成的引用。

synchronized 提供什么保证

synchronized 同时提供:

  • 原子性:同一时刻只有持有同一把锁的线程进入临界区。
  • 可见性:前一个线程解锁前的写入,对后续获得同一把锁的线程可见。
  • 必要的有序性:锁边界限制会破坏同步语义的重排序。

对比来看:

能力volatilesynchronized
可见性保证保证
特定有序性保证保证
复合操作原子性不保证保证临界区内的原子性
竞争时是否阻塞不阻塞可能阻塞

final 字段的内存语义

final 不只是“赋值后不能修改”。JMM 还为正确构造对象的 final 字段提供特殊初始化保证:构造器中对 final 字段的写入,不能被重排到对象引用发布之后。

final class Config {
    private final int timeout;
    private final String endpoint;

    Config(int timeout, String endpoint) {
        this.timeout = timeout;
        this.endpoint = endpoint;
    }
}

只要对象在构造过程中没有逸出,其他线程获得该对象后,能看到 final 字段的正确初始化值。

构造期间不要让 this 逸出

下面的写法会在对象尚未构造完成时把 this 暴露出去:

class Listener {
    static Listener shared;
    final int value;

    Listener() {
        shared = this; // this 提前逸出
        value = 42;
    }
}

常见的构造期间逸出还包括:

  • 在构造器中注册监听器并传入 this
  • 在构造器中启动访问当前对象的新线程
  • this 放入静态集合或共享容器
  • 在构造器中调用可能被子类重写并暴露状态的方法

final 引用不等于对象不可变

final List<String> names = new ArrayList<>();
names.add("Alice"); // 合法

final 只保证 names 不能重新指向另一个 List,并不保证 List 内部状态不可修改,更不保证多个线程并发修改安全。构建不可变对象还需要避免修改入口,并对可变集合进行防御性复制。

什么是安全发布

安全发布是指:一个线程创建对象后,通过明确的同步机制把它交给其他线程,使其他线程既能看到对象引用,也能看到对象完整初始化后的状态。

常见方式包括:

方式建立保证的原因适用场景
静态初始化JVM 保证类初始化串行完成,并对后续使用可见固定单例、常量配置
volatile 引用volatile 写 happens-before 后续读配置热更新、不可变快照切换
synchronized / Lock同一把锁的释放 happens-before 后续获取对象发布并伴随复合读写
并发容器容器操作提供相应的内存一致性保证跨线程共享和查找对象
BlockingQueue入队前的操作对成功出队后的线程可见生产者—消费者
AtomicReferencevolatile 语义加 CAS需要条件更新的对象快照

静态字段不等于静态初始化

下面只是运行期间对共享静态字段的一次普通写入,并不天然安全:

class ConfigHolder {
    static Config config;

    static void init() {
        config = new Config();
    }
}

真正依赖 JVM 类初始化进行安全发布的是字段初始化表达式或 static 代码块:

class ConfigHolder {
    static final Config CONFIG = new Config();
}

二者的区别不在于“字段是否为 static”,而在于赋值是否发生在 JVM 的类初始化方法 <clinit> 中。

volatile 动态发布:构造后整体替换

对于运行期配置更新,一个实用模式是:先完整构造不可变对象,最后一步写入 volatile 引用

public final class ConfigCenter {
    private static volatile Config current =
            new Config(3000, 2);

    public static Config current() {
        return current;
    }

    public static void reload(int timeout, int retries) {
        Config next = new Config(timeout, retries);
        current = next; // 最后一步发布
    }

    public record Config(int timeout, int retries) {}
}

读线程每次先取得一次快照:

ConfigCenter.Config snapshot = ConfigCenter.current();
use(snapshot.timeout(), snapshot.retries());

这样读线程看到的要么是完整的旧配置,要么是完整的新配置,不会看到一个被逐字段修改的中间状态。

错误方式是先发布引用,再继续修改对象:

config = new Config(); // 已经发布
config.setTimeout(3000);
config.setEndpoint("https://example.com");

volatile 修饰的是引用,并不会让对象内部后续修改自动具有原子性。多个写线程如果需要避免相互覆盖,仍要使用锁、AtomicReference.compareAndSet 或版本号。

安全发布不等于后续线程安全

安全发布只保证对象交给其他线程的那一刻,初始化状态完整可见。如果发布后对象仍然可变,后续并发读写仍需使用锁、volatile 字段、原子类、并发容器,或者改为不可变对象整体替换。

需要区分三件事:

正确构造:对象内部初始化完整,构造期间 this 不逸出
安全发布:通过 happens-before 把完整对象交给其他线程
后续线程安全:发布后的每一次共享状态修改都有并发保护

常见误区

“JMM 就是堆、栈、方法区”

错误。那是 JVM 运行时内存区域;JMM 是并发访问共享变量的规则。

“volatile 能保证线程安全”

不准确。它保证可见性和特定有序性,不能让 count++ 等复合操作原子化。

“final 对象天然不可变、线程安全”

错误。final 引用不能重新赋值,但引用指向的对象仍可能被修改。

“字段是 static,所以写入就是安全发布”

错误。普通静态方法中的赋值仍然是共享变量的普通写;只有类初始化、volatile、锁等机制才能建立明确的发布保证。

“CPU 有缓存一致性协议,所以不需要 JMM”

错误。缓存一致性不能单独覆盖 Store Buffer、编译器与 CPU 重排序、JVM 优化和不同硬件内存模型差异。JMM 为 Java 程序提供跨平台的统一语义。

面试回答模板

JMM 是 Java 并发访问共享变量的规范,核心解决原子性、可见性和有序性问题。它通过 happens-before 规则定义一个线程的写入何时必须对另一个线程可见。volatile 写与后续读、同一把锁的释放与获取、线程 start 和 join 都能建立 happens-before。volatile 适合状态标志和不可变对象引用的安全发布,但不能保证复合操作原子性;synchronized 则同时提供临界区原子性、可见性和必要的有序性。final 字段具有特殊初始化语义,但前提是构造期间 this 不逸出。对象安全发布后,如果还会发生并发修改,仍需要额外的线程安全措施。

复习检查清单

  1. 能否解释 JMM 与 JVM 内存区域的区别?
  2. 为什么 volatile int count; count++ 仍不安全?
  3. happens-before 提供的是哪两类保证?
  4. volatile 写和读如何安全发布普通字段的写入?
  5. DCL 单例为什么必须使用 volatile?
  6. final 字段语义的前提为什么是 this 不逸出?
  7. 静态字段与 JVM 静态初始化有什么区别?
  8. 安全发布为什么不等于对象后续线程安全?
  9. 如何用不可变对象加 volatile 引用实现配置热更新?
  10. 多个写线程更新快照时为什么可能还需要 CAS 或锁?

参考资料

DISCUSSION

评论与讨论

留下你的想法