Java 内存模型(JMM):可见性、有序性与安全发布
从原子性、可见性、有序性出发,系统理解 happens-before、volatile、final 字段语义和对象安全发布。
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,也不能让这组复合操作具备原子性。
常用保证方式包括:
synchronized或Lock- 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 主要提供两类保证:
- 对该变量的写入能够被后续读取它的线程看到。
- 禁止影响发布与读取语义的特定重排序。
可以将它理解为写端的 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 同时提供:
- 原子性:同一时刻只有持有同一把锁的线程进入临界区。
- 可见性:前一个线程解锁前的写入,对后续获得同一把锁的线程可见。
- 必要的有序性:锁边界限制会破坏同步语义的重排序。
对比来看:
| 能力 | volatile | synchronized |
|---|---|---|
| 可见性 | 保证 | 保证 |
| 特定有序性 | 保证 | 保证 |
| 复合操作原子性 | 不保证 | 保证临界区内的原子性 |
| 竞争时是否阻塞 | 不阻塞 | 可能阻塞 |
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 | 入队前的操作对成功出队后的线程可见 | 生产者—消费者 |
| AtomicReference | volatile 语义加 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 不逸出。对象安全发布后,如果还会发生并发修改,仍需要额外的线程安全措施。
复习检查清单
- 能否解释 JMM 与 JVM 内存区域的区别?
- 为什么
volatile int count; count++仍不安全? - happens-before 提供的是哪两类保证?
- volatile 写和读如何安全发布普通字段的写入?
- DCL 单例为什么必须使用 volatile?
- final 字段语义的前提为什么是
this不逸出? - 静态字段与 JVM 静态初始化有什么区别?
- 安全发布为什么不等于对象后续线程安全?
- 如何用不可变对象加 volatile 引用实现配置热更新?
- 多个写线程更新快照时为什么可能还需要 CAS 或锁?
评论与讨论
回复 :
留下你的想法