文章目录
-
- [1. 问题与场景:多线程下为何出现「看不见、顺序乱」](#1. 问题与场景:多线程下为何出现「看不见、顺序乱」)
- [2. JMM 基础:主内存与本地内存的抽象](#2. JMM 基础:主内存与本地内存的抽象)
- [3. Happens-Before:约定「何时可见」](#3. Happens-Before:约定「何时可见」)
- [4. volatile:可见性与禁止重排](#4. volatile:可见性与禁止重排)
- [5. 管程与 synchronized:互斥与块内可见性](#5. 管程与 synchronized:互斥与块内可见性)
- [6. final 域:不可变对象的安全发布](#6. final 域:不可变对象的安全发布)
- [7. 实际开发与架构设计中的注意点](#7. 实际开发与架构设计中的注意点)
- 总结与参考链接
以库存扣减为例------多线程同时扣减同一库存时,会出现「扣完了别的线程还读到旧值」导致超卖,或「先判断库存再扣减」被重排成「先扣减再判断」导致逻辑错乱。根因是 CPU 缓存和指令重排:写操作未必立刻对其它线程可见,代码顺序也未必等于执行顺序。
JMM 用主内存 / 本地内存 抽象和 Happens-Before 规则约定「何时对另一线程可见」,再通过 volatile (如扣减完成标志)、synchronized (扣减块互斥)、final(库存上限等配置的安全发布)把规则落地。
核心就一句:先有「何时可见」的约定(Happens-Before),再用关键字和锁去实现,业务才能安全地写多线程共享变量。
下文按「现象与根因 → JMM 与 Happens-Before → volatile/synchronized/final 用法与边界 → 实际开发注意点」展开,文末总结与参考链接。
1. 问题与场景:多线程下为何出现「看不见、顺序乱」
多线程共享变量时,常出现两类现象:写完了别的线程看不见 (可见性)、代码顺序和执行顺序不一致(有序性),于是出现脏读、重复扣减、状态不一致等难以复现的 bug。
根因简要
- 可见性:CPU 有缓存,写往往先到缓存再异步刷主存,其他线程读到的可能是旧缓存。
- 有序性:编译器/CPU 会重排指令,在不改变单线程语义的前提下打乱顺序,多线程下可能把「先读后写」重排成「先写后读」,从而读到未初始化的值。
若没有统一规范,既无法判断「何时对另一线程可见」,也无法在优化与正确性之间做权衡。因此需要一套约定「何时可见」的规范,并让具体的关键字和锁去实现它------这正是下文第 2、3 节要讲的 JMM 与 Happens-Before,以及第 4、5、6 节的 volatile/synchronized/final;
2. JMM 基础:主内存与本地内存的抽象
要约定「何时对另一线程可见」,先要对「谁在哪儿、何时同步」有一层统一描述。
JMM 用「主内存 + 线程本地内存」的抽象来做到这一点:
- 共享变量在主内存,每个线程有一份「本地内存」(涵盖缓存、写缓冲区、寄存器以及编译器重排等);
- 读/写先与本地内存交互,再与主内存同步。
JMM 通过控制「主内存与各线程本地内存之间的交互时机」来提供内存可见性保证。

在这种抽象下,线程间通信被规范为两步:
① 线程 A 将本地内存中更新过的共享变量刷新到主内存 ;
② 线程 B 从主内存读取 A 已更新过的共享变量。
也就是说,可见性由「何时刷新、何时读」的规则决定------这套规则就是下一节的 Happens-Before。
3. Happens-Before:约定「何时可见」
上一节说到,可见性由「何时刷新、何时读」的规则决定,在 JMM 里这套规则就是 Happens-Before :若 A Happens-Before B ,则 A 的结果对 B 可见。它约束的是可见性顺序,而不是执行顺序------允许不相关操作重排,但不允许破坏 Happens-Before 关系,这样既满足业务对可见性的需求,又给编译器和 CPU 留出优化空间。
常用规则与使用场景
| 规则 | 含义 | 典型场景 |
|---|---|---|
| 程序顺序 | 同一线程内,前面的修改对后面可见 | 单线程内逻辑顺序可推理 |
| volatile 变量 | 对 volatile 的写对后续该变量的读可见 | 标志位、开关(如「x 已写好」用 v=true 表示) |
| 传递性 | A→B,B→C ⇒ A→C | 组合多条规则推导跨线程可见性 |
| 管程中锁 | 解锁 Happens-Before 后续对该锁的加锁 | synchronized 块内修改对下一进入该锁的线程可见 |
| 线程 start() | 主线程 start 前操作对子线程可见 | 传给子线程的初始参数、配置 |
| 线程 join() | 子线程结束对 join 返回后的主线程可见 | 子线程计算结果对主线程可见 |
| 线程中断 | interrupt() 先于被中断线程检测到中断 | 中断请求一定早于中断处理 |
示例:volatile 做可见性桥梁
java
class VolatileExample {
int x = 0;
volatile boolean v = false;
public void writer() {
x = 42; // 程序顺序:先于 v=true
v = true; // volatile 写:对后续读 v 可见
}
public void reader() {
if (v == true) { // 传递性 ⇒ x=42 对当前线程可见
// x 在 1.5+ 上等于 42
}
}
}

上面用 v 做「x 已写好」的信号:volatile 写与读建立 Happens-Before,再通过传递性把 x=42 的可见性带给 reader,无需锁整块代码。volatile 正是实现「对 volatile 变量的写对后续读可见」这条 Happens-Before 规则的关键字,下一节单独说它的语义与边界。
4. volatile:可见性与禁止重排
Happens-Before 里有一条是「对 volatile 的写对后续该变量的读可见」,volatile 关键字就是实现这条规则的手段。
语义
- 对该变量的读写 与主内存直接交互,不经过线程本地缓存(可见性)。
- 禁止对该变量相关操作的指令重排 (有序性)。
- 实现上通过读/写前后插入内存屏障保证。
适用场景
- 单变量状态、标志位,写一次、多线程尽快可见(如关闭开关、初始化完成标志)。
- 与 Happens-Before 配合做「可见性桥梁」(如上节示例)。
边界与注意
- 不保证原子性 :i++ 等复合操作仍需 synchronized 或原子类。
- 仅限本变量:只保证该变量本身及其前后顺序,不保证其他共享变量的可见性,需通过传递性组合规则。
当需求不只是「单变量可见」,而是「同一时刻只有一个线程执行某段代码」或「先检查再更新」这类复合操作时,就要用到 synchronized,见下一节。
5. 管程与 synchronized:互斥与块内可见性
volatile 只保证单变量可见性,不保证互斥和原子性。 需要互斥或「先读后改」的原子性时,要用 synchronized:它对应 Happens-Before 里的「管程中锁」规则------解锁 Happens-Before 后续对该锁的加锁。
语义
- 管程(Monitor) :通用同步原语;synchronized 是 Java 对管程的实现。
- JMM 规则:解锁 Happens-Before 后续对该锁的加锁 → 块内对共享变量的写,在释放锁后对下一个获得同一把锁的线程可见。
适用场景
- 需要互斥 且块内修改对下一进入者可见(如判断并更新库存、先检查再赋值)。
- 需要原子性的复合操作(如 i++、check-then-act)。
示例
java
synchronized (this) {
if (this.x < 12) {
this.x = 12;
}
}
// 解锁后,下一个获得 this 锁的线程能看到 x==12
还有一种常见需求是「构造完成后不再变的配置或引用,希望无锁地安全发布给多线程」------这由 final 配合 JMM 对 final 域的禁止重排规则来保证,见下一节。
6. final 域:不可变对象的安全发布
JMM 对 final 域做了特殊规定:禁止把对 final 域的写重排到构造函数 return 之外,从而在无锁的前提下实现「构造完成即对其它线程可见」的安全发布。
语义
- 基本类型 final:禁止把对 final 域的写重排到构造函数 return 之外(final 写之后、return 前插入 StoreStore 屏障)→ 对象发布时 final 已写好。
- 引用类型 final:构造函数内对 final 引用所指对象的写,不能与「把该引用赋给引用变量」重排 → 避免发布半初始化对象。
适用场景
- 构造完成后不再变的配置、常量、引用;希望构造完成后对所有线程立即可见且无需加锁。
- 不可变对象(final 基本类型、String、枚举、Long/Double/BigInteger/BigDecimal 等)一旦正确发布,可被多线程安全使用;AtomicInteger/AtomicLong 为可变,需按原子类语义使用。
7. 实际开发与架构设计中的注意点
前面几节分别讲了 JMM 与 Happens-Before 的约定,以及 volatile、synchronized、final 的语义与场景。实际开发中需要把这些串起来:什么时候选谁、各有什么边界、出问题时怎么排查。下面按选型、边界与排查三块简要梳理。
选型标准
| 诉求 | 选型 | 说明 |
|---|---|---|
| 单变量状态/标志位,仅需可见性 | volatile | 轻量,不保证复合操作原子性 |
| 需要互斥或复合操作原子性、块内修改对下一进入者可见 | synchronized | 锁粒度尽量小,避免全局锁 |
| 构造后不变、安全发布 | final + 正确构造 | 无锁发布,依赖 JMM 禁止重排 |
边界与局限
- volatile:不适用于 i++、check-then-act 等复合操作;多变量可见性需依赖 Happens-Before 传递性设计。
- synchronized:需明确锁对象(同一对象才互斥);注意锁顺序,避免死锁。
- final:仅保证构造完成时的可见性,构造后若通过其他引用修改对象内部状态,仍需其他同步手段。
问题排查思路
- 现象:多线程下偶尔读到旧值或未初始化值。
- 定位:先确认是否为可见性/有序性(是否未用 volatile/synchronized/final,或跨线程可见性未满足 Happens-Before);再结合 Thread Dump 看是否阻塞、死锁。
- 措施:按诉求补 volatile(单变量可见)、synchronized(互斥+可见)、或 final(安全发布);复核共享变量的读写是否都在同一锁或同一 Happens-Before 链上。
总结与参考链接
回到开头的库存扣减场景:要避免「扣完了别人还读到旧值」导致的超卖,以及「先判断再扣减」被重排导致的逻辑错乱,就需要先理解「何时对另一线程可见」(JMM 与 Happens-Before),再把约定落到代码里(volatile/synchronized/final)。全文要点如下。
总结
- 现象与根因:多线程下「看不见、顺序乱」来自 CPU 缓存与指令重排;JMM 用主内存/本地内存抽象和 Happens-Before 约定「何时可见」。
- 手段与边界:volatile 做单变量可见与禁止重排,不保证原子性;synchronized 做互斥与块内可见;final 做无锁安全发布。按诉求选型,注意各手段的边界与组合时的 Happens-Before 关系。
- 排查:先确认是否为可见性/有序性(未用或误用 volatile/synchronized/final),再结合 Thread Dump 看阻塞与死锁;按诉求补手段并复核 Happens-Before 链。
参考链接
| 主题 | 链接 |
|---|---|
| volatile 深入 | java 并发关键字:volatile 深入浅出 |
| synchronized 深入 | 关键字:synchronized 详解 |
| JMM 总览 | pdai - JMM |