第 30 章 JMM、volatile 与 Synchronized
同一段并发代码在 x86 上正确、在 ARM 上出错,说明"正确性"不取决于机器而取决于规约,本章讲这份规约与实现它的两把工具。
学习目标
读完本章,你应当能够:
- 辨析 JMM 规约的三件事(原子性、可见性、有序性),说出重排序的三种来源与
as-if-serial的边界; - 逐条解释
happens-before八条规则的含义与用途,说清它与"墙上时间先后"的本质区别; - 推出四种内存屏障的约束关系,说明
volatile读写各插什么屏障、x86 上为什么只剩StoreLoad; - 完整推导双重检查锁定(double-checked locking, DCL)单例为什么必须加
volatile,并写出两种正确写法; - 画出 64 位 Mark Word 在四种锁状态下的位域图,说清锁升级每一级的触发条件与成本;
- 推导
ThreadLocal内存泄漏的引用链,解释弱引用为何救不了 value、为什么线程池下必须remove; - 用"不可变 → 无状态 → 同步"的判断流程回答"这段代码要不要加锁"。
前置知识检查
| 你需要知道 | 在哪一节补齐 |
|---|---|
| 线程与进程的本质区别 | 见第 4 章;本章只讲内存可见性 |
原子指令与 CAS 的硬件实现 | 见第 6 章;本章 30.4、30.11 节只讲 Java 侧语义封装 |
多核缓存一致性与 MESI | 见第 1、6 章;本章只用它解释"写为何要立即对其他核可见" |
| JVM 运行时数据区与栈帧结构 | 见第 32 章 32.2 节;本章只用到"锁记录在栈上"这一事实 |
30.1 为什么需要 JMM:一份"允许看到什么"的规约
30.1.1 同一段代码为什么在不同平台行为不同
并发程序的行为由三层共同决定:编译器优化的重排 → CPU 乱序执行的发射顺序 → 多核缓存与写缓冲的传播顺序。这三层在 x86 与 ARM 上的宽松程度完全不同,于是同一份字节码跑出不同结果。
| 平台 | 内存模型 | 允许的重排序 | 屏障指令 |
|---|---|---|---|
| x86-64 | 强序 TSO(total store order) | 只允许 Store 之后的 Load 提前(StoreLoad) | mfence、lock 前缀指令 |
| ARMv8 | 弱序(weak memory model) | 四类重排基本都允许 | dmb ish / dmb ishst / dmb ishld |
| JMM | 抽象规约 | 不承诺任何具体重排,只承诺 happens-before | 由 JVM 翻译为目标平台的真实屏障 |
注意
x86 的强序来自两条硬件事实:写缓冲(Store Buffer)让写不立即进 cache,失效队列(invalidate queue)让 Load 可能越过未完成的 Store,所以 StoreLoad 是唯一必须显式拦的一类。ARM 连 Store-Store 都不保证,同样的 volatile 写要翻译成更重的屏障。
30.1.2 JMM 到底规约了什么
JMM(Java Memory Model)定义在 JLS 第 17 章,它不描述硬件怎么执行,而是规定一次合法执行允许读到哪些值:输入是程序中所有动作(读、写、锁、volatile 读写、线程启停);规则是① 每个读看到的值必须来自某个"允许的"写,而不是"最新的"写,② 筛选"允许的写"的唯一条件就是 happens-before,③ 禁止因果循环(out-of-thin-air);输出是一个或多个"合法执行",读到的值可以不止一种组合。正确性的举证责任在程序员一侧:只要程序里有一个不受 happens-before 约束的数据竞争(data race),JMM 就允许它读到任意"陈旧但合法"的值,甚至允许出现真实硬件上不会出现的组合。
小心
答"JMM 保证了硬件不会重排序"是方向性错误。JMM 只保证遵守 happens-before 的程序得到确定的可见性结果;它不禁止硬件重排,而是反过来要求 JVM 插入足够的屏障来"补上"被硬件吃掉的那部分顺序。
30.2 三条性质与重排序
30.2.1 原子性:哪些操作天然原子
| 操作 | 原子性 | 依据与坑 |
|---|---|---|
除 long/double 外的 32 位及以下基本类型、引用类型的读写 | 原子 | JLS 17.6;但引用读写各算一次,两次之间无顺序保证 |
long/double 非 volatile 读写 | 规范不保证 | JLS 允许拆成两次 32 位访问 |
volatile 的 long/double | 原子 | JLS 17.7 明确要求 |
i++、i += 1 | 非原子 | 字节码是 getstatic → iconst_1 → iadd → putstatic 四步 |
synchronized 块内 | 由锁保证 | 互斥性使临界区整体成为一个原子单位 |
警告
long 的"半写"在 64 位 HotSpot 上实际不会发生(64 位对齐读写天然原子),但 JLS 允许它发生。面试正确答法是:“规范层面不保证,HotSpot 64 位实现上实际原子,所以不能依赖规范不保证的东西”——这句分层表述比背"long 不安全"更值钱。
30.2.2 重排序的三种类型与 as-if-serial
可见性(visibility) 指一个线程的写何时、以何种顺序对另一个线程的读可见,物理障碍是写缓冲与失效队列。有序性(ordering) 指动作允许以什么顺序被观察到,只在跨线程视角下有讨论意义。
| 类型 | 发生在哪 | 典型例子 | 拦截手段 |
|---|---|---|---|
| 编译器重排序 | 源码→字节码 / 字节码→机器码 | JIT 调换两个无依赖的写;循环不变量读提到循环外 | volatile、synchronized、final、VarHandle 的 acquire/release |
| 指令级并行重排序 | CPU 乱序执行引擎 | 无依赖指令同拍发射;写缓冲让 Store 延后可见 | 硬件屏障(mfence/dmb) |
| 内存系统重排序 | cache 与写缓冲 | 核 A 已写 x 但核 B 仍读到旧值,先写的 y 反而先可见 | 缓存一致性协议 + 屏障 |
as-if-serial 语义:无论怎么重排,单线程程序的执行结果必须与严格顺序执行一致。它给了编译器和 CPU 极大自由度,代价是这份保证只在单线程内成立。于是并发 bug 的特征就很清楚:单线程下永远正确,只在多线程交错时出错,这正是它难以复现的原因。经典反例是 a = 0; flag = false; 时线程 A 写 a = 1; flag = true;、线程 B 轮询 while (!flag) {} 后读 a:单线程视角 A 的两行看似有序、r 应为 1,但 A 与 B 之间没有 happens-before,flag 的写可能先于 a 的写可见,于是 B 读到 flag = true 而 a = 0——把 flag 声明为 volatile 即可修复(见 30.3 节)。
30.3 happens-before 八条规则
happens-before 是 JMM 的核心关系,记作 hb(A, B),含义是 A 的结果对 B 可见,且 A 在 B 之前被观察到。它不是"时间上先发生",而是"允许被看到的顺序"的许可。
| # | 规则 | 含义 | 用途 |
|---|---|---|---|
| 1 | 程序顺序规则 | 同一线程内,前面的动作 hb 后面的动作 | 单线程内无需同步即可看到自己的写 |
| 2 | 监视器锁规则 | 对同一把锁的 unlock hb 后续的 lock | synchronized 同时给出互斥与可见性 |
| 3 | volatile 变量规则 | 对 volatile 字段的写 hb 后续对它的读 | 建立"一写多读"的轻量发布/订阅 |
| 4 | 传递性(transitivity) | hb(A,B) 且 hb(B,C) ⟹ hb(A,C) | 让"锁→volatile→锁"这类链条串起来 |
| 5 | Thread.start() 规则 | start() 之前的动作 hb 新线程的第一条动作 | 新线程能看到构造它时写入的字段 |
| 6 | Thread.join() 规则 | 被 join 线程的所有动作 hb join() 成功返回 | join 返回后可安全读取子线程结果 |
| 7 | 线程中断规则 | interrupt() 的调用 hb 被中断线程检测到中断 | 保证 isInterrupted() 能看到标志 |
| 8 | 对象终结规则 | 对象构造完成 hb 其 finalize() 开始 | 终结器能看到已初始化对象的状态(机制已废弃) |
重要
happens-before 与"时间先后"的区别必须一句话说清:hb 是可见性保证,不是时间顺序保证。两个动作在物理时间上完全可能乱序执行;hb 只保证"若 A hb B,则 B 一定看得到 A 的效果"。反过来,“A 在墙钟上早于 B”不产生任何 hb,这正是 30.2.2 那个例子的病根。
规则 3 与规则 4 联动是最常被追问的组合:T1 写 x = 42 再写 v = true(v 为 volatile),T2 读到 v == true 再写 b = x,则由规则 1、3、4 得 x = 42 hb b = x——用 volatile 发布一个对象,其内部所有普通字段也跟着安全发布,这是"不可变对象 + 一次性发布"模式的全部依据。
30.4 内存屏障的四种类型与插入策略
30.4.1 四种屏障的约束关系
| 屏障 | 约束 | 直觉 | 相对成本 |
|---|---|---|---|
LoadLoad | 禁止前后两个 Load 重排 | 先读完前一个再读后一个 | 低(x86 通常无需指令) |
StoreStore | 禁止前后两个 Store 重排 | 前一个写必须先全局可见 | 低(x86 通常无需指令) |
LoadStore | 禁止 Load 与它后面的 Store 重排 | 读不能跑到写后面去 | 低 |
StoreLoad | 禁止 Store 与它后面的 Load 重排 | 写必须全系统可见后才能继续读 | 最贵:排空写缓冲,x86 上是 mfence 或 lock 前缀 |
StoreLoad 最贵的原因是它要求"全局可见"这一强条件:其他三类只约束本核流水线与本核视角的顺序,StoreLoad 却要等到写缓冲清空、所有核都承认这次写,后续 Load 才能发射。一次 mfence 在 x86 上约 20~30 个时钟周期,接近其余三类之和。
30.4.2 volatile 写读的屏障插入位置
| 操作 | 前置屏障 | 后置屏障 | 一句话记忆 |
|---|---|---|---|
volatile 写 | StoreStore | StoreLoad | 写之前把前面的写都推出去,写之后不许抢跑 |
volatile 读 | LoadLoad | LoadStore | 读之后不许把后面的读写提前 |
| 普通读写 | 无 | 无 | 无任何约束 |
| |
30.4.3 x86 上为什么只剩 StoreLoad
x86 的 TSO 本身保证 LoadLoad、StoreStore、LoadStore 三类顺序,只有 StoreLoad 会重排,因此 HotSpot 的 x86 后端把 volatile 写编译成一条带 lock 前缀的指令(如 lock addl $0x0, (%rsp),效果等价于 mfence 且更短),而 volatile 读不需要额外指令——只需从内存重新加载而不能复用寄存器。
注意
这解释了一个常见疑问:“为什么 volatile 读看起来没有开销?“x86 上确实几乎没有;ARM 上读要插 dmb ishld、写要插 dmb ish,两边代价差一个量级。JDK 9 之后更推荐用 VarHandle 的 getAcquire/setRelease(见 30.11.2 节)表达"我只需要单向屏障”,它比 volatile 少一半屏障。
30.5 volatile 的两条语义与不保证原子性
volatile 的全部语义只有两条。语义一,可见性:对 volatile 字段的写会立即对其他核可见,其他线程的后续读必然读到本次写及其之前的所有普通写。语义二,禁止重排序:由 30.4.2 的屏障组合实现,它让 volatile 写之前的所有普通写与这个 volatile 写构成一个整体发布单元。
小心
volatile 不保证原子性。i++ 是"读—改—写"三步,volatile 只保证每一步的读与写各自原子且有序,不保证三步合起来原子。两个线程各做 1 万次 i++,结果大概率小于 2 万。需要复合原子性只有三条路:synchronized、AtomicInteger(CAS 循环)、LongAdder(分段累加)。
30.5.1 DCL 单例为什么必须加 volatile
对象创建在字节码层面展开为三步:① 分配内存并得到引用 ② 执行构造器初始化字段 ③ 把引用赋给静态字段。其中 ② 与 ③ 之间没有数据依赖,编译器与 CPU 都可以把它们重排为 ① → ③ → ②。第 ① 步分配出来的内存是"全零"的,所以重排后其他线程看到的引用非 null 但字段全是零值。
| |
| |
注意 value 是 final:final 域本身有安全发布语义(见 30.9 节),但它保护的是"字段写完先于构造器返回”,与 volatile 保护的"引用赋值不被提前暴露"不是同一件事,所以两者都要有。
提示
更优写法是静态内部类持有(initialization-on-demand holder):私有的静态内部类 Holder 里放一个 static final HolderSingleton INSTANCE = new HolderSingleton();,getInstance() 直接返回它。它利用 JVM 类初始化的同步机制(<clinit> 由 JVM 保证只执行一次、其他线程阻塞等待),既天然延迟加载(类只在首次主动使用时初始化)又不需要 volatile 与 synchronized。单元素 enum 更简洁,还额外获得反序列化安全与反射攻击防护,Effective Java 首推它;代价是不能继承其他类。
30.6 synchronized 的字节码、锁对象与 Mark Word
30.6.1 字节码层面发生了什么
| |
为什么编译器要复制一遍 monitorexit:JVM 要求 monitorenter 与 monitorexit 数量严格配对,且释放必须发生在任何退出路径上。编译器于是用 Exception table 的 any 类型兜住全部异常并补上释放。这也意味着 synchronized 不会因异常泄漏锁,而手工 Lock 必须写在 finally 里。同步方法没有 monitorenter,而是在方法 access_flags 上置 ACC_SYNCHRONIZED,由 JVM 在方法调用与返回时隐式加解锁。
30.6.2 锁的是哪个对象
| 写法 | 锁对象 | 需要注意 |
|---|---|---|
synchronized (obj) { } | obj 这个实例 | 必须是所有线程共享的同一引用;synchronized (new Object()) 毫无意义 |
synchronized (this) / 实例同步方法 | 当前实例 | 不同实例之间不互斥;单例上等价于全局锁 |
static 同步方法 | 该类的 Class 对象 | 与实例方法锁不是同一把锁,两者可以并发 |
synchronized (SomeConst) | 那个常量对象 | 锁 String 字面量、Integer 缓存对象极危险:会被无关代码共享 |
警告
常见错误是"同步方法锁的是方法"。锁的永远是对象,方法只决定了隐含对象是 this 还是 Class。另一个高频坑是锁对象被替换:synchronized (lock) 中若 lock 是非 final 字段且在临界区内被重新赋值,其他线程就锁到了新对象,互斥直接失效。
30.6.3 Mark Word 位域图与锁状态
HotSpot 把锁状态与运行时元数据塞进对象头的 Mark Word(64 位 JVM 下 8 字节)。它的内容随锁状态整体改变含义,这是理解锁升级的前提。
| |
lock 两位是状态机的选择子(01 无锁/偏向、00 轻量级、10 重量级、11 GC 标记);biased_lock 位区分同为 01 的"无锁"与"偏向"。偏向锁状态下 54 位线程 ID 占了原 hashCode 的位置,这就是"调用 hashCode()(或 System.identityHashCode)会撤销偏向锁“的原因——两者抢同一块位域。
30.7 锁升级路径
| |
| 状态 | Mark Word 内容 | 获取成本 | 触发/退出条件 |
|---|---|---|---|
| 无锁 | hash + age + 01 | 0 | 初始状态 |
| 偏向锁 | 线程 ID + epoch + 01 | 一次 CAS 写入自身线程 ID | 只有一个线程反复进入;第二个线程尝试获取即触发撤销 |
| 轻量级锁 | 栈上 Lock Record 指针 + 00 | 一次 CAS + 若干次自旋 | 有竞争但临界区极短;自旋失败或竞争加剧即膨胀 |
| 重量级锁 | ObjectMonitor 指针 + 10 | 内核互斥量,可能阻塞线程 | 竞争激烈或临界区长;解锁后唤醒等待者 |
30.7.1 偏向锁、轻量级锁与自旋
偏向锁由 JDK 6 引入、-XX:+UseBiasedLocking 控制,它赌的是大多数锁在整个生命周期内只被一个线程持有,于是把 CAS 成本也省掉:第一次获取用一次 CAS 把线程 ID 写进 Mark Word,此后该线程再进入只需比对 ID,连 CAS 都不用。代价在撤销:撤销偏向锁必须把线程停在安全点(safepoint),检查它是否还在临界区内,再改写 Mark Word——这是一次 STW,虽然通常只有微秒级,但"为省一次 CAS 而引入 STW"在偏向锁命中率很低(大量使用 java.util.concurrent)的代码里就不划算了。
注意
JDK 15 起 -XX:+UseBiasedLocking 默认关闭(JEP 374),并在后续版本移除了该实现。所以答"锁升级一定经过偏向锁"并不准确:默认配置下现在是"无锁 → 轻量级 → 重量级"三态,偏向锁属于"历史上存在、需要知道为什么被移除"的知识点。
轻量级锁的核心动作是"把 Mark Word 搬走”:① 在当前线程的栈帧里分配 Lock Record,把 Mark Word 当前值复制进它的 displaced_header 字段;② 用 CAS 把对象头的 Mark Word 改成"指向这个 Lock Record 的指针"。成功即加锁成功、锁状态位变 00;失败说明有竞争,先判断是否自己重入(是则 displaced_header 置 null、重入计数 +1),否则自旋,自旋用尽即膨胀为重量级锁。
| |
解锁就是把 displaced_header 反向 CAS 回对象头;若 CAS 失败,说明等待期间已膨胀为重量级锁,必须走重量级解锁路径(唤醒 _EntryList 中的线程)。
自旋(spin)的判据很简单:临界区执行时间 < 一次线程挂起/唤醒的成本时,忙等比阻塞划算。 数量级对比是:一次上下文切换约 1~5 μs,而一次空循环极便宜,所以只要临界区能在微秒级内结束,自旋就有正收益。-XX:PreBlockSpin(JDK 6 之前)是固定自旋次数、默认 10,问题是固定次数无法适应不同临界区;JDK 6 起改为自适应自旋(adaptive spinning):由 JVM 根据同一个锁上最近一次自旋的成功率动态决定——上次成功则允许更久,上次失败则直接跳过自旋去阻塞。于是 -XX:PreBlockSpin 实际失效(参数仍在,通常不生效)。
锁粗化(lock coarsening) 指 JIT 发现一串相邻操作反复对同一对象加解锁(典型是循环体内的 synchronized,或 StringBuffer.append 连续调用)时,把锁扩展到整个操作序列外,判断依据是"加解锁本身的开销大于合并后增加的临界区持有时间"。锁消除(lock elision) 基于逃逸分析(escape analysis):若 JIT 证明锁对象不会逃逸出当前线程(如方法内 new 出的 StringBuffer 且未被返回或存入共享字段),该锁不可能是共享资源,直接删除。这与栈上分配、标量替换(见第 32 章 32.2 节)是同一份逃逸分析结果的三个用途。
30.8 ObjectMonitor 与 wait/notify 的队列迁移
| |
重要
wait 必须在 synchronized 块内,否则抛 IllegalMonitorStateException。两条理由缺一不可:(1) wait 操作的是对象监视器,必须先成为 _owner 才有权操作它的 _WaitSet;(2) 更本质的是为了消除丢失唤醒(lost wakeup)的竞态——判断条件(如队列非空)与进入等待必须原子,否则"判断为假 → 尚未 wait 时通知先到达 → 通知丢失 → 永久等待"。
wait 会释放锁:它要停下来等别人改变条件,握着锁别人就进不了临界区、也就无法 notify(典型死锁)。这是 wait 与 sleep 最关键的差别:sleep/park 都不释放锁。另外被唤醒后线程变 BLOCKED 重新抢锁,中间条件可能已被改回不满足,且 JVM 允许虚假唤醒(spurious wakeup),所以规范写法是 while (!condition) { lock.wait(); } 而非 if。
30.9 final 域的安全发布语义
JLS 17.5 规定:对象构造器执行完毕时,如果没有对 this 的引用逃逸,那么 final 字段对其他线程可见,且看到的值至少是构造器里写入的值。 实现手段是在 final 字段的写之后、构造器返回之前插入 StoreStore 屏障,禁止"把对象引用赋给共享变量"这一写越过 final 字段的写。
| |
对不可变对象的意义:若一个类的字段全为 final 且指向的对象本身不可变(如 String),那么构造完成后的对象天然线程安全、无需任何同步即可安全发布。这就是不可变对象(immutable object)成为并发设计第一选择的根本原因——它把可见性问题在构造阶段一次性解决,之后不再需要锁。
警告
final 语义有前提,被追问时必须说全:(1) 构造器内不能泄漏 this(注册到监听器、存入静态集合、传给会回读字段的方法),否则其他线程能在字段赋值完成前拿到引用;(2) final 只保护它自己那个字段的写,构造器里对非 final 字段的写没有安全发布保证(上面例子的 y = 2 就不受保护);(3) 反序列化产生的对象不走构造器,final 语义不覆盖。
30.10 ThreadLocal 的完整机制与内存泄漏根因
30.10.1 前提:ThreadLocalMap 是 Thread 的字段
理解 ThreadLocal 的一切都要从这句话开始:ThreadLocalMap 挂在 Thread 对象上,而不是挂在 ThreadLocal 对象上。
| |
重要
为什么 key 用弱引用、value 用强引用? key 用弱引用是为了给"忘记 remove“提供兜底:当外部再也无法访问该 ThreadLocal 时自动断开 key。value 若也用弱引用,则任何一次 GC 都能清掉正常业务值,语义直接崩掉。所以弱引用只加在 key 上,代价就是 value 泄漏,这个代价由 get/set/remove 的探测式清理来偿还。
30.10.2 探测式清理与 remove 的必要性
ThreadLocalMap 用开放定址法(线性探测)解决冲突,key 为 null 的 Entry 即"陈旧条目(stale entry)"。三个入口都会顺手清理:
| 方法 | 是否清理 | 清理策略 |
|---|---|---|
get() | 是 | 探测到 key 相同者返回 value;途中遇到 key == null 则调用 expungeStaleEntry |
set() | 是 | 写入前做启发式清理:至少扫 log2(阈值) 个槽,污染度高则全表扫描 |
remove() | 是 | 定位后先把 value 置 null,再调 expungeStaleEntry 断开 Entry |
expungeStaleEntry 的关键动作有两步:把 value 置 null(真正让对象可回收),以及把该槽之后连续非空段里的有效 Entry 重新散列到正确位置——开放定址法的探测链断了以后,后面的元素会查找不到,必须搬移。
小心
“有 expungeStaleEntry 兜底,所以不用显式 remove“是最危险的错误结论。清理只在同一线程后续访问同一个 ThreadLocalMap 时被动触发,且只覆盖探测路径上的局部区域。线程池场景下线程被复用、业务代码却不再碰这个 ThreadLocal 时,清理永不发生。必须 try { ... } finally { tl.remove(); },尤其在把数据库连接、大对象、SimpleDateFormat 这类资源放进 ThreadLocal 时。
30.10.3 InheritableThreadLocal 与 TransmittableThreadLocal
| 类型 | 传递方向 | 快照时机 | 失效场景 |
|---|---|---|---|
ThreadLocal | 不传递 | — | 子线程读不到父线程的值 |
InheritableThreadLocal | 父 → 子 | new Thread() 时在 Thread.init 中复制 | 线程池复用线程:副本在首次创建时固定,后续提交的任务不会刷新 |
TransmittableThreadLocal | 提交方 → 执行线程 | 任务提交时抓快照、执行前回放、执行后还原 | 需要包裹线程池(TtlExecutors)或其增强线程池 |
三者的本质差别是快照的抓取时机:InheritableThreadLocal 在"线程创建"时抓,而线程池里线程创建与任务提交是解耦的,所以必然失效;TTL 把时机改到"任务提交时”,才对得上线程池的语义。
30.11 线程安全的三个层次与 VarHandle
30.11.1 “这段代码要不要加锁"的判断流程
| |
判据的核心是复合操作:if (map.get(k) == null) map.put(k, v) 即使 map 是 ConcurrentHashMap 也不安全,因为"检查"与"动作"之间不原子,必须用 putIfAbsent/computeIfAbsent 或加锁。
30.11.2 Unsafe 与 VarHandle 的低层语义
| 方法族 | 语义 | 屏障强度 | 适用 |
|---|---|---|---|
compareAndSet | 原子比较并交换 | 兼具 volatile 读写语义(full fence) | 无锁结构、计数器、AQS |
getAcquire / setRelease | 单向语义:acquire 读、release 写 | release ≈ 前置 StoreStore+LoadStore;acquire ≈ 后置 LoadLoad+LoadStore | 发布/订阅,比 volatile 少一半屏障 |
getOpaque / setOpaque | 不重排,但不保证跨线程可见 | 强于 plain、弱于 acquire/release | 抑制编译器优化(热循环里反复读字段) |
getPlain / setPlain | 普通读写 | 无 | 显式表达"这里不需要内存语义” |
JDK 9 起 Unsafe 的内存访问方法由 VarHandle 取代,后者在编译期做类型检查、对 JIT 更友好,其相对 olatile 的价值在于可以只声明单向屏障。
| |
本章小结
| 关键结论 | 一句话表述 | 常见错误 |
|---|---|---|
| JMM 的定位 | 它规约"允许看到什么”,不描述硬件怎么执行 | 答"JMM 禁止了硬件重排序” |
| 重排序三类 | 编译器、指令级并行、内存系统各重排一次 | 只答"CPU 乱序执行"一种 |
happens-before | 是可见性保证,不是墙上时间顺序 | 用"代码写在前面"论证可见性 |
| 规则用法 | 规则 3 + 规则 4 是发布对象的常用组合 | 只背八条却不会用它推可见性 |
| 屏障 | 写 StoreStore+StoreLoad;读 LoadLoad+LoadStore | 答"读写各插一个屏障" |
volatile 原子性 | 复合操作不原子,i++ 必须加锁或原子类 | 答"volatile 是轻量级锁" |
| DCL | 必须 volatile,否则可能拿到半初始化对象 | 说"不加只是性能差一点" |
| 锁对象 | 同步方法锁 this 或 Class,二者不互斥 | 答"锁的是方法或代码块" |
| Mark Word | 内容随锁状态整体改变,hashCode 与偏向锁抢位域 | 答"对象头固定存哈希值" |
| 轻量级锁 | CAS 把 Mark Word 指向栈上 Lock Record | 答"轻量级锁就是不阻塞的锁" |
wait | 必须在 synchronized 内、释放锁、用 while 包裹 | 答"wait 是让出 CPU 的 sleep" |
自测题
Q1(辨析)"volatile 就是轻量级的 synchronized“这句话对吗?请给出判据,并举一个 volatile 不够用的最小例子。
答案与解析
**只有一半对。** 相同点是两者都提供可见性、都禁止重排序。**关键差别只有一条:`volatile` 不提供互斥**,因此不提供原子性。判据是"操作是否单步、能否用一个 `volatile` 写完整表达状态":能替代的是单写多读的状态标志、DCL 的引用发布、"写值 + 置标志"的发布模式;不能替代的是 `i++`、check-then-act、需保护多字段不变量的场景。 最小反例:`volatile int i` 上两个线程各做 1 万次 `i++`,结果小于 2 万。根因是 `i++` 的字节码为 `getstatic → iconst_1 → iadd → putstatic` 四步,`volatile` 只让 `getstatic`/`putstatic` 各自带屏障,**两个 `putstatic` 之间仍可交错**,于是丢失更新。修法是 `synchronized` 或 `AtomicInteger.incrementAndGet()`(`CAS` 循环重试)。 **面试官会追问**:(1) `volatile` 读在 x86 上几乎没有开销,只需重新从内存加载,写才要带 `lock` 前缀的指令(见 30.4.3 节)。(2) `AtomicInteger` 原子是因为 `CAS` 指令的"比较"与"写入"在硬件上不可分割,且带全屏障语义(见 30.11.2 节)。答案与解析
**缺少规则 3(`volatile` 变量规则)**:`ready` 只是普通字段,两线程之间不存在任何 `hb`,交错发生在两层——JIT 可调换 A 中两行无依赖的写;ARM 不保证 Store-Store 顺序,`ready = true` 可能先于 `data = 42` 对其他核可见,B 读到 `ready == true` 时 `data` 仍是 0。x86 因 TSO 保证 Store-Store 顺序所以"通常能对",这正是"同一段代码在不同平台行为不同"的样本。 **修法**:把 `ready` 声明为 `volatile`。写侧插 `StoreStore`(保证 `data = 42` 先可见)+ `StoreLoad`(禁止写抢跑),读侧插 `LoadLoad`(后续读 `data` 不能提前)。于是由规则 1、3、4 得 `data = 42 hb ready = true hb if(ready)`,输出必然是 42。 **面试官会追问**:只给 `data` 加 `volatile` 不行,关键是**标志位**必须带 `volatile`;两个字段都用 `AtomicInteger` 可以但浪费(原子类方法是 full fence);更省的做法是发布侧 `setRelease(ready)`、消费侧 `getAcquire(ready)`,比 `volatile` 少一半屏障(见 30.11.2 节)。答案与解析
**反驳一(字节码)**:`new` 在字节码里是三条指令——`new`(分配并零值初始化)、`invokespecial答案与解析
**引用链**:`Thread`(线程池核心线程,存活期几乎等于进程)→ 强引用 `threadLocals` 字段 → `ThreadLocalMap` → `Entry[]` → `Entry` → **`value`(强引用)→ 那 1 MB 的 `byte[]`**;而 `Entry` 对 `ThreadLocal` 对象本身只有**弱引用**(`Entry extends WeakReference答案与解析
**为什么不安全**:`ConcurrentHashMap` 只保证**单个方法调用**原子。这里"判断存在性"与"写入"是两次独立调用,中间其他线程可以插入写入,于是 `load(k)` 可能被多个线程重复执行。若 `load` 有副作用或代价高,就是实打实的损失;`size()`/`isEmpty()` 之类在并发下也只是估计值,不能当判据。**三种修复**:(1) **`computeIfAbsent(k, this::load)`**:整段由 map 内部按桶加锁,最简洁;代价是**同一个桶上的其他 key 会被阻塞**,`load` 很慢会拖住该桶,且 `load` 内部递归操作同一 map 会抛异常。(2) **`putIfAbsent(k, load(k))`**:语义是"不存在才放",但参数 `load(k)` **先被求值**,重复加载仍会发生,只在 `load` 便宜时可用。(3) **双检 + 锁**:`V v = map.get(k); if (v == null) { synchronized (lock) { v = map.get(k); if (v == null) { v = load(k); map.put(k, v); } } }`,把重复加载压到最小,代价是引入外部锁、不同 key 之间也会互相阻塞。 **用 `synchronized` 的注意事项与追问**:(a) 锁对象必须 `final` 且所有访问路径共用同一个(不要用 `this`,除非能保证单实例);(b) **临界区内不要做 I/O 或耗时计算**,`load(k)` 要查库就放到锁外或用"占位 + 异步填充";(c) `synchronized` 没有超时,若 `load` 可能永久阻塞,应改用 `ReentrantLock.tryLock`。`computeIfAbsent` 的坑是同一线程递归修改同一 map 会抛 `IllegalStateException`,在其内部访问**另一个** map 也可能形成锁顺序环;判断抖动究竟来自锁竞争还是 `load` 本身,必须看 `jstack` 里 `BLOCKED` 线程数与火焰图中 `load` 占比,不要凭猜测改代码。面试高频问题与陷阱
| 面试问法 | 考查角度 | 陷阱 / 加分点 | 对应本章小节 |
|---|---|---|---|
| 既然硬件已有内存模型,Java 为什么还要自己定一套 JMM?它规定了什么? | JMM 的抽象层次 | 错答"为了禁止硬件重排序”。加分点:JMM 是"允许读到哪些值"的合同,举证责任在程序员;x86 是 TSO、ARM 是弱序,JVM 负责把 happens-before 翻译成各自平台的屏障 | 30.1 |
happens-before 的八条规则各是什么?它和时间上的"先发生"是一回事吗? | 八条规则的记忆与语义 | 错答"就是时间上先发生",或用"代码写在前面"论证可见性。加分点:它是可见性保证;并把规则 3 + 传递性组合成"用 volatile 发布对象、其内部普通字段也安全发布" | 30.3 |
volatile 到底做了什么?它在 x86 和 ARM 上编译出的指令一样吗? | 屏障插入策略与平台差异 | 错答"volatile 就是把值写回主存"或"读写各插一个屏障"。加分点:写 = StoreStore+StoreLoad,读 = LoadLoad+LoadStore;x86 只剩 StoreLoad(lock 前缀),读侧零指令 | 30.4 |
volatile 能保证 i++ 线程安全吗?为什么不能? | 原子性与互斥的区分 | 错答"能,因为 volatile 保证了可见性"。加分点:i++ 是 getstatic → iconst_1 → iadd → putstatic 四步,volatile 只保证每步各自语义,两个 putstatic 之间仍可交错(丢失更新) | 30.5 |
双重检查锁定单例为什么必须加 volatile?不加会怎样? | 对象创建三步与 2/3 重排 | 错答"不加只是性能差一点"或"new 是原子操作不会出问题"。加分点:new → invokespecial <init> → putstatic 三步,后两步无依赖可重排,其他线程可能读到字段为默认值的半初始化对象 | 30.5.1 |
synchronized 锁的到底是什么?修饰实例方法和静态方法锁的是同一把锁吗? | 锁对象的确定 | 错答"锁的是方法"或"锁的是代码块"。加分点:实例方法锁 this、静态方法锁 Class 对象,二者不互斥;并指出锁 String 字面量或 Integer 缓存对象的危险 | 30.6.2 |
| 说说对象头里的 Mark Word,以及锁升级的完整路径与每级成本。 | Mark Word 位域与锁升级状态机 | 错答"锁升级一定经过偏向锁"(JDK 15 起默认禁用并已移除),或说不出 lock 两位的取值含义。加分点:画位域图,说明 hashCode 与偏向锁线程 ID 抢同一块位域、撤销偏向锁要 STW 到安全点 | 30.6.3、30.7 |
ThreadLocal 会内存泄漏吗?为什么 Entry 的 key 用弱引用、value 用强引用? | 引用链与清理时机 | 错答"用了弱引用所以不会泄漏"或"有 expungeStaleEntry 兜底所以不用 remove"。加分点:画出 Thread → ThreadLocalMap → Entry → value 强引用链;清理只在同线程再次访问该 map 时发生,线程池下必须 try/finally remove | 30.10 |
延伸阅读
- JLS 第 17 章 Threads and Locks:17.4 内存模型(
happens-before与因果性要求)、17.5final字段语义、17.6 字的拆分、17.7volatile的 64 位原子性。 - JEP 374: Deprecate and Disable Biased Locking:偏向锁被默认关闭的理由与后续移除计划。
- OpenJDK 源码:
src/hotspot/share/runtime/synchronizer.cpp(ObjectMonitor、fast_enter/slow_enter、ObjectWaiter在_cxq/_EntryList/_WaitSet间的迁移)、src/hotspot/share/oops/markWord.hpp(Mark Word 位域与lock位取值)、src/hotspot/share/opto/下的屏障节点与LockCoarsening、EliminateLocks优化。-java.lang.invoke.VarHandle官方 API 文档(getAcquire/setRelease/getOpaque的语义与compareAndSet的屏障强度)、ThreadLocal/InheritableThreadLocal文档与ThreadLocalMap的expungeStaleEntry实现。- 见第 6 章(硬件层原子指令与内存屏障)、第 29 章(ConcurrentHashMap与并发容器演进)、第 31 章(AQS、ReentrantLock与线程池)、第 32 章 32.2 节(逃逸分析、栈上分配与标量替换)。