Appearance
JMM:happens-before / 安全发布
在总览中的位置:Java 并发模型总览 §5
相关主题:synchronized / Lock / volatile · Atomic* / CAS / LongAdder
一句话定位
JMM(Java Memory Model)定义的是:在编译器优化、CPU 重排序、缓存层次都存在时,Java 多线程之间哪些写入对哪些读取一定可见、哪些执行顺序一定成立;happens-before 是推理这件事的核心语言,安全发布则是它在工程里的高频落点。
代码索引
| 主题 | Lab 说明 | 源码 |
|---|---|---|
volatile 可见性、非原子自增反例 | volatile-vs-atomic-vs-lock | VolatileVsAtomicVsLock.java |
| DCL 单例与安全发布 | singleton-dcl | SingletonDcl.java |
submit() / Future.get() 结果回收链路 | thread-pool-basics | ThreadPoolBasics.java |
为什么需要
很多并发 bug 代码看起来并不“复杂”,却会在线上偶发:
- 一个配置对象明明已经初始化了,别的线程偶尔还是读到旧值
- 单例实例非空了,里面某些字段却是默认值
- 主线程改了停止开关,工作线程却像没看见一样继续跑
- 某个 Future 已经完成,调用方却不知道什么时候可以安全读取结果
这些问题如果只靠“我觉得应该按这个顺序执行”是解释不通的。JMM 的作用就是给出一条更硬的规则:
如果线程 A 的写入和线程 B 的读取之间没有建立明确的 happens-before,就不要假设 B 一定能及时、完整、按你想象的顺序看到 A 的结果。
底层机制
1. JMM 关心的三件事:可见性、原子性、有序性
可见性(Visibility)
一个线程写入共享变量后,另一个线程什么时候能看到?
- 没有同步边界时,读线程可能长期读到旧值
volatile、锁、线程启动/结束、Future 完成等同步点都能建立可见性边界
原子性(Atomicity)
一个操作是否会被观察到中间状态?
count++不是原子- 多字段一起更新更不是原子
- 原子性通常靠锁或 CAS 获得
有序性(Ordering)
代码写在前面,不代表别的线程一定按这个顺序观察到它。
- 编译器和 CPU 会重排序
- 单线程语义没变不代表多线程语义安全
- happens-before 的存在,才让“顺序”变得可推理
2. 必须记住的 happens-before 规则
| 规则 | 你可以怎么记 |
|---|---|
| 线程内程序顺序规则 | 同一线程里,前面的操作先于后面的操作 |
| 监视器锁规则 | 对同一把锁,先解锁 happens-before 后加锁 |
volatile 变量规则 | 对同一变量,先写 happens-before 后读 |
| 线程启动规则 | Thread.start() 之前的写,对新线程可见 |
| 线程终止规则 | 线程内操作 happens-before 其他线程从 join() 成功返回 |
| Future 完成规则 | 异步任务完成的结果,在 Future.get() 成功返回后对调用方可见 |
| 类初始化规则 | 类初始化完成后,静态初始化结果对后续线程可见 |
这张表真正有用的地方在于排障:
- “我 sleep 了 100ms,按理说另一个线程应该看到了吧?” —— 不算同步规则。
- “我把对象丢进并发容器了,别人再取出来是否算安全发布?” —— 通常可以,因为容器内部建立了同步边界。
3. 安全发布:别人不能看到半初始化对象
“安全发布”关心的不是对象有没有 new 出来,而是:
- 这个对象的引用是否已经跨线程可见
- 别的线程拿到引用时,对象内部状态是否也已经初始化完整
常见安全发布方式:
| 方式 | 典型场景 | 备注 |
|---|---|---|
| 静态初始化 / 静态内部类 / enum | 单例、全局常量 | JVM 类初始化天然带同步语义 |
volatile 引用整体替换 | 配置快照、DCL 单例 | 适合“整体替换,不在发布后乱改内部字段” |
| 锁保护后发布 | 复杂对象初始化后放入共享区 | 发布和读取都要遵守同一同步边界 |
| 并发容器发布 | 放入 ConcurrentHashMap / 阻塞队列再读取 | 容器内部同步语义帮你建立边界 |
| 不可变对象 + 无 this 逃逸 | 配置对象、值对象 | 前提是构造期间别把自己提前暴露出去 |
4. DCL 为什么依赖 volatile
对应 Lab:singleton-dcl
错误核心不在“可能创建多次”这么简单,而在于:
java
class Singleton {
private static volatile Singleton instance;
static Singleton getInstance() {
if (instance == null) {
synchronized (Singleton.class) {
if (instance == null) {
instance = new Singleton();
}
}
}
return instance;
}
}如果没有 volatile,new Singleton() 相关动作可能被重排成:
- 分配内存
- 把引用写到
instance - 再慢慢执行构造初始化
这样别的线程就可能在第 2 步后看见一个“非空但未初始化完成”的对象。
5. final 字段、不可变对象与 this 逃逸
JMM 里常见的第二个误区是:
- “字段加了
final,对象一定安全发布” —— 不完整。
更准确地说:
final字段有更强的初始化安全语义- 但前提是对象在构造期间没有
this逃逸
危险反例:
java
class BadConfig {
static BadConfig latest;
final String token;
BadConfig() {
latest = this; // ❌ 构造期间提前发布
token = loadToken();
}
}这种写法会破坏你以为的安全边界。
Android / Flutter / Web / Backend 对照
| 维度 | JMM / happens-before | Android | Flutter | Web | Backend |
|---|---|---|---|---|---|
| 核心问题 | 共享内存下何时可见、何时有序 | 主线程与后台线程共享状态常见 | isolate 默认不共享内存,少直接面对 JMM | JS 主线程单线程,少显式讨论 | 共享缓存、线程池、并发容器天天遇到 |
| 高频场景 | 标志位、单例、配置快照、结果回收 | 页面退出取消、后台配置刷新、单例管理器 | 主要通过消息传递规避 | 主要通过事件循环规避 | 单例、缓存、异步聚合、连接池状态 |
| 最大误区 | 把“感觉应该可见”当成同步保证 | 用 sleep 替代同步 | 把 isolate 思维强套到 Java | 把 Promise 顺序当共享内存可见性 | 忽视安全发布导致偶发脏读 |
常见场景
1. Android 全局配置快照热更新
java
final class AppConfig {
final String baseUrl;
final int timeoutMs;
AppConfig(String baseUrl, int timeoutMs) {
this.baseUrl = baseUrl;
this.timeoutMs = timeoutMs;
}
}
private volatile AppConfig currentConfig = new AppConfig("https://a.example", 3000);
void reload(AppConfig fresh) {
currentConfig = fresh;
}为什么这类写法比“共享一个可变对象到处改字段”更稳:
- 发布边界清晰:整体替换引用
- 读线程只读快照
- 更容易推理 happens-before
2. 后台任务结果回收
java
Future<User> future = pool.submit(() -> userRepository.load(uid));
User user = future.get();这里除了“拿到返回值”之外,还建立了一个重要事实:
- 任务线程里对结果对象的初始化,在
get()成功返回后对调用方可见
这就是为什么 Future 不只是“值的容器”。
3. 单例 / SDK 管理器初始化
- 需要懒加载时:DCL +
volatile或静态内部类 - 不需要复杂延迟加载时:优先静态初始化 / enum / DI 容器
- Android 上单例若持有
Context,必须转成applicationContext;这和 JMM 是两层问题,别混在一起
4. 并发容器中的安全发布
当对象放进 ConcurrentHashMap 或阻塞队列再被其他线程取出时,通常可以把这次“进容器 -> 出容器”当作一次同步边界,但前提仍然是:
- 对象自身初始化已经完成
- 不是发布后又在其他线程偷偷改内部字段
常见坑
| 坑 | 现象 | 修法 |
|---|---|---|
用 Thread.sleep() 当同步手段 | 本地偶尔“好像好了”,线上仍偶发脏读 | 用锁、volatile、Future.get()、并发容器等真实同步边界 |
DCL 漏掉 volatile | 单例偶发半初始化 | DCL 必须配 volatile,或换更稳的单例方式 |
| 发布的是可变对象,发布后还继续改内部字段 | 别的线程读到时序不一致状态 | 优先不可变对象或在同一同步边界内修改 |
构造函数里让 this 提前逃逸 | final 字段语义也救不了 | 构造完成前不要把 this 暴露出去 |
| 以为“放到线程池里执行了”就自动线程安全 | 结果、状态、发布边界仍可能有洞 | 继续明确状态归属与同步规则 |
与相近概念对比
| 概念 | 解决什么 | 不解决什么 |
|---|---|---|
volatile | 某个变量的可见性与重排序约束 | 复合操作原子性、多字段一致性 |
final | 构造完成后的初始化安全语义 | 构造期间 this 逃逸、发布后继续乱改 |
synchronized / Lock | 建立互斥与 happens-before | 自动让锁外的共享状态都安全 |
| 并发容器 | 发布 / 获取边界、单次操作线程安全 | 对象内部后续修改的整体一致性 |
| 安全发布 | 让“别人拿到引用时对象已经完整” | 代替所有业务并发控制 |
对应实验
| Lab | 说明 | 源码 |
|---|---|---|
| volatile-vs-atomic-vs-lock | volatile-vs-atomic-vs-lock | VolatileVsAtomicVsLock.java |
| singleton-dcl | singleton-dcl | SingletonDcl.java |
| thread-pool-basics | thread-pool-basics | ThreadPoolBasics.java |
当前仍缺:happens-before 规则可视化专属实验、构造期间
this逃逸反例、Future 结果可见性专门演示。
复习检查题
为什么“我让线程 A 先
sleep(100),线程 B 再读”不能算可靠同步?答:因为时间延迟不是 JMM 认可的同步规则。
sleep()只是在调度层面让线程暂停,不会自动建立写入和读取之间的 happens-before 关系,所以线上仍可能读到旧值或不完整状态。happens-before 最值得背下来的两条规则是什么?
答:第一,对同一把锁的“先解锁后加锁”;第二,对同一
volatile变量的“先写后读”。这两条是日常解释可见性和有序性的最常用工具。为什么 DCL 单例不是“只要双重
if就完事”?答:因为它真正要防的是对象创建时的重排序。没有
volatile,别的线程可能在对象尚未初始化完成时就看到非空引用,所以双重if本身不够,必须配合volatile做安全发布。安全发布和线程安全是同一个概念吗?
答:不是。安全发布关注“第一次把对象交给别人时是否完整可见”;线程安全还要继续关心对象发布之后被多个线程并发访问、修改时是否仍然正确。
Future.get()在 JMM 视角下除了“拿结果”还有什么意义?答:它还建立了一个结果可见性边界:异步任务线程对结果对象的写入,在
get()成功返回后对调用方可见,所以它不仅是 API 语法点,也是 happens-before 的落地点。
速记
- 没有 happens-before,就别脑补“应该看得见”。
- JMM 复习先抓三件事:可见性、原子性、有序性。
- 安全发布关心的是:别人拿到引用时,对象是不是已经完整。
- DCL 的关键不是双重
if,而是volatile。