同一段并发代码在 x86 上正确、在 ARM 上出错,说明"正确性"不取决于机器而取决于规约,本章讲这份规约与实现它的两把工具。

学习目标

读完本章,你应当能够:

  1. 辨析 JMM 规约的三件事(原子性、可见性、有序性),说出重排序的三种来源与 as-if-serial 的边界;
  2. 逐条解释 happens-before 八条规则的含义与用途,说清它与"墙上时间先后"的本质区别;
  3. 推出四种内存屏障的约束关系,说明 volatile 读写各插什么屏障、x86 上为什么只剩 StoreLoad;
  4. 完整推导双重检查锁定(double-checked locking, DCL)单例为什么必须加 volatile,并写出两种正确写法;
  5. 画出 64 位 Mark Word 在四种锁状态下的位域图,说清锁升级每一级的触发条件与成本;
  6. 推导 ThreadLocal 内存泄漏的引用链,解释弱引用为何救不了 value、为什么线程池下必须 remove;
  7. 用"不可变 → 无状态 → 同步"的判断流程回答"这段代码要不要加锁"。

前置知识检查

你需要知道在哪一节补齐
线程与进程的本质区别见第 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 后续的 locksynchronized 同时给出互斥与可见性
3volatile 变量规则对 volatile 字段的写 hb 后续对它的读建立"一写多读"的轻量发布/订阅
4传递性(transitivity)hb(A,B) 且 hb(B,C) ⟹ hb(A,C)让"锁→volatile→锁"这类链条串起来
5Thread.start() 规则start() 之前的动作 hb 新线程的第一条动作新线程能看到构造它时写入的字段
6Thread.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 写StoreStoreStoreLoad写之前把前面的写都推出去,写之后不许抢跑
volatile 读LoadLoadLoadStore读之后不许把后面的读写提前
普通读写无无无任何约束
1
2
3
4
5
  volatile 写 = 前 StoreStore + 后 StoreLoad
   [StoreStore] ← 保证 v 之前的普通写(如 x = 42)都已进入可见路径
      store v = true        ← volatile 写本身
   [StoreLoad]  ← 禁止 v 之后的读提前,排空写缓冲,代价最高
  ⟹ A 线程 x=42; v=true 与 B 线程 if(v) use(x) 之间形成 hb(A, B)

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 但字段全是零值。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
  没有 volatile 时可能出现的交错(② 与 ③ 重排)
  ── 线程 A(构造) ──────    ── 线程 B(首次取用) ──────
  ① instance = 分配内存(零值)
  ③ INSTANCE = instance(提前赋值!)
                              ④ 检查 INSTANCE != null → 通过
                              ⑤ 使用 obj.field → 读到 0 / null ★
  ② 执行构造器,把 field 赋真值
  ⟹ 根因:B 的④⑤与 A 的①②③之间没有 happens-before
  ⟹ 若 INSTANCE 是 volatile:③ 是 volatile 写、④ 是 volatile 读,
     由规则 3 + 4 得①②都 hb ⑤,B 必然看到已完成初始化的对象
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
public final class SafeSingleton {
    // volatile 让"引用赋值"与"构造器内的字段写"之间建立 happens-before,
    // 否则第二次检查可能看到引用非 null、字段却还是零值的半初始化对象
    private static volatile SafeSingleton instance;
    public static SafeSingleton getInstance() {
        if (instance == null) {                 // 第一次检查:常态下不进临界区,省掉每次同步开销
            synchronized (SafeSingleton.class) {
                if (instance == null) {         // 第二次检查:并发进入时只有第一个线程该构造
                    instance = new SafeSingleton();
                }
            }
        }
        return instance;
    }
}

注意 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 字节码层面发生了什么

1
2
3
  字节码骨架:astore_1(锁对象存入局部变量槽,供异常路径复用)→ monitorenter →
  临界区 → monitorexit → goto;异常处理器入口再 aload_1 → monitorexit → athrow
  Exception table: from 4 to 13, target 16, type any ← 覆盖所有异常

为什么编译器要复制一遍 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 字节)。它的内容随锁状态整体改变含义,这是理解锁升级的前提。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
  64 位 Mark Word 的四种状态(bit 从右往左编号,右侧为低位)
  无锁(Normal,biased_lock=0):
    | unused:25 | identity_hashcode:31 | unused:1 | age:4 | biased_lock:1 | lock:2 = 01
  偏向锁(Biased):
    | thread_id:54 | epoch:2 | unused:1 | age:4 | biased_lock:1 | lock:2 = 01
  轻量级锁: | 指向栈中 Lock Record 的指针:62 | lock:2 = 00
  重量级锁: | 指向 ObjectMonitor 的指针:62  | lock:2 = 10
  GC 标记:  | 空(转发指针或占位)            | lock:2 = 11
  对象头布局: | Mark Word (8 字节) | Klass Pointer (8 字节) |
    ← 开 -XX:+UseCompressedOops 时 Klass 压到 4 字节;故普通对象至少 16 字节

lock 两位是状态机的选择子(01 无锁/偏向、00 轻量级、10 重量级、11 GC 标记);biased_lock 位区分同为 01 的"无锁"与"偏向"。偏向锁状态下 54 位线程 ID 占了原 hashCode 的位置,这就是"调用 hashCode()(或 System.identityHashCode)会撤销偏向锁“的原因——两者抢同一块位域。

30.7 锁升级路径

1
2
3
  无锁 ─首次同步、偏向未关闭→ 偏向锁 ─第二个线程竞争→ 轻量级锁 ─CAS 自旋失败→ 重量级锁
  成本: 零      一次 CAS 置线程 ID     一次 CAS + 自旋        内核互斥量 + park/unpark
  备注: —       撤销要 STW 到安全点    失败即膨胀、不可逆      一次上下文切换约 1~5 μs
状态Mark Word 内容获取成本触发/退出条件
无锁hash + age + 010初始状态
偏向锁线程 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),否则自旋,自旋用尽即膨胀为重量级锁。

1
2
3
4
5
6
7
  栈帧(线程私有)                   堆上的对象
  ┌────────────────────┐        ┌───────────────────────┐
  │ Lock Record        │        │ Mark Word             │
  │  displaced_header ─┼─备份─▶ │  (原 hash/age/01)     │
  └─────────▲──────────┘        │ Mark Word 现在= ─────┼─┐
            └───────────────────┴───────────────────────┘ │
               指向 Lock Record 的指针 ◀──────────────────┘

解锁就是把 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 的队列迁移

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
  HotSpot 的 ObjectMonitor 结构(c++ 层,对应重量级锁)
  ┌──────────────┐  _owner     = 当前持有锁的线程(重入计数 _recursions)
  │ ObjectMonitor│  _cxq       = 竞争队列:新来的竞争者 CAS 入队(单向链表)
  │              │  _EntryList = 入口队列:从 _cxq 搬运过来的"等锁"线程
  │              │  _WaitSet   = 等待集:调用过 wait() 的线程(双向循环链表)
  └──────────────┘
  wait()     ⟹ 释放锁(_owner 置空、_recursions 归零),把自身包成 ObjectWaiter
                挂入 _WaitSet,线程状态变 WAITING
  notify()   ⟹ 从 _WaitSet 取一个 ObjectWaiter,按策略迁到 _EntryList 或 _cxq;
                ★ 被唤醒者不立刻持锁,而是变 BLOCKED 重新抢锁
  notifyAll()⟹ _WaitSet 全部迁移;持锁线程退出 ⟹ 从 _EntryList 选一个(默认非公平)

重要

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 字段的写。

1
2
3
4
5
6
7
8
9
public class FinalPublish {
    final int x;              // 构造器内一次性写入,之后不可改
    int y;                    // 非 final:没有安全发布保证
    static FinalPublish obj;  // 共享引用,不加 volatile,靠 final 语义兜住 x
    public FinalPublish() {
        x = 1;
        y = 2;   // 编译器在构造器返回前插入 StoreStore 屏障:保证 x=1 先于对象引用对外可见
    }
}

对不可变对象的意义:若一个类的字段全为 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 对象上。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
  引用链(面试必须能画出来的那张图)
  Thread ─(强引用 threadLocals 字段)→ ThreadLocalMap → Entry[] table
                       └─ Entry ◀─ extends WeakReference<ThreadLocal<?>>
                            ├─ referent ─(弱引用)→ ThreadLocal 对象 ★ key
                            └─ value ───(强引用)→ 业务对象      ★ value
  ① 外部对 ThreadLocal 的强引用消失 ⟹ 只剩 Entry 的弱引用指向它
  ② 下一次 GC ⟹ key 被回收,Entry 的 key 变成 null,但 value 仍被 Entry 强引用
  ③ Entry 被 ThreadLocalMap 强引用,ThreadLocalMap 被 Thread 强引用
  ④ 线程存活期间(★线程池的核心线程几乎永不退出)这部分 value 永远无法回收
  ⟹ "key 被回收但 value 无法回收",泄漏量 = 全部失效 Entry 的 value 之和

重要

为什么 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 “这段代码要不要加锁"的判断流程

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
  第 0 问:存在共享可变状态吗?
     └─ 不存在(全用局部变量、参数、不可变对象)⟹ 线程安全,不需任何同步 ★最常被忽略
  第 1 问:能改成不可变(字段全 final、无 setter、内部集合不外泄)吗?
     └─ 能 ⟹ 线程安全,靠 final 的安全发布(见 30.9 节)
  第 2 问:能改成"每线程一份"吗?⟹ 用 ThreadLocal,必须 remove(见 30.10 节)
  第 3 问:能换成并发容器或原子类吗?(单个操作原子 ≠ 复合操作原子,见第 29、31 章)
     └─ 能 ⟹ ConcurrentHashMap / AtomicInteger / LongAdder
  第 4 问:以上都不行 ⟹ 必须同步
     ├─ 只发不收的状态标志、单一变量更新 ⟹ volatile 足够
     └─ "检查后动作"或需保护多个变量的一致性 ⟹ synchronized 或 Lock
  ⟹ 三层次:不可变 > 无状态与局部变量 > 同步;同步是最后手段而非首选

判据的核心是复合操作: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 的价值在于可以只声明单向屏障。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
import java.lang.invoke.VarHandle;
public final class ReleasePublish {
    private int data;                                  // 普通字段:靠 release/acquire 配对建立可见性
    // 用标准 API 拿字段句柄,编译期即可校验类型,避免 Unsafe"签名一错就崩"
    private static final VarHandle DATA = MethodHandles.lookup()
            .findVarHandle(ReleasePublish.class, "data", int.class);
    // 发布方:setRelease 只要求"之前的写不能跑到它后面",比 volatile 写便宜(x86 上省掉 StoreLoad)
    public void publish(int value) {
        DATA.setRelease(this, value);
    }
    // 消费方:getAcquire 只要求"之后的读写不能跑到它前面",与 setRelease 配对即可安全看到 value
    public int consume() {
        return (int) DATA.getAcquire(this);
    }
}

本章小结

关键结论一句话表述常见错误
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 节)。
**Q2**(推导)`int data = 0; boolean ready = false;`,线程 A 执行 `data = 42; ready = true;`,线程 B 执行 `if (ready) { println(data); }`。这段代码在 x86 上通常打印 42,在 ARM 上可能打印 0。请指出缺少哪条 `happens-before`,画出可能的重排,并说明加 `volatile` 后屏障如何补上缺口。
答案与解析**缺少规则 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 节)。
**Q3**(分析)有人写"不加 `volatile` 的 DCL"并辩称"`new` 是一整条指令,别的线程不可能看到半个对象"。请从字节码与屏障两个角度反驳,并说明静态内部类写法为什么天然不需要 `volatile`。
答案与解析**反驳一(字节码)**:`new` 在字节码里是三条指令——`new`(分配并零值初始化)、`invokespecial `(执行构造器)、`putstatic`(赋值引用)。**后两条无数据依赖**,JIT 与 CPU 都可把 `putstatic` 提前到 `` 之前,而第 ① 步分配的内存全是零值,所以另一个线程通过首次检查后读到的是 `0`/`null`。**反驳二(屏障)与静态内部类为什么不需要**:不加 `volatile` 时 `putstatic` 前后**没有任何屏障**,缺少 `StoreStore` 意味着构造器内的字段写可能后于引用赋值对其他核可见;加 `volatile` 后前置 `StoreStore` 把构造器内的写压在它前面,读侧 `LoadLoad` 保证第二个检查通过后才允许读字段。静态内部类则依赖 **JVM 的类初始化锁**:`` 由 JVM 保证"同一类只执行一次,其他尝试初始化的线程被阻塞到完成"(JLS 12.4.2),本身就是完整的 `hb` 链,且类只在首次主动使用时初始化,故既安全又延迟加载。**面试官会追问**:反射可以调私有构造器,需要额外防护,而 `enum` 单例天然免疫反射与反序列化;若构造器里把 `this` 传出去,就破坏了安全发布的前提,静态内部类也救不了(见 30.9 节)。
**Q4**(推导)某线程池服务用 `ThreadLocal` 缓存每请求约 1 MB 缓冲,代码只有 `set` 没有 `remove`,数小时后堆占用持续上涨且 `Full GC` 后不下降。请画出引用链,解释弱引用为何没能避免泄漏,并说明为什么显式 `remove` 是唯一解法。
答案与解析**引用链**:`Thread`(线程池核心线程,存活期几乎等于进程)→ 强引用 `threadLocals` 字段 → `ThreadLocalMap` → `Entry[]` → `Entry` → **`value`(强引用)→ 那 1 MB 的 `byte[]`**;而 `Entry` 对 `ThreadLocal` 对象本身只有**弱引用**(`Entry extends WeakReference>`)。弱引用为什么没救:它只加在 **key** 上,GC 回收 key 后 `Entry` 成为"key 为 `null` 的陈旧条目",但 `value` 仍被 `Entry` 强引用、`Entry` 仍被 map 强引用、map 仍被那个**不会退出的线程**强引用,所以这 1 MB 不可达但不可回收,`Full GC` 清不掉。 **为什么必须显式 `remove`**:`expungeStaleEntry` 只在**同一线程再次访问该 map** 时被动触发(`get`/`set` 的探测路径或 `set` 的启发式清理)。线程池复用时若该请求类型的 `ThreadLocal` 后续不再被这个线程使用,清理路径永远走不到,泄漏是永久性的。`remove()` 会**先把 `value` 置 `null`** 再清 Entry,是唯一能立即断开强引用的动作。规范写法:`try { tl.set(buf); ... } finally { tl.remove(); }`。 **面试官会追问**:`value` 不能做成弱引用,否则正常业务值会在任何一次 GC 时被清空;map 也不能挂在 `ThreadLocal` 上,否则它就成了所有线程共享的 map,既需要并发控制又丢失"每线程一份副本"的隔离语义。线程池下要跨任务传递上下文,用 `TransmittableThreadLocal` 把快照时机从"线程创建"改到"任务提交"(见 30.10.3 节)。
**Q5**(设计)一段"检查后更新共享 map"的代码 `if (!map.containsKey(k)) map.put(k, load(k));` 中 `map` 是 `ConcurrentHashMap`,接口响应时间却抖动。请说明它为什么不安全,给出三种修复并比较代价,再说明用 `synchronized` 保护时要注意什么。
答案与解析**为什么不安全**:`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 remove30.10

延伸阅读

  • JLS 第 17 章 Threads and Locks:17.4 内存模型(happens-before 与因果性要求)、17.5 final 字段语义、17.6 字的拆分、17.7 volatile 的 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 节(逃逸分析、栈上分配与标量替换)。