线程安全与锁优化
版本说明:本文主要基于 JDK 6 ~ JDK 14 的 HotSpot 虚拟机实现。需要注意的是,从 JDK 15 开始,偏向锁已被默认关闭并标记为废弃(JEP 374)。如果你使用的是 JDK 15+,文中关于偏向锁的内容仅作为历史参考。
线程安全
什么是线程安全
“当多个线程访问一个对象时,如果不用考虑这些线程在运行时环境下的调度和交替执行,也不需要进行额外的同步,或者在调用方法进行任何其他的协调操作,调用这个对象的行为都可以获得正确的结果,那么这个对象就是线程安全的。”
相对的线程安全,可以分成五个等级。但在深入讨论线程安全的分类之前,我们需要先理解 Java 内存模型——它是理解线程安全问题的理论基础。
Java 内存模型基础
Java 内存模型(Java Memory Model,JMM)是 Java 语言规范的一部分,定义了多线程程序中共享变量的访问规则。理解 JMM 是理解线程安全问题的基础。
为什么需要内存模型?
现代计算机系统中,CPU 与主内存之间存在巨大的速度差异。为了弥补这一差距,硬件层面引入了多级缓存(L1、L2、L3 Cache)。这带来了一个问题:当多个 CPU 核心同时操作同一块内存时,各自缓存中的数据可能不一致。
JMM 的作用就是屏蔽底层硬件差异,为 Java 程序员提供一套统一的内存访问规则。
JMM 的抽象模型
JMM 将内存分为两个层次:
| 概念 | 说明 |
|---|---|
| 主内存(Main Memory) | 所有线程共享的内存区域,存储共享变量 |
| 工作内存(Working Memory) | 每个线程私有的内存区域,存储该线程使用的共享变量的副本 |
关键规则:
- 线程对共享变量的所有操作都必须在工作内存中进行,不能直接读写主内存
- 不同线程之间无法直接访问对方的工作内存,必须通过主内存传递
线程安全的三大特性
JMM 围绕三个核心特性来定义线程安全:
1. 原子性(Atomicity)
定义:一个操作要么全部执行完成,要么完全不执行,不会被其他线程中断。
问题示例:i++ 不是原子操作
1 | |
保证原子性的方式:
synchronized关键字java.util.concurrent.atomic包中的原子类Lock接口的实现类
2. 可见性(Visibility)
定义:当一个线程修改了共享变量的值,其他线程能够立即看到这个修改。
问题示例:没有可见性保证的循环
1 | |
为什么会看不到?
- 线程 1 可能将
running缓存在 CPU 寄存器或 L1 Cache 中 - 线程 2 的修改只写入了主内存,没有通知线程 1 刷新缓存
- 结果:线程 1 一直读取的是缓存中的旧值
保证可见性的方式:
volatile关键字synchronized关键字(进入和退出同步块时会刷新缓存)final关键字(对于不可变对象)
3. 有序性(Ordering)
定义:程序执行的顺序按照代码的先后顺序执行。
问题:编译器和处理器可能对指令进行重排序以优化性能。
1 | |
经典问题:双重检查锁定(DCL)的问题
1 | |
instance = new Singleton() 实际上是三个操作:
- 分配内存空间
- 初始化对象
- 将 instance 指向分配的内存
由于重排序,可能变成 1 → 3 → 2,导致其他线程看到一个未完全初始化的对象。
保证有序性的方式:
volatile关键字(禁止特定类型的重排序)synchronized关键字(同一时刻只有一个线程执行)- happens-before 规则
happens-before 规则
happens-before 是 JMM 的核心概念,它定义了操作之间的可见性关系。
定义:如果操作 A happens-before 操作 B,那么 A 的执行结果对 B 可见,且 A 的执行顺序在 B 之前。
注意:happens-before 不是指时间上的先后,而是指可见性的保证。
JMM 定义的 happens-before 规则
| 规则 | 说明 |
|---|---|
| 程序顺序规则 | 同一个线程中,前面的操作 happens-before 后面的操作 |
| 监视器锁规则 | 对一个锁的解锁 happens-before 对同一个锁的加锁 |
| volatile 变量规则 | 对 volatile 变量的写 happens-before 对同一变量的读 |
| 传递性规则 | 如果 A happens-before B,B happens-before C,则 A happens-before C |
| 线程启动规则 | Thread.start() happens-before 该线程中的任何操作 |
| 线程终止规则 | 线程中的任何操作 happens-before 其他线程检测到该线程终止 |
| 中断规则 | 对线程 interrupt() 的调用 happens-before 被中断线程检测到中断 |
| 对象终结规则 | 对象的构造函数执行完成 happens-before finalize() 方法的开始 |
volatile 关键字
volatile 是 Java 提供的最轻量级的同步机制,它保证了可见性和有序性,但不保证原子性。
volatile 的语义
- 可见性:对 volatile 变量的写入会立即刷新到主内存,读取会从主内存重新加载
- 有序性:禁止 volatile 变量与普通变量之间的重排序
volatile 的内存屏障
JVM 通过插入**内存屏障(Memory Barrier)**来实现 volatile 的语义:
| 屏障类型 | 说明 |
|---|---|
| LoadLoad | 确保 Load1 数据的装载先于 Load2 及后续装载指令 |
| StoreStore | 确保 Store1 数据对其他处理器可见先于 Store2 及后续存储指令 |
| LoadStore | 确保 Load1 数据装载先于 Store2 及后续存储指令刷新到内存 |
| StoreLoad | 确保 Store1 数据对其他处理器可见先于 Load2 及后续装载指令 |
深入理解内存屏障
上面我们介绍了 JVM 层面的四种内存屏障(LoadLoad、LoadStore、StoreLoad、StoreStore),但这只是冰山一角。要真正理解内存屏障的本质,我们需要深入到 CPU 和缓存的层面。
为什么需要内存屏障?
现代 CPU 为了提高性能,引入了多级缓存和各种优化机制,这些机制可能导致内存操作的可见性和顺序性问题:
Store Buffer(写缓冲区):
- CPU 执行写操作时,不会直接写入缓存,而是先写入 Store Buffer
- 这样 CPU 可以继续执行后续指令,不用等待缓存一致性协议完成
- 问题:其他 CPU 可能看不到 Store Buffer 中的数据
Invalidate Queue(失效队列):
- 当 CPU 收到缓存失效消息时,不会立即处理,而是放入 Invalidate Queue
- 这样 CPU 可以快速响应,不用等待缓存失效完成
- 问题:缓存行可能还未真正失效,读到过期数据
内存屏障的本质
内存屏障(Memory Barrier / Memory Fence)是 CPU 提供的指令,用于强制刷新 Store Buffer 或处理 Invalidate Queue:
| CPU 架构 | 写屏障指令 | 读屏障指令 | 全屏障指令 |
|---|---|---|---|
| x86/x64 | sfence |
lfence |
mfence |
| ARM | dmb st |
dmb ld |
dmb ish |
| POWER | lwsync |
lwsync |
sync |
JVM 如何映射内存屏障
JVM 的四种抽象屏障会根据不同的 CPU 架构映射到具体的指令:
| JVM 屏障 | x86/x64 | ARM | 作用 |
|---|---|---|---|
| LoadLoad | 空操作(x86 保证) | dmb ishld |
禁止读-读重排序 |
| StoreStore | 空操作(x86 保证) | dmb ishst |
禁止写-写重排序 |
| LoadStore | 空操作(x86 保证) | dmb ish |
禁止读-写重排序 |
| StoreLoad | mfence 或 lock 前缀 |
dmb ish |
禁止写-读重排序 |
为什么 x86 大部分屏障是空操作?
x86 架构采用强内存模型(TSO - Total Store Order),硬件层面已经保证了大部分顺序性,只有 StoreLoad 重排序需要显式屏障。这也是为什么 Java 在 x86 上的并发性能通常比 ARM 更好。
volatile 的底层实现
重要说明:JMM 规范与 JVM 实现的区别
在讨论 volatile 的实现之前,需要明确区分两个层次:
| 层次 | 定义者 | 内容 |
|---|---|---|
| JMM 规范 | JLS 17.4 | 定义 happens-before 语义,不规定具体实现 |
| JVM 实现 | HotSpot 等 | 使用内存屏障实现 JMM 语义,因架构而异 |
根据 JLS 17.4.4,volatile 变量的语义是:
- 对 volatile 变量的写操作 happens-before 后续对同一变量的读操作
- 这保证了可见性和有序性,但 JLS 并未规定必须使用哪种内存屏障
JSR-133 Cookbook 的实现指南(非规范,仅供参考):
当 JVM 编译 volatile 变量的读写时,会插入相应的内存屏障:
1 | |
1 | |
x86 架构的实际实现:
由于 x86 采用强内存模型(TSO),大部分屏障是空操作:
| 屏障类型 | x86 上的实现 | 原因 |
|---|---|---|
| StoreStore | 空操作 | x86 保证 Store-Store 顺序 |
| LoadLoad | 空操作 | x86 保证 Load-Load 顺序 |
| LoadStore | 空操作 | x86 保证 Load-Store 顺序 |
| StoreLoad | 需要显式屏障 | x86 允许 Store-Load 重排序 |
1 | |
lock 前缀会锁定缓存行(或总线),确保写操作对所有 CPU 可见。
关键理解:内存屏障是 JVM 实现 JMM 语义的手段,不是 JMM 规范本身。不同 JVM、不同 CPU 架构可能使用不同的屏障组合,只要最终满足 happens-before 语义即可。
内存屏障 vs GC 屏障:名字相同,本质不同
你可能听说过 G1、ZGC 也使用"屏障"(Barrier),但这与 CPU 内存屏障是完全不同的概念:
| 维度 | CPU 内存屏障 | GC 屏障 |
|---|---|---|
| 层次 | 硬件指令 | 软件代码 |
| 目的 | 保证内存操作的可见性和顺序性 | 维护 GC 的正确性(如记录引用变化) |
| 实现 | CPU 指令(mfence、dmb 等) | JIT 编译器插入的代码片段 |
| 性能影响 | 阻止 CPU 优化,有一定开销 | 每次引用操作都执行,开销更大 |
G1 的写屏障(Write Barrier)
G1 使用写屏障来维护 Remembered Set(记录跨 Region 引用):
1 | |
ZGC 的读屏障(Load Barrier)
ZGC 使用读屏障来实现并发重定位:
1 | |
为什么都叫"屏障"?
两者都是在某个操作前后插入额外的逻辑,起到"拦截"或"保护"的作用,因此都被称为"屏障"。但它们解决的问题完全不同:
- CPU 内存屏障:解决多核 CPU 的缓存一致性问题
- GC 屏障:解决并发 GC 的正确性问题
MESI 协议:缓存一致性的基石
在讨论内存屏障时,我们提到了"缓存一致性"。那么 CPU 是如何保证多核之间缓存一致的呢?答案是 MESI 协议。
重要说明:MESI 协议不是 x86 专属的,而是一种通用的缓存一致性协议。它最初由 Intel 在 1980 年代提出,但被广泛应用于多种处理器架构:
| 处理器架构 | 使用的协议 | 说明 |
|---|---|---|
| x86/x64 | MESI、MESIF | Intel 使用 MESIF(增加 Forward 状态),AMD 使用 MOESI |
| ARM | MOESI、MESI 变体 | ARM 的 big.LITTLE 架构使用类似协议 |
| SPARC | MOESI | Sun/Oracle 的 SPARC 处理器 |
| PowerPC | MESI 扩展 | IBM 的 Power 系列处理器 |
| RISC-V | 可配置 | 支持多种一致性协议 |
本文以 MESI 为例讲解,因为它是最基础、最经典的缓存一致性协议,其他协议(如 MOESI、MESIF)都是在 MESI 基础上的扩展。
MESI 的四种状态
MESI 是一种缓存一致性协议,每个缓存行(Cache Line)都有四种状态:
| 状态 | 全称 | 含义 |
|---|---|---|
| M | Modified | 缓存行被修改,与主内存不一致,只有当前 CPU 有效副本 |
| E | Exclusive | 缓存行与主内存一致,只有当前 CPU 有副本 |
| S | Shared | 缓存行与主内存一致,多个 CPU 都有副本 |
| I | Invalid | 缓存行无效,需要从主内存或其他 CPU 重新加载 |
MESI 的工作流程
上面的状态图展示了 MESI 协议的基本状态转换,但实际的 CPU 实现中还有两个关键组件:Store Buffer 和 Invalidate Queue。它们是性能优化的关键,也是导致内存可见性问题的根源。
Store Buffer 和 Invalidate Queue 的作用:
| 组件 | 作用 | 带来的问题 |
|---|---|---|
| Store Buffer | CPU 写入时先写入 Store Buffer,不等待其他 CPU 确认,提高写入性能 | 写入可能延迟对其他 CPU 可见 |
| Invalidate Queue | CPU 收到 Invalidate 消息后先放入队列,不立即处理,提高响应速度 | 可能读到已被其他 CPU 修改的旧值 |
这就是为什么需要内存屏障的根本原因:
关键理解:
- MESI 协议本身是正确的,它最终会保证所有 CPU 看到一致的值
- 但 Store Buffer 和 Invalidate Queue 引入了延迟,导致"最终一致"而非"即时一致"
- 内存屏障的作用就是强制刷新这些缓冲区,确保写入立即可见
MESI 与 JMM 的关系
MESI 是硬件层面的缓存一致性协议,JMM 是语言层面的内存模型。两者的关系是:
关键理解:
- MESI 保证最终一致性:通过 Invalidate 消息,MESI 最终会让所有 CPU 看到相同的值
- 但 MESI 不保证即时可见性:由于 Store Buffer 和 Invalidate Queue 的存在,写操作可能延迟可见
- 内存屏障弥补这个差距:强制刷新 Store Buffer 或处理 Invalidate Queue
“锁缓存”(Cache Line Locking)
你可能听说过"锁缓存"这个概念,它与 MESI 密切相关:
锁缓存是 x86 CPU 的一种优化机制:当执行带 lock 前缀的指令时,如果操作的数据在缓存中,CPU 不会锁定整个总线,而是只锁定对应的缓存行。
CAS 操作与缓存锁:
1 | |
lock cmpxchg 指令会:
- 如果数据在缓存中且满足条件,使用缓存锁
- 将缓存行状态设为 M(Modified)
- 发送 Invalidate 消息给其他 CPU
- 其他 CPU 的对应缓存行变为 I(Invalid)
这就是 CAS 能保证原子性的硬件基础。
为什么有了 MESI 还需要内存屏障?
这是一个常见的困惑。答案是:MESI 保证一致性,但不保证可见性的时机。
Store Buffer 和 Invalidate Queue 是性能优化,它们让 CPU 不用等待 MESI 协议完成就能继续执行。但这也带来了可见性延迟。
内存屏障的作用就是在需要时强制等待这些异步操作完成:
- 写屏障:等待 Store Buffer 刷新
- 读屏障:等待 Invalidate Queue 处理
synchronized 与内存屏障
JLS 17.4.4 对 synchronized 的规范定义:
根据 JLS 规范,synchronized 的内存语义通过 happens-before 关系定义:
- 对一个 monitor 的解锁操作 happens-before 后续对同一 monitor 的加锁操作
这意味着:在解锁之前的所有写操作,对于后续获取同一锁的线程都是可见的。
JVM 实现层面(非规范,仅供理解):
1 | |
JSR-133 Cookbook 建议的屏障(实现指南,非规范):
| 操作 | 建议屏障 | 语义 |
|---|---|---|
| monitorenter 之后 | LoadLoad + LoadStore | 获取语义(acquire) |
| monitorexit 之前 | StoreStore + StoreLoad | 释放语义(release) |
实际实现因架构而异:
| 架构 | monitorenter 实现 | monitorexit 实现 |
|---|---|---|
| x86 | lock cmpxchg(隐含全屏障) |
lock 前缀指令或普通写 |
| ARM | dmb + ldrex/strex |
dmb + 普通写 |
| AArch64 | ldaxr/stxr(acquire) |
stlr(release) |
关键理解:JLS 规定的是 happens-before 语义,具体使用哪种内存屏障是 JVM 的实现细节。在 x86 上,由于其强内存模型,lock 前缀指令已经提供了足够的内存屏障语义。
这就是为什么 synchronized 能保证可见性:释放锁时的 StoreLoad 屏障确保所有写操作对获取锁的线程可见。
小结
| 概念 | 层次 | 作用 |
|---|---|---|
| MESI 协议 | 硬件 | 保证多核缓存的最终一致性 |
| Store Buffer / Invalidate Queue | 硬件 | 性能优化,但导致可见性延迟 |
| CPU 内存屏障 | 硬件指令 | 强制刷新缓冲区,保证可见性时机 |
| JMM 内存屏障 | JVM 抽象 | 屏蔽硬件差异,提供统一语义 |
| volatile / synchronized | Java 语言 | 通过内存屏障实现可见性和有序性 |
| GC 屏障 | JVM 软件 | 维护 GC 正确性,与内存屏障无关 |
理解这些概念的层次关系,有助于:
- 正确理解 volatile 和 synchronized 的底层原理
- 区分不同"屏障"概念,避免混淆
- 理解为什么 Java 在不同 CPU 架构上的并发行为可能不同
volatile 的正确使用场景
适用场景:
- 状态标志(如上面的
running变量) - 一次性安全发布(配合 final 字段)
- 双重检查锁定(DCL)的修复
修复后的 DCL:
1 | |
为什么 synchronized 不能替代 volatile?
有人可能会问:synchronized 不是也保证可见性吗?为什么还需要 volatile?
关键在于:第一次检查 if (instance == null) 发生在 synchronized 块外部。
具体分析:
| 检查位置 | 是否在 synchronized 内 | 可见性保证 |
|---|---|---|
第一次检查 if (instance == null) |
❌ 否 | 无保证,可能看到部分构造的对象 |
第二次检查 if (instance == null) |
✅ 是 | 有保证,synchronized 提供可见性 |
volatile 的作用:
- 禁止指令重排序:确保
new Singleton()的三个步骤(分配内存、初始化、赋值引用)不会被重排序 - 保证可见性:确保第一次检查(在 synchronized 外部)也能看到完全初始化的对象
如果所有访问都在 synchronized 内部呢?
如果你能保证所有对 instance 的访问都通过 getInstance() 方法,并且都进入 synchronized 块,那么理论上 synchronized 的可见性就足够了。但 DCL 的设计目的就是避免每次都进入 synchronized,所以第一次检查必须在锁外部,这就需要 volatile。
不适用场景:
- 复合操作(如
i++) - 需要原子性保证的场景
synchronized 与 volatile 的对比
| 特性 | synchronized | volatile |
|---|---|---|
| 原子性 | 保证 | 不保证 |
| 可见性 | 保证 | 保证 |
| 有序性 | 保证 | 保证(部分) |
| 阻塞 | 会阻塞 | 不会阻塞 |
| 性能 | 较重 | 较轻 |
| 使用场景 | 复合操作、临界区 | 状态标志、一次性发布 |
关于"一致性"的说明
本文多处提到"一致性"这个词,需要特别说明:一致性(Consistency)不是 JMM 的专有属性。
JMM 的三大核心属性是:
- 原子性(Atomicity)
- 可见性(Visibility)
- 有序性(Ordering)
而"一致性"在不同上下文中有不同含义:
| 术语 | 含义 | 所属领域 |
|---|---|---|
| 缓存一致性(Cache Coherence) | 多核 CPU 缓存之间的数据一致 | 硬件/MESI 协议 |
| 内存一致性模型(Memory Consistency Model) | 定义多处理器系统中内存操作的可见顺序 | 硬件/操作系统 |
| 顺序一致性(Sequential Consistency) | 最强的内存模型,所有操作按全局顺序执行 | 理论模型 |
| 数据一致性(Data Consistency) | 分布式系统中数据副本的一致 | 分布式系统/ACID |
| 最终一致性(Eventual Consistency) | 数据最终会达到一致状态 | 分布式系统/BASE |
JMM 与一致性的关系:
- JMM 通过 happens-before 规则定义了操作之间的可见性关系
- JMM 提供的是一种弱于顺序一致性的内存模型(允许一定程度的重排序)
- JMM 的目标是在性能和正确性之间取得平衡
final 字段的内存语义
除了 volatile 和 synchronized,final 字段也是 JMM 的重要组成部分。final 字段的内存语义保证了不可变对象的线程安全发布。
final 字段的写入语义
当构造函数完成时,JVM 会在 final 字段写入之后插入一个 StoreStore 屏障,确保:
- final 字段的写入不会被重排序到构造函数之外
- 其他线程在看到对象引用时,一定能看到 final 字段的正确值
final 字段的读取语义
当首次读取包含 final 字段的对象引用时,JVM 会在读取 final 字段之前插入一个 LoadLoad 屏障,确保:
- 先读取对象引用,再读取 final 字段
- 不会读取到 final 字段的默认值(如 0 或 null)
正确使用 final 的示例
1 | |
final 语义的限制
重要:final 的内存语义有一个前提条件——对象引用必须被正确发布。如果在构造函数中将 this 引用泄露出去,final 的语义保证将失效:
1 | |
final vs volatile vs synchronized
| 特性 | final | volatile | synchronized |
|---|---|---|---|
| 可见性 | 构造完成后保证 | 每次读写都保证 | 临界区内保证 |
| 有序性 | 禁止构造函数内外重排序 | 禁止读写重排序 | 禁止临界区内外重排序 |
| 原子性 | 不保证(只写一次) | 不保证 | 保证 |
| 适用场景 | 不可变对象 | 状态标志、DCL | 复合操作 |
| 性能开销 | 最低(只在构造时) | 较低 | 较高 |
小结
理解 JMM 是理解 Java 并发编程的基础:
- JMM 的作用:屏蔽硬件差异,提供统一的内存访问规则
- 三大特性:原子性、可见性、有序性(注意:不包括"一致性")
- happens-before:定义操作之间可见性关系的核心规则
- volatile:轻量级同步机制,保证可见性和有序性
- final:不可变对象的安全发布保证
- synchronized:重量级同步机制,保证原子性、可见性和有序性
有了这些基础知识,我们就可以更好地理解后续章节中的线程安全分类和锁优化技术。
线程安全的分类
不可变(Immutable)
不可变的数据,都是线程安全的。不可变的对象引用,加上所有field都是不可变的。如果有得选,尽量连方法都是final的。
绝对线程安全(Absolute Thread Safety)
Vector 不是绝对线程安全的。它也会出现并发修改时 Out of Range 的异常(注意,不是 ConcurrentModification 的异常)。
相对线程安全(Relative Thread Safety)
需要保证对这个对象的单独操作是线程安全的,在调用的时候不需要加上额外的保障措施。对于特定顺序的连续操作,就需要额外的同步来保证调用的正确性了。
线程对立
Vector 是相对线程安全的。
线程兼容(Thread Compatible)
可以通过特殊手段做到线程安全的普通类,绝大部分类都属于线程兼容的。
线程对立(Thread Hostile)
线程对立,是不管调用端是否采取了同步措施,都无法在多线程环境中使用的代码。常见的线程对立的操作还有 suspend(),resume(), System.setIn(),System.setOut()和System.runFinalizerOnExit()。
线程安全的实现
互斥同步(Mutual Exclusion & Synchronization)
这是最常见(也是我们在考虑并发问题的时候,首先应该考虑的万能解决方案,也是《Java并发编程实践》和《Thinking in Java 》中最推荐的做法。)的保障并发正确性的手段。同步(Synchronization)是指协调多个线程的执行,保证共享数据在同一时刻只被一条(或使用信号量时多条)线程访问。互斥(Mutual Exclusion)是实现同步的一种手段,临界区(Critical Section)、互斥量(Mutex)和信号量都是实现互斥的常见方式。互斥是因,同步是果;互斥是方法,同步是目的。这两个概念源自操作系统理论(Dijkstra 1965 年提出信号量时引入),同样出现在 OS 层面。同步的终极目标是将并发的乱序转化为类似无并发时的有序。
[辨析] 临界区、同步块与锁保护区域
这三个概念经常被混淆,但实际上是层次关系:
-
临界区(Critical Section):通用并发编程领域的抽象概念,指"访问共享资源的代码段"。例如:
x = 1; y = 2;这段代码如果需要互斥访问,它就是一个临界区。 -
同步块(Synchronized Block):Java 语言层面的语法结构,是临界区的一种实现方式。例如:
synchronized (lock) { x = 1; },其中的{ x = 1; }是临界区,synchronized (lock) { }是同步块。 -
锁保护区域(Lock-Protected Region):运行时概念,指实际被
synchronized或ReentrantLock保护的具体代码区域。例如:当线程执行到synchronized (lock)内的代码时,这块正在执行的代码就是锁保护区域。
层次关系图:
关键区别:
- 临界区是抽象概念,描述"需要保护什么"
- 同步块是语法工具,描述"如何保护"
- 锁保护区域是运行时实例,描述"当前正在保护什么"
在 Java 里面,最基本的互斥手段就是 synchronized 关键字。它经过编译后,会转化为 monitorenter 和 monitorexit 这两个字节码指令(bytecode instructions)。在执行这两个字节码指令之前,需要先将锁对象的引用压入操作数栈,指令会从栈顶获取这个引用来确定加锁/解锁的对象。这个引用不是一个普通对象实例,就是一个 Class 对象(对于 synchronized 静态方法)。
根据虚拟机规范,在执行 monitorenter 指令时,首先尝试获取对象的锁(实际上就是去用线程信息写 markword)。如果这个对象没有被锁定,或者当前线程已经拥有了那个对象的锁,那么把锁的计数器加1。相应地,在执行 monitorexit 时,会对计数器减1,当计数器为0时,锁就被释放了。从某种意义上来讲,这种设计可以在分布式场景下用 Redis 实现。如果获取锁失败了,那么就会进入阻塞状态,直到对象锁被释放为止。虚拟机规范对 monitorenter 和 monitorexit 两条指令的行为描述中,有两点是需要特别注意的。首先,synchronized同步块对同一条线程来说是可重入的,不会出现自己把自己锁死(阻塞)的情况。其次,同步块在已进入的线程执行完之前,会阻塞后面其他线程的进入。对于映射到操作系统原生进程的实现,不管是阻塞还是唤醒线程,都需要操作系统的调用帮忙,也就会牵涉到用户态转变入核心态的问题(系统控制权从用户空间转入内核空间)。这种切换需要消耗很多 CPU 时间。这也是为什么它是昂贵的原因,时间是最昂贵的。对于很多简单的getter()、setter()操作,花在状态切换上的时间,甚至会多过用户代码执行的时间。甚至可以认为,这样的状态切换需要使用很多的汇编指令代码,以至于要使用很多的 cpu 时钟周期。因此synchronized本身是一种重量级(Heavyweight)操作。JVM(注意,不是Java语言) 本身可能会对重量锁进行优化,使用偏向锁/轻量级锁/锁自旋而非阻塞来避免频繁地切入核心态之中(自旋难道就不浪费CPU 时间了吗?)。
J.U.C包里专门提供了Reentrantlock来实现同步。它同样具有 synchronized 具有的可重入、阻塞其他求锁者的特性。但它还具有三个额外的特点,支持某些场景下的任务调度需求:
- 等待可中断。Lock 接口有实现类可以实现试锁,超时试锁等功能,各种接口都有 interruptibly 版本。这样 synchronized中,其他求锁线程傻等的情况可以避免。
- 公平锁。公平锁指的是按照求锁顺序来分配锁(求锁也是有顺序的,fifo 天然就是公平的)。默认的锁(synchronized 和 ReentrantLock 的默认构造函数)是非公平的,随机给予锁,这样性能更好。synchronized 本身并不内置公平锁,AQS 的非公平锁通过允许插队(新来的线程可以直接尝试 CAS 获取锁,不用排队),来减少 cpu 时间片花在调度/cpu上下文切换上的开销,来获得更高的吞吐。非公平锁的吞吐会更好,而公平锁可避免线程饥饿。ReentrantLock 默认使用非公平锁。
- 绑定多个条件。在 synchronized 的时代,多个 condition 就意味着多层 synchronized。
为什么需要可中断锁?使用场景分析
lock() 不支持中断,线程会一直阻塞直到获取锁。这与原始的 synchronized 行为一致:一旦进入等待,就无法中途退出。而 lockInterruptibly() 的引入,让加解锁不再是单一的对称重复操作,提供了更灵活的控制。
可中断锁的核心价值:让线程在等待锁的过程中能够响应外部中断信号,提前退出等待状态。
典型使用场景
场景 1:线程池优雅关闭
1 | |
为什么场景 1 需要可中断锁?
- 线程池调用
shutdownNow()时,会对所有工作线程调用interrupt() - 如果工作线程使用
lock()阻塞在锁上,无法响应中断,会一直傻等到获取锁 - 使用
lockInterruptibly(),线程会立即抛出InterruptedException,可以快速响应关闭
场景 2:任务超时取消
1 | |
为什么场景 2 需要可中断锁?
- 任务可能因为锁竞争严重而长时间无法获取锁
- 外部调用者可能因为超时需要取消任务
- 使用
lockInterruptibly()或tryLock(timeout),可以让任务快速响应取消
场景 3:分布式锁的锁续期失败
1 | |
为什么场景 3 需要可中断锁?
- 分布式锁可能因为网络问题、Redis 故障等原因续期失败
- 需要快速放弃锁持有,避免其他节点长时间等待
- 可中断锁可以让线程快速响应异常情况
可中断锁 vs 不可中断锁
| 特性 | lock() |
lockInterruptibly() |
|---|---|---|
| 响应中断 | ❌ 不响应 | ✅ 响应 |
| 中断时行为 | 继续阻塞 | 抛出 InterruptedException |
| 适用场景 | 简单互斥,不需要取消 | 需要快速响应外部取消信号 |
| 底层实现 | AQS acquire() |
AQS acquireInterruptibly() |
| 线程状态 | BLOCKED 或 WAITING |
WAITING(可被中断唤醒) |
synchronized 为什么不支持中断?
synchronized 不支持中断是历史设计和 JVM 实现限制的结果:
- 历史原因:
synchronized是 Java 1.0 就存在的语言特性,当时还没有考虑线程中断机制 - JVM 实现:
synchronized的monitorenter指令直接映射到操作系统的互斥锁,无法在中途取消等待 - 语义设计:
synchronized设计为"简单的互斥",ReentrantLock设计为"高级的同步工具"
总结:
- 优先使用
synchronized:对于简单的互斥场景,synchronized的性能和可读性都更好 - 需要可中断时使用
lockInterruptibly():线程池关闭、任务取消、超时控制等场景 - 需要超时时使用
tryLock(timeout):避免长时间等待锁
这种设计体现了 Java 并发库的渐进式复杂度:简单场景用简单工具,复杂场景用高级工具。
synchronized 的性能屡屡被 JVM 的实现者改进,因此还是优先要使用synchronized(《TIJ》、《Java 并发实践》和《深入理解 Java 虚拟机》到此达到了同一结论)。
synchronized 的公平性深入分析
synchronized 是一个非公平锁,这一特性源于其底层实现机制。理解 synchronized 的非公平性,需要从 JVM 的 ObjectMonitor 实现说起。
为什么 synchronized 是非公平的
synchronized 的非公平性体现在以下几个层面:
| 层面 | 非公平行为 | 原因 |
|---|---|---|
| 偏向锁 | 偏向第一个获取锁的线程 | 优化单线程场景 |
| 轻量级锁 | 自旋竞争,先 CAS 成功者获胜 | 避免线程阻塞开销 |
| 重量级锁 | 新线程可直接尝试获取,不必排队 | 减少上下文切换 |
ObjectMonitor 的 EntryList 竞争机制
当 synchronized 升级为重量级锁后,底层使用 ObjectMonitor 管理等待线程。ObjectMonitor 维护两个队列:
关键点:当锁被释放时,JVM 并不保证 EntryList 中等待最久的线程一定能获得锁。新到达的线程可以"插队"直接尝试获取锁,这就是非公平的本质。
synchronized 与 ReentrantLock 公平性对比
| 特性 | synchronized | ReentrantLock(非公平) | ReentrantLock(公平) |
|---|---|---|---|
| 公平性 | 非公平 | 非公平 | 公平 |
| 可配置 | 否 | 是 | 是 |
| 吞吐量 | 高 | 高 | 较低 |
| 线程饥饿风险 | 存在 | 存在 | 无 |
| 适用场景 | 通用 | 高吞吐优先 | 顺序敏感场景 |
非公平锁的性能优势
非公平锁的设计是一个经过深思熟虑的权衡。其性能优势来源于:
- 减少线程唤醒开销:新线程可能在队列线程被唤醒之前就获得锁并完成工作
- 减少上下文切换:避免不必要的线程状态转换
- 提高 CPU 缓存命中率:刚释放锁的线程再次获取锁时,相关数据可能还在缓存中
非阻塞同步(Non-Blocking Synchronization)
也就是我们常说的乐观策略。不需要加锁,也就不需要负担线程状态切换的代价。但代价是,如果真的发生了冲突,乐观操作需要付出的代价就是补偿(compensation)。最常见的补偿,应该就是不断重试(又要引入自旋了)。乐观锁的核心基石,实际上是 CAS(CompareAndSet或者 CompareAndSwap),这两个操作必须是原子化操作,这就要求现代的处理器提供这样的指令原语(instruction primitive)。JVM 虚拟机里,专门通过 Unsafe 包来向上层提供这种原语的语义。
CAS操作有一个很讨厌的 ABA 问题。虽然 ABA 问题本身在大部分情况下不会引起问题,但J.U.C还是提供了一个 AtomicStampedReference操作来避免这个问题(所以说,带版本的原子值才是最安全的)。在大多数情况下,进入互斥同步,还比用这些鸡肋功能要高效。所有自旋都满足如下规律:低度竞争自旋优于真正的 mutex 互斥锁定,高度竞争自旋会浪费 cpu-低度竞争适合线程活跃等待,高度竞争适合线程阻塞等待。
无同步方案
可重入代码(Reentrant Code)
也叫纯代码(Pure Code)。在它执行的任意时刻中断它,转而去执行另一段代码,再切换上下文回来以后,不会发生任何错误。所有可重入的代码都是线程安全的,但并非所有线程安全的代码都是可重入的。
可重入代码的特征:
- 不依赖任何非常量的全局变量或静态变量
- 不修改自身的代码
- 不调用不可重入的函数
- 所有数据都通过参数传递或使用局部变量(栈封闭)
这类似于函数式编程里的纯函数,函数的行为完全由输入参数决定,结果可预测,不依赖也不修改外部状态。这也是为什么函数式编程在高并发下是安全的,它们天然满足栈封闁的标准。
线程本地存储(Thread Local Storage)
请参阅专题文章:ThreadLocal 的设计模式
对象头
需要参考:
在 32 位虚拟机里:
1 | |
我们大致上认为一个对象应该分为 object header 和 object body,然后再把 header 分为 Mark Word 和 Klass Pointer。
为什么叫 Mark Word?
- Mark = 标记/标志,因为这个字段用于存储对象的各种运行时标记信息
- Word = 机器字长(32 位或 64 位),表示它占用一个机器字的空间
- 存储内容(根据锁状态动态变化):
- 无锁状态:对象的 hashCode、GC 分代年龄(age)、锁标志位
- 偏向锁状态:偏向线程 ID、epoch、GC 分代年龄、锁标志位
- 轻量级锁状态:指向栈中 Lock Record 的指针
- 重量级锁状态:指向 Monitor 对象的指针
- GC 标记状态:用于垃圾回收
为什么叫 Klass Word(类型字)?
- Klass 是 HotSpot 虚拟机中对 Java 类元数据的 C++ 表示(注意不是 Class,而是 Klass,这是 HotSpot 源码中的命名约定)
- Word = 机器字长(32 位或 64 位),表示它占用一个机器字的空间
- 本质上是一个指针,指向方法区中该对象所属类的元数据(Klass 对象)
- 存储内容:指向方法区中
InstanceKlass(普通对象)或ArrayKlass(数组对象)的指针 - 作用:JVM 通过这个指针确定对象是哪个类的实例,从而找到类的方法表、字段布局等信息
术语说明:在不同的技术文档中,你可能会看到 Klass Word 和 Klass Pointer 两种叫法,它们指的是同一个东西。Klass Word 强调它占用一个机器字的空间(与 Mark Word 对应),Klass Pointer 强调它的本质是一个指针。本文统一使用 Klass Word 以与对象头结构图保持一致。
Klass Pointer 与类元数据的关系
Klass Pointer 指向的究竟是什么?当 .class 文件被类加载器加载进 JVM 后,字节码中的类型信息会被解析并存储在方法区(JDK 8+ 为 Metaspace)中,形成 HotSpot 内部的 C++ 数据结构——Klass。
图:从 .class 文件到对象实例的完整链路
图:Klass Pointer 的指针压缩
核心概念澄清:
| 概念 | 层面 | 说明 |
|---|---|---|
| Klass | C++ (HotSpot 内部) | 类元数据的抽象基类,是 HotSpot 对 Java 类型系统的内部表示 |
| InstanceKlass | C++ (HotSpot 内部) | 普通 Java 类的元数据,包含 vtable、字段布局、方法字节码等 |
| ArrayKlass | C++ (HotSpot 内部) | 数组类型的元数据,分为 ObjArrayKlass 和 TypeArrayKlass |
| java.lang.Class | Java | Klass 在 Java 层的"镜像"对象,通过 MyClass.class 获取 |
| Klass Pointer | 对象头 | 每个对象实例中指向其 Klass 的指针,占 1 个机器字(可压缩) |
常量池、运行时常量池与方法区
方法区存储类元数据,常量池是其中最容易混淆的一部分。三者的关系可以从生命周期和所在区域两个维度理解:
- Class 文件常量池(Constant Pool):编译后存在于
.class文件中,是静态的符号表。 - 运行时常量池(Runtime Constant Pool):类加载后,JVM 在方法区中为每个类维护一份动态常量池,解析符号引用并支持运行期动态添加(如
String.intern())。 - 字符串常量池(String Pool):JDK 7 起从 PermGen 移到 Java 堆,由
StringTable实现,专门缓存字符串字面量。
图:常量池、方法区与 JVM 各区域的引用关系
关键数据流说明:
.class文件被类加载器读入后,其常量池信息进入方法区,形成运行时常量池。InstanceKlass持有指向运行时常量池的引用,对象头中的 Klass Pointer 指向InstanceKlass。- 栈帧通过动态链接访问运行时常量池,解析方法、字段的符号引用。
- 字符串字面量经
String.intern()或自动入池进入字符串常量池;JDK 7 起该池位于堆中,不再是方法区的一部分。
| 概念 | 所在区域 | 生命周期 | 主要内容 |
|---|---|---|---|
| Class 文件常量池 | .class 文件 |
编译后至加载前 | 类名、方法名、字段名、字面量等符号 |
| 运行时常量池 | 方法区 | 类加载后至类卸载 | 解析后的符号引用、运行期新增的常量 |
| 字符串常量池 | JDK 7+ 堆;JDK 6- 方法区 | 随字符串引用可达性 | String.intern() 缓存的字符串实例 |
为什么叫 Klass 而不是 Class?
这是 HotSpot 源码的命名约定。由于 C++ 中 class 是保留关键字,无法用作类名,因此 HotSpot 开发者使用 Klass 来表示 Java 类的元数据。这个命名也体现了它与 Java 层 java.lang.Class 的区别——Klass 是 VM 内部的 C++ 对象,而 Class 是 Java 层的反射对象。
为什么放在对象头?
- 这两个信息是 JVM 管理对象的核心元数据,每个对象都必须携带
- Mark Word 支持 synchronized 锁机制和 GC
- Klass Word 支持方法调用(虚方法表查找)和类型检查(instanceof)
两个 Word 的结构特性差异:
在不同的 JVM 实现中(32 位 vs 64 位),这两个 Word 都是固定的机器字长(32 bits 或 64 bits),但它们的结构可变性完全不同:
| 字段 | 大小 | 结构是否可变 | 说明 |
|---|---|---|---|
| Mark Word | 32/64 bits | 可变 | 根据锁状态动态变化,像 C 语言的 union,同一块内存在不同状态下存储不同含义的数据 |
| Klass Word | 32/64 bits | 固定 | 始终是一个指向方法区类元数据的指针,结构从不改变 |
这种设计体现了不同的职责:
- Klass Word 是静态的:对象的类型在创建后就确定了,永远不会改变,所以 Klass Word 只需要存储一个固定的指针
- Mark Word 是动态的:对象的运行时状态(锁状态、GC 年龄、hashCode 等)会随着程序执行而变化,所以 Mark Word 需要能够"变形"来适应不同的场景
锁升级时原始信息去哪了?
Mark Word 的各种状态是互斥的,锁升级时原始信息(hashCode、age 等)会被转移保存。关于三种锁对 Mark Word 的详细处理策略,请参见下文偏向锁章节的"三种锁对 Mark Word 的处理策略"。
这也解释了为什么轻量级锁解锁时必须用 CAS 把 Displaced Mark Word 写回——就是为了恢复原始的 Mark Word 信息。如果 CAS 失败,说明锁已膨胀,原始信息已转移到 ObjectMonitor 中。
Mark Word 本身在对象生命周期里面表现得像 union 一样可变,是让研究 synchronized 的人最头痛的。
通常我们可以看到 thread 会维护 lock record/monitor record;monitor 会维护两种 set 和 owner(aqs 原理的原型),似乎可以被看成操作系统的 mutext lock 在 jvm 里的句柄;object 本身使用一个 object header。
Mark Word 与 Monitor 的关系
核心要点:Mark Word 只保存 Monitor 的引用(指针),而不保存 Monitor 的具体信息。
虽然 Mark Word 是一个多变的数据结构(根据锁状态动态变化),但在重量级锁状态下,它会保存指向 Monitor 对象的指针。而真正的锁管理信息——包括 Owner(当前持有锁的线程)、EntryList(阻塞等待锁的线程队列)、WaitSet(调用 wait() 后等待的线程集合)——都存储在 Monitor 这个独立的数据结构中。
Monitor(ObjectMonitor)的核心字段:
| 字段 | 类型 | 说明 |
|---|---|---|
_header |
markOop | 保存对象原始的 Mark Word(用于锁释放时恢复) |
_owner |
void* | 指向当前持有锁的线程 |
_EntryList |
ObjectWaiter* | 阻塞在 synchronized 入口处的线程链表 |
_WaitSet |
ObjectWaiter* | 调用 wait() 后进入等待状态的线程集合 |
_recursions |
intptr_t | 锁的重入次数 |
_count |
volatile intptr_t | 等待获取锁的线程数 |
图1:锁对象、Mark Word 与 Monitor 的引用关系
图2:不同锁状态下 Mark Word 的内容变化
图3:重量级锁下 Monitor 的工作机制
图4:锁状态转换与数据存储位置
关键理解:
-
Mark Word 是"指针容器"而非"数据容器":在轻量级锁和重量级锁状态下,Mark Word 不再直接存储 hashCode 等原始信息,而是存储指向其他数据结构的指针。
-
Monitor 是独立的数据结构:ObjectMonitor 是 JVM 在 C++ 层面实现的对象,它独立于 Java 对象存在。当锁膨胀为重量级锁时,JVM 会创建(或复用)一个 ObjectMonitor 对象,并将其地址写入 Mark Word。
-
原始信息的"流转":
- 无锁/偏向锁:原始信息直接在 Mark Word 中
- 轻量级锁:原始信息被拷贝到线程栈帧的 Lock Record 中
- 重量级锁:原始信息被保存到 Monitor 的
_header字段中
-
解锁时的恢复:无论是轻量级锁还是重量级锁,解锁时都需要将原始的 Mark Word 恢复回去。这就是为什么轻量级锁解锁时需要 CAS 操作——它要把 Lock Record 中保存的 Displaced Mark Word 写回对象头。
Monitor 与操作系统同步原语的关系
什么是原语(Primitive)?
在讨论锁的底层实现之前,我们需要先理解一个核心概念:原语(Primitive)。
原语的定义:
原语是指由若干条机器指令组成的、用于完成特定功能的不可分割的基本操作。"不可分割"意味着原语的执行过程不会被中断——要么全部执行完成,要么完全不执行,不存在执行到一半的中间状态。
原语的层次结构:
在计算机系统中,原语存在于不同的层次,它们之间有明确的依赖关系:
CPU 指令原语(Instruction Primitives):
CPU 指令原语是最底层的原子操作,由硬件直接保证其原子性。常见的 CPU 指令原语包括:
| 指令原语 | 架构 | 功能 | 在 Java 中的体现 |
|---|---|---|---|
| CAS(Compare-And-Swap) | x86: CMPXCHGARM: LDREX/STREX |
比较并交换:如果内存值等于预期值,则更新为新值 | Unsafe.compareAndSwapInt()AtomicInteger.compareAndSet() |
| LOCK 前缀 | x86 | 锁定总线或缓存行,确保后续指令的原子性 | CAS 指令的底层实现 |
| Memory Barrier | 所有架构 | 防止指令重排序,确保内存可见性 | volatile 的底层实现 |
| XCHG | x86 | 原子交换两个值 | 自旋锁的底层实现 |
操作系统同步原语(OS Synchronization Primitives):
操作系统基于 CPU 指令原语,提供了更高级的同步机制:
| OS 原语 | 功能 | 特点 | 对应的 Java 机制 |
|---|---|---|---|
| Mutex(互斥量) | 保证同一时刻只有一个线程进入临界区 | 阻塞式,需要系统调用 | synchronized(重量级锁) |
| Semaphore(信号量) | 控制同时访问资源的线程数量 | 可以允许多个线程同时访问 | java.util.concurrent.Semaphore |
| Condition Variable(条件变量) | 线程等待特定条件成立 | 必须与 Mutex 配合使用 | Object.wait()/notify() |
| Futex(Fast Userspace Mutex) | Linux 特有的快速互斥量 | 无竞争时在用户态完成,有竞争时才进入内核 | JVM 在 Linux 上的锁实现 |
指令原语与操作系统原语的关系:
为什么需要操作系统原语?
CPU 指令原语(如 CAS)只能实现自旋等待——线程不断循环检查条件是否满足。这种方式在等待时间短时效率很高,但如果等待时间长,会浪费大量 CPU 资源。
操作系统原语(如 Mutex)提供了阻塞等待的能力——线程可以被挂起,不占用 CPU,等条件满足时再被唤醒。这需要操作系统内核的支持,因为只有内核才能控制线程的调度。
| 等待方式 | 实现基础 | CPU 占用 | 适用场景 |
|---|---|---|---|
| 自旋等待 | CPU 指令原语(CAS) | 高(持续占用 CPU) | 等待时间极短(< 几微秒) |
| 阻塞等待 | OS 原语(Mutex) | 低(线程挂起) | 等待时间较长(> 几微秒) |
这也解释了为什么 JVM 的锁优化策略是"先自旋,后阻塞"——先用 CPU 原语尝试快速获取锁,如果失败再退化到 OS 原语进行阻塞等待。
核心问题:ObjectMonitor 是如何实现线程阻塞和唤醒的?
前面我们知道,重量级锁的核心是 ObjectMonitor,它管理着 Owner、EntryList、WaitSet 等数据结构。但 ObjectMonitor 本身只是一个 JVM 层面的 C++ 对象,它无法直接让线程阻塞或唤醒——这些操作必须依赖操作系统提供的同步原语。
图1:从 synchronized 到操作系统的完整调用链
图2:ObjectMonitor 内部的同步机制
ObjectMonitor 与 OS 原语的对应关系:
| ObjectMonitor 操作 | 对应的 OS 原语 | 说明 |
|---|---|---|
enter() 获取锁失败 |
pthread_mutex_lock() 或 futex(FUTEX_WAIT) |
线程阻塞,进入 EntryList |
exit() 释放锁 |
pthread_mutex_unlock() 或 futex(FUTEX_WAKE) |
唤醒 EntryList 中的线程 |
wait() |
pthread_cond_wait() |
释放锁,进入 WaitSet,阻塞 |
notify() |
pthread_cond_signal() |
从 WaitSet 移动一个线程到 EntryList |
notifyAll() |
pthread_cond_broadcast() |
移动 WaitSet 中所有线程到 EntryList |
图3:Linux 上的 Futex 优化机制
图4:完整的锁获取流程(从 Java 到内核)
为什么需要 Parker 这个中间层?
重要说明:Parker 不是 Java 类,而是 HotSpot JVM 中的 C++ 类,定义在
hotspot/src/share/runtime/park.hpp文件中。每个 Java 线程在 JVM 内部都有一个对应的Parker对象实例。你在 Java 代码中找不到Parker类,因为它完全存在于 JVM 的 native 层。
1 | |
Parker 的设计目的:
- 跨平台抽象:不同操作系统的阻塞原语不同(Linux 用 futex/pthread,Windows 用 Event),Parker 提供统一接口
- 性能优化:Parker 可以实现"先自旋再阻塞"的策略,减少不必要的系统调用
- 与 LockSupport 对接:Java 层的
LockSupport.park()/unpark()最终调用的就是 Parker
调用链路:
1 | |
关键理解:两层 Mutex 的区别
很多人容易混淆的一点是:ObjectMonitor 内部有一个 Mutex,操作系统也有 Mutex,它们是什么关系?
| 层级 | Mutex 用途 | 说明 |
|---|---|---|
| ObjectMonitor 内部 Mutex | 保护 ObjectMonitor 自身的数据结构 | 确保多线程并发修改 _owner、_EntryList 等字段时的一致性 |
| OS Mutex(通过 Parker) | 实现线程的阻塞和唤醒 | 当线程需要等待时,真正让 CPU 不再调度该线程 |
简单来说:
- ObjectMonitor 的 Mutex 是为了保护"锁的元数据"
- OS 的 Mutex/Futex 是为了实现"线程的阻塞"
这就像一个银行:
- 银行内部的保险柜锁(ObjectMonitor Mutex)保护的是"谁在排队、谁在办业务"的记录
- 银行大门的锁(OS Mutex)决定的是"顾客能不能进来"
锁对象复用陷阱: 由于一个对象在任意时刻只能被一个线程锁定(monitor 只有一个 owner),如果在不同的业务逻辑中复用同一个锁对象,会导致本来毫无关联的代码互相阻塞。例如:
1 | |
即使 methodA 和 methodB 的业务逻辑完全独立,它们也会互相阻塞,因为竞争的是同一个对象的 monitor。正确做法是为不相关的临界区使用不同的锁对象。这个问题同样适用于 ReentrantLock——锁的粒度由锁对象/Lock 实例的数量决定,一个锁对象 = 一把锁 = 同一时刻只能一个线程持有。
锁优化
所有的锁优化其实是 synchronized 优化。
锁优化的设计哲学:用户态锁 vs 内核态锁
核心思想:锁越轻,离底层 Mutex 机制越远,越能在 Java/JVM 内部的数据结构中解决;涉及的 JVM 外部/底层机制越少,开销就越小。
这个设计思想与 绿色线程(Green Thread)vs内核线程(Kernel Thread) 的设计理念高度相似:
| 对比维度 | 绿色线程 | 内核线程 | 轻量级锁 | 重量级锁 |
|---|---|---|---|---|
| 调度/管理者 | 用户态运行时(如 JVM、Go runtime) | 操作系统内核 | JVM(CAS + 自旋) | 操作系统(Mutex) |
| 切换开销 | 极低(不涉及内核) | 高(用户态/内核态切换) | 极低(用户态 CAS) | 高(系统调用) |
| 数据结构位置 | 用户空间 | 内核空间 | Java 栈帧(Lock Record) | 内核 Mutex + ObjectMonitor |
| 适用场景 | 大量轻量级并发 | 需要真正并行 | 低竞争同步 | 高竞争同步 |
可以说,偏向锁和轻量级锁就是"用户态锁",而重量级锁是"内核态锁"。
图:锁机制的分层架构——从用户态到内核态的渐进式下沉
锁升级的标志性事件
锁的状态转换不是随机发生的,而是由明确的、可识别的标志性事件触发。理解这些事件是理解锁优化的关键:
锁升级的标志性事件详解:
| 事件 | 触发条件 | 状态转换 | 具体表现 | 为什么触发升级 |
|---|---|---|---|---|
| 事件①:首次加锁 | 对象第一次被 synchronized |
无锁 → 偏向锁 | CAS 将当前线程 ID 写入 Mark Word | 假设锁总是被同一线程获取 |
| 事件②:第二个线程尝试获取锁 | 另一个线程尝试获取已偏向的锁 | 偏向锁 → 轻量级锁 | 撤销偏向,两个线程都创建 Lock Record,CAS 竞争 | 偏向假设被打破,需要真正的同步机制 |
| 事件③:CAS 竞争失败 | 线程 CAS 设置 Lock Record 指针失败 | 轻量级锁 → 重量级锁 | 创建 ObjectMonitor,失败线程进入 EntryList 阻塞 | CAS 失败意味着存在真正的并发竞争 |
关键理解:什么是"竞争"?
在锁优化的语境下,"竞争"有精确的定义:
| 竞争类型 | 定义 | 锁状态 | 处理方式 |
|---|---|---|---|
| 无竞争 | 始终只有一个线程访问锁 | 偏向锁 | 仅记录线程 ID,无需同步 |
| 交替访问(无真正竞争) | 多个线程访问锁,但不同时 | 轻量级锁 | CAS 总能成功,无需阻塞 |
| 真正竞争 | 多个线程同时尝试获取锁 | 重量级锁 | CAS 失败,需要阻塞等待 |
偏向锁 vs 轻量级锁的本质区别:
| 对比维度 | 偏向锁 | 轻量级锁 |
|---|---|---|
| 假设场景 | 锁始终被同一个线程获取 | 锁被多个线程交替获取,但不同时 |
| 加锁操作 | 首次 CAS,后续仅比较线程 ID | 每次都需要 CAS |
| 数据结构 | 仅 Mark Word(存线程 ID) | Mark Word + Lock Record |
| 升级触发 | 第二个线程出现 | CAS 失败(真正竞争) |
| 适用场景 | 单线程反复进入同步块 | 多线程交替进入同步块 |
为什么这种分层设计是高效的?
观察 Mark Word 在不同锁状态下的内容变化,可以发现一个清晰的局部性原则:
| 锁状态 | 数据存储位置 | 依赖层级 | 涉及的外部机制 | 性能开销 |
|---|---|---|---|---|
| 无锁 | Mark Word 直接存储 hashCode、age | 纯 Java 对象 | 无 | 无额外开销 |
| 偏向锁 | Mark Word 存储线程 ID | 纯 Java 对象 | 无(仅首次 CAS) | ≈ 0 |
| 轻量级锁 | Mark Word → Lock Record(线程栈帧) | Java 栈 + CPU CAS 指令 | CPU 原子指令 | 用户态自旋 |
| 重量级锁 | Mark Word → ObjectMonitor → OS Mutex | 操作系统内核 | 系统调用、内核调度器 | 用户态/内核态切换 |
这种设计体现了"能在用户态解决的问题,就不要下沉到内核态"的优化原则:
-
偏向锁(纯用户态,零开销):假设锁总是被同一个线程获取,直接在 Mark Word 中记录线程 ID,后续加锁只需比较线程 ID,连 CAS 都省了。这是最乐观的假设,完全在 Java 对象层面解决,不涉及任何 JVM 外部机制。
-
轻量级锁(用户态,低开销):当出现竞争时,退而求其次,使用 CAS + 自旋的方式在用户态解决。Lock Record 存储在线程栈帧中,仍然是 Java 层面的数据结构。虽然 CAS 需要 CPU 提供原子指令支持,但不涉及操作系统调用,仍在用户态完成。
-
重量级锁(内核态,高开销):当竞争激烈、自旋无法快速获取锁时,才不得不"下沉"到操作系统层面,使用 Mutex/Futex 等同步原语。此时 Mark Word 指向 ObjectMonitor,而 ObjectMonitor 内部会调用操作系统的阻塞/唤醒机制,触发用户态/内核态切换。
与绿色线程的类比深化:
| 设计理念 | 绿色线程 | 轻量级锁 |
|---|---|---|
| 核心思想 | 用户态调度替代内核态调度 | 用户态同步替代内核态同步 |
| 实现方式 | M:N 模型,多个用户态线程映射到少量内核线程 | CAS + 自旋,在用户态完成锁的获取和释放 |
| 优势 | 避免内核态切换开销,支持大量轻量级并发 | 避免系统调用开销,支持低竞争场景的高效同步 |
| 局限性 | 无法利用多核并行(除非有内核线程支撑) | 无法处理高竞争场景(必须膨胀为重量级锁) |
| 典型实现 | Go goroutine、Erlang process、Java 虚拟线程 | JVM 偏向锁、轻量级锁 |
但有一个关键区别:绿色线程可以完全替代内核线程(如 Go 的 goroutine 在大多数场景下足够),而轻量级锁不能完全替代重量级锁——当竞争激烈时,自旋会浪费大量 CPU,必须膨胀为重量级锁让线程阻塞等待。
为什么锁只能升级不能降级?
这也解释了为什么锁只能升级不能降级:一旦发现竞争激烈到需要重量级锁,说明这个锁的使用场景确实存在高并发竞争,降级回轻量级锁反而会因为频繁的 CAS 失败和自旋浪费更多 CPU 资源。这就像一个服务发现单机处理不了流量后扩容到集群,即使流量下降也不会立即缩容——因为流量模式已经证明了需要更高的处理能力。
总结:锁优化的本质是"就近原则"
1 | |
离问题发生地越近的解决方案,开销越小。 这与计算机体系结构中的"局部性原理"一脉相承:CPU 缓存比内存快,内存比磁盘快,本地调用比远程调用快——同样,用户态同步比内核态同步快。
三种锁对 Mark Word 的处理策略
理解三种锁的本质差异,关键在于理解它们如何处理 Mark Word 中的原始信息(hashCode、GC age 等):
| 锁类型 | Mark Word 处理方式 | 原始信息存储位置 |
|---|---|---|
| 偏向锁 | 原地复用 Mark Word,用 threadId 替换 hashCode 字段 | 无需备份(但 hashCode 与偏向锁互斥) |
| 轻量级锁 | Mark Word 被替换为指向 Lock Record 的指针 | Lock Record 的 Displaced Mark Word 字段 |
| 重量级锁 | Mark Word 被替换为指向 ObjectMonitor 的指针 | ObjectMonitor 的 _header 字段 |
这个设计体现了一个核心权衡:
- 偏向锁是"原地操作",不需要额外的数据结构来保存原始 Mark Word,因此最轻量。代价是 hashCode 和 threadId 互斥,调用
hashCode()会导致偏向锁失效。 - 轻量级锁和重量级锁都是"指针替换",需要将原始 Mark Word 转移到别处保存(Lock Record 或 ObjectMonitor),解锁时再恢复。这也解释了为什么轻量级锁解锁时必须用 CAS 把 Displaced Mark Word 写回——就是为了恢复原始信息。
三种锁的数据结构对比
理解三种锁的本质差异,关键在于理解它们各自的数据结构:
三种锁的本质区别总结:
| 维度 | 偏向锁 | 轻量级锁 | 重量级锁 |
|---|---|---|---|
| Mark Word 内容 | 偏向线程 ID | 指向 Lock Record 的指针 | 指向 ObjectMonitor 的指针 |
| 额外数据结构 | 无 | Lock Record(栈上,1个) | ObjectMonitor(C++ 堆,含多个队列) |
| 持锁线程记录 | Mark Word 中的 threadId | Mark Word 指向的 Lock Record 所在栈帧 | Monitor 的 _owner 字段 |
| 等待队列 | 无 | 无(自旋重试) | 有(_EntryList、_WaitSet、_cxq) |
| 底层同步原语 | 无 | CAS 指令 | OS Mutex + Condition Variable |
| 竞争失败处理 | 撤销偏向,升级 | CAS 自旋,超时则膨胀 | 阻塞,进入等待队列 |
| 适用场景 | 单线程反复获取同一把锁 | 多线程交替获取锁,竞争不激烈 | 多线程激烈竞争 |
关键理解:
-
偏向锁:最轻量,直接在 Mark Word 中记录偏向线程 ID,无任何额外数据结构。相当于"这把锁归你了,以后你来就直接进"。
-
轻量级锁:Mark Word 指向持锁线程栈上的 Lock Record。Lock Record 只保存原始 Mark Word 的备份,不包含任何等待队列。竞争失败的线程只能 CAS 自旋重试,不会被阻塞。
-
重量级锁:Mark Word 指向 ObjectMonitor,Monitor 是一个完整的同步结构,包含:
_owner:指向持锁线程_EntryList:阻塞等待获取锁的线程队列_WaitSet:调用wait()后等待被notify()的线程队列_cxq:竞争队列(新来的竞争者先进这里)- 底层封装了 OS 的 Mutex 和 Condition Variable
ObjectMonitor 的三个队列:cxq、EntryList、WaitSet 详解
ObjectMonitor 中的三个队列各有不同的职责,理解它们的区别是理解 synchronized 底层机制的关键:
| 队列 | 全称 | 用途 | 线程来源 | 数据结构 |
|---|---|---|---|---|
| _cxq | Contention Queue | 新竞争线程的入口队列 | 新到达的、尝试获取锁失败的线程 | 单向链表(LIFO) |
| _EntryList | Entry List | 等待获取锁的正式队列 | 从 cxq 转移过来的线程 | 双向链表 |
| _WaitSet | Wait Set | 调用 wait() 后等待的线程 | 调用 Object.wait() 的线程 | 双向循环链表 |
为什么需要两个入口队列(cxq 和 EntryList)?
这是一个性能优化设计:
- cxq 是"快速入口":新线程竞争锁失败时,直接 CAS 插入 cxq 头部(LIFO),这个操作非常快,不需要获取任何锁
- EntryList 是"正式队列":锁释放时,持锁线程会将 cxq 中的线程批量转移到 EntryList,然后从 EntryList 中唤醒线程
- 分离的好处:入队操作(插入 cxq)和出队操作(从 EntryList 唤醒)在不同的队列上进行,减少了竞争
为什么 ObjectMonitor 有 cxq,而 AQS 没有?
这是一个很好的问题。AQS 确实没有 cxq 这样的结构,但两者都实现了"无锁入队"的目标,只是方式不同:
| 对比项 | ObjectMonitor | AQS |
|---|---|---|
| 入队结构 | cxq(单向链表,LIFO) | sync queue(双向链表,FIFO) |
| 入队方式 | CAS 插入 cxq 头部 | CAS 设置 tail 指针 |
| 是否需要额外队列 | 需要 cxq + EntryList | 不需要,直接操作 sync queue |
| 入队复杂度 | O(1) CAS 到头部 | O(1) CAS 到尾部 |
为什么设计不同?
- ObjectMonitor 的历史原因:ObjectMonitor 是 JVM 早期设计的,cxq 的 LIFO 设计可能是为了简化实现(头插法比尾插法简单)
- AQS 的改进设计:AQS 设计时借鉴了 CLH 队列的思想,直接 CAS 到 tail,同样是 O(1) 操作,但保持了 FIFO 顺序
- 本质相同:两者都实现了"无锁入队"——新线程入队时不需要获取任何锁,只需要一次 CAS 操作
1 | |
结论:cxq 不是锁的"必要"组成部分,它只是 ObjectMonitor 实现无锁入队的一种方式。AQS 用另一种方式(CAS 到 tail)达到了同样的目的。
WaitSet 的特殊性:
WaitSet 与 cxq/EntryList 有本质区别:
- cxq/EntryList:线程在等待获取锁
- WaitSet:线程在等待某个条件(已经释放了锁)
当线程调用 wait() 时:
- 释放持有的锁(
_owner = null) - 进入 WaitSet 等待
- 被
notify()唤醒后,转移到 EntryList 或 cxq - 重新竞争锁,获取成功后从
wait()返回
为什么轻量级锁不需要等待队列?
因为轻量级锁的设计假设是"竞争很少发生"。在这种假设下,竞争失败的线程通过短暂的 CAS 自旋就能获取到锁,不需要排队等待。一旦自旋超过阈值(说明竞争激烈),就膨胀为重量级锁,此时才需要等待队列来管理大量等待的线程。
synchronized 的非公平性:完整机制分析
规范层面的说明:
Java Language Specification (JLS 17.1) 明确指出:synchronized 只保证互斥性和 happens-before 关系,不保证任何特定的获取顺序。这意味着依赖 synchronized 公平性的代码是不正确的。
非公平性的来源(HotSpot 实现):
synchronized 的非公平性来源于多个因素的组合,而不仅仅是 cxq 的 LIFO 特性:
| 因素 | 描述 | 对非公平性的贡献 |
|---|---|---|
| 新线程直接抢占 | 新线程首先尝试 CAS 获取 _owner,不先排队 |
主要因素:新线程可以"插队" |
| cxq 的 LIFO 特性 | 新竞争线程从 cxq 头部插入 | 次要因素:影响等待线程间的顺序 |
| 唤醒后重新竞争 | 被唤醒的线程需要与新线程竞争 | 次要因素:唤醒不等于获得锁 |
关键澄清:cxq 的 LIFO 不是非公平性的主要原因
一个常见的误解是认为 synchronized 的非公平性主要来自 cxq 的 LIFO 特性。实际上:
-
cxq 的 LIFO 只影响等待线程之间的顺序:它决定了 A、B、C 三个等待线程谁先被转移到 EntryList,但这只是"不公平"的一个方面。
-
真正的非公平性来自"插队"机制:即使 cxq 是 FIFO 的,只要允许新线程直接 CAS 获取锁(不先检查队列),synchronized 仍然是非公平的。
-
对比 AQS 的公平锁实现:AQS 公平锁通过
hasQueuedPredecessors()检查队列是否有等待者,如果有则不允许新线程直接获取锁。synchronized 没有这个检查。
为什么设计成非公平的?
非公平锁的吞吐量更高,原因是:
-
减少锁空闲时间:公平锁在释放锁后必须等待队列头部线程被唤醒,这期间锁处于空闲状态。非公平锁允许新线程立即获取锁,减少了空闲时间。
-
减少上下文切换:如果新线程能直接获取锁,就不需要经历"入队 → 阻塞 → 唤醒"的过程,减少了上下文切换开销。
-
利用 CPU 缓存局部性:刚释放锁的线程如果立即重新获取锁,其 CPU 缓存中的数据仍然有效,性能更好。
总结:
| 问题 | 答案 |
|---|---|
| synchronized 是公平的吗? | 否,JLS 不保证公平性 |
| 非公平性的主要来源? | 新线程可以直接 CAS 获取锁,不先排队 |
| cxq 的 LIFO 是主要原因吗? | 否,它只是次要因素,影响等待线程间的顺序 |
| 为什么设计成非公平的? | 吞吐量更高,减少锁空闲时间和上下文切换 |
锁升级全景图:从无锁到重量级锁的完整状态转换
下图展示了 synchronized 锁的完整生命周期,包括所有可能的状态转换路径:
锁升级的触发条件与详细流程:
锁状态与 Mark Word 的对应关系:
| 锁状态 | Mark Word 布局(64位) | 标志位 | 特点 |
|---|---|---|---|
| 无锁 | [unused:25][hashCode:31][unused:1][age:4][biased:1][lock:2] |
0 01 |
存储对象 hashCode |
| 偏向锁 | [threadId:54][epoch:2][unused:1][age:4][biased:1][lock:2] |
1 01 |
存储偏向线程 ID,无 hashCode 存储空间 |
| 轻量级锁 | [Lock Record 指针:62][lock:2] |
00 |
指向栈上 Lock Record |
| 重量级锁 | [ObjectMonitor 指针:62][lock:2] |
10 |
指向堆上 Monitor |
| GC 标记 | [转发指针或其他 GC 信息:62][lock:2] |
11 |
GC 期间使用 |
锁升级的不可逆性与例外:
各锁状态的性能特征对比:
| 维度 | 偏向锁 | 轻量级锁 | 重量级锁 |
|---|---|---|---|
| 获取开销 | 几乎为零(仅比较 threadId) | 一次 CAS 操作 | 可能涉及系统调用 |
| 释放开销 | 无(不修改 Mark Word) | 一次 CAS 操作 | Monitor 操作 |
| 竞争处理 | 撤销偏向(需 STW) | CAS 自旋 | 阻塞等待 |
| 适用场景 | 单线程反复获取同一锁 | 多线程交替获取,竞争不激烈 | 多线程激烈竞争 |
| JDK 15+ 默认 | 禁用 | 启用 | 启用 |
为什么 JDK 15 默认禁用偏向锁?
- 撤销成本高:偏向锁撤销需要在安全点(STW)进行,影响应用延迟
- 现代应用特征变化:容器化、微服务架构下,单线程反复获取同一锁的场景减少
- 轻量级锁足够高效:现代 CPU 的 CAS 操作已经非常快
- 简化 JVM 实现:移除偏向锁可以简化锁的实现和调试
偏向锁与 hashCode() 的特殊关系
当对象处于偏向锁状态时,调用 hashCode() 会导致偏向锁直接膨胀为重量级锁,而非回到无锁状态。这是因为:
- Mark Word 空间冲突:偏向锁的 Mark Word 存储的是 threadId(54位),没有空间存储 hashCode(31位)
- hashCode 需要持久存储:一旦计算了 hashCode,必须保存在对象头中(用于 HashMap 等场景的一致性)
- 重量级锁的解决方案:ObjectMonitor 结构中有专门的字段存储 hashCode
依据:HotSpot 源码 biasedLocking.cpp 中的 BiasedLocking::revoke_and_rebias() 方法。
最佳实践:如果对象会被用作锁,避免在加锁前调用 hashCode(),或者考虑使用 System.identityHashCode() 的替代方案。
锁消除(Lock Elimination)
什么是锁消除?
锁消除是 JIT 编译器的一项优化技术:如果 JVM 通过**逃逸分析(Escape Analysis)**发现某个锁对象不可能被其他线程访问,那么这个锁就是"多余的",可以被安全地消除。
逃逸分析的核心问题:对象会不会"逃逸"出当前作用域?
| 逃逸类型 | 说明 | 示例 |
|---|---|---|
| 不逃逸 | 对象只在方法内部使用,不会被外部引用 | 方法内的局部变量 |
| 方法逃逸 | 对象被作为参数传递或作为返回值 | return new Object() |
| 线程逃逸 | 对象可能被其他线程访问 | 赋值给静态变量、实例变量 |
只有"不逃逸"的对象上的锁才能被消除。
锁消除的工作原理
图1:锁消除的决策流程
锁消除的典型场景
场景1:StringBuffer/StringBuilder 的同步
1 | |
在这个例子中,StringBuffer sb 是一个局部变量,不会逃逸出 concatString 方法,更不可能被其他线程访问。因此,JIT 编译器可以安全地消除 append() 方法内部的同步操作。
优化后的等效代码:
1 | |
场景2:方法内部的同步块
1 | |
由于 lock 对象是方法内部创建的局部变量,每次方法调用都会创建新的对象,不可能被其他线程访问,因此这个同步块可以被完全消除。
ReentrantLock 能被消除吗?
根据 CMU 的研究论文,ReentrantLock 也可以被锁消除优化。JIT 编译器的逃逸分析不仅适用于 synchronized,也适用于 java.util.concurrent 包中的锁。
1 | |
如果 lock 对象不逃逸,JIT 编译器可以消除 lock() 和 unlock() 的调用。
但需要注意:
| 锁类型 | 消除难度 | 原因 |
|---|---|---|
| synchronized | 较容易 | JVM 内置支持,字节码层面可识别 |
| ReentrantLock | 较难 | 需要识别 lock()/unlock() 的调用模式 |
| 分布式锁 | 不可能 | 涉及外部系统,无法通过逃逸分析判断 |
如何验证锁消除是否生效?
1 | |
锁粗化(Lock Coarsening)
什么是锁粗化?
锁粗化是 JIT 编译器的另一项优化技术:如果 JVM 检测到一系列连续的加锁/解锁操作都是针对同一个锁对象,那么可以将这些操作合并为一次更大范围的加锁/解锁,从而减少锁操作的开销。
图1:锁粗化的优化过程
锁粗化的典型场景
场景1:循环内的同步
1 | |
场景2:连续的同步方法调用
1 | |
锁粗化的权衡
图2:锁粗化的利弊分析
| 场景 | 是否适合锁粗化 | 原因 |
|---|---|---|
| 连续的短临界区 | 适合 | 锁操作开销 > 临界区执行时间 |
| 循环内的同步 | 适合 | 避免大量重复的加锁/解锁 |
| 包含 I/O 操作的临界区 | 不适合 | 锁持有时间过长,严重影响并发 |
| 高并发竞争场景 | 不适合 | 锁粗化会加剧竞争 |
锁粗化 vs 锁细化
锁粗化和锁细化(Lock Splitting/Striping)是两个相反的优化方向:
JVM 自动进行锁粗化,但锁细化需要程序员手动设计。 典型的锁细化例子包括:
ConcurrentHashMap的分段锁(JDK 7)/ CAS + synchronized(JDK 8+)LongAdder的分散热点- 读写锁分离(
ReentrantReadWriteLock)
如何控制锁粗化?
1 | |
最佳实践:
- 信任 JVM 的优化:在大多数情况下,JVM 的锁粗化决策是合理的
- 避免过度优化:不要为了"帮助" JVM 而手动粗化锁,这可能适得其反
- 关注热点代码:只有被 JIT 编译的热点代码才会进行锁粗化优化
- 监控锁竞争:使用
jstack、async-profiler等工具监控锁竞争情况,判断是否需要手动调整锁粒度
锁膨胀(Lock Inflation)
本章节完整介绍 synchronized 锁的状态变化机制,包括:偏向锁、轻量级锁、重量级锁的实现原理,以及自旋与阻塞的等待机制优化。这是理解 JVM 锁优化的核心内容。
术语说明:在 JVM 和 HotSpot 源码中,描述偏向锁 -> 轻量级锁 -> 重量级锁这一过程的正式术语是 Lock Inflation(锁膨胀),而非 “Lock Escalation”。后者是数据库领域的术语,指将多个细粒度锁(如行锁)升级为粗粒度锁(如表锁)以减少锁管理开销。中文社区常用的"锁升级"(Lock Upgrade)虽然不够精确,但已被广泛接受。本文采用"锁膨胀"这一更贴近 HotSpot 实现的术语。
概述:锁状态与等待机制
在深入讨论之前,我们需要先澄清两个常被混淆的概念:
"自旋锁"这个术语在不同语境下有不同含义:
-
狭义的自旋锁(Spinlock):一种独立的锁实现,如 Linux 内核中的
spinlock_t,它完全基于自旋实现,不涉及线程阻塞。这种锁通常用于内核态或中断上下文中,因为这些场景不能进行线程调度。 -
锁的自旋优化(Spin Optimization):在传统阻塞锁的基础上,增加一个自旋阶段。线程获取锁失败后,先自旋一段时间,如果还获取不到再阻塞。这是 JVM 中 synchronized 和 ReentrantLock 采用的策略。
本文讨论的是第二种含义——锁的自旋优化。
关键理解:自旋是等待机制的优化,不是锁实现的优化
英文文献中的术语:
在学术文献和专业书籍中,这两个概念有明确的区分:
| 英文术语 | 中文 | 含义 |
|---|---|---|
| Lock Implementation | 锁实现 | 偏向锁、轻量级锁、重量级锁属于这个范畴 |
| Spinning / Spin-waiting | 自旋等待 | 获取锁失败后,线程忙循环等待 |
| Blocking / Block-waiting | 阻塞等待 | 获取锁失败后,线程被挂起 |
| Spin-then-block | 先自旋后阻塞 | JVM 采用的混合策略 |
《The Art of Multiprocessor Programming》(多处理器编程的艺术)一书中明确指出:
“Spinning is a technique where a thread repeatedly checks a condition instead of blocking. It is orthogonal to the lock implementation itself.”
(自旋是一种线程反复检查条件而不是阻塞的技术。它与锁的实现本身是正交的。)
为什么这个区分很重要?
因为:
- 偏向锁、轻量级锁、重量级锁回答的是"锁的状态如何记录、锁的持有者如何追踪"
- 自旋、阻塞回答的是"获取锁失败后,线程如何等待"
这两个问题是独立的。例如:
- 轻量级锁获取失败后,可以选择自旋等待,也可以直接膨胀为重量级锁然后阻塞
- 重量级锁获取失败后,JVM 也会先自旋一段时间,再进入阻塞
锁状态升级的完整路径:
1 | |
接下来,我们将按照这个升级路径,逐一介绍每种锁状态的实现原理。
偏向锁(Biased Lock)
偏向锁是 JDK 1.6 中引入的一项锁优化。它的目的是消除数据在无竞争情况下的同步原语,进一步提高程序的运行性能。如果说轻量级锁在无竞争的情况下使用 CAS 操作去消除同步使用的互斥量,偏向锁就是在无竞争的情况下,把整个同步过程都消除掉,连 CAS 都不做了。
偏向锁的本质:一个"写在 Mark Word 里的 volatile threadId"
理解偏向锁最直观的方式是将它类比为一个 volatile 全局变量:
1 | |
偏向锁与 volatile 变量的相似性:
| 对比维度 | volatile threadId 变量 | 偏向锁的 Mark Word |
|---|---|---|
| 存储位置 | 堆中的对象字段 | 对象头的 Mark Word |
| 可见性保证 | volatile 语义 | Mark Word 的原子读写 |
| 写入方式 | 直接赋值 | CAS(仅首次) |
| 读取开销 | 一次内存读取 | 一次内存读取 + 比较 |
| 空间占用 | 8 字节(long) | 复用 Mark Word 的 54 位 |
关键区别:偏向锁复用了 Mark Word 的空间
偏向锁的精妙之处在于:它没有额外分配一个字段来存储 threadId,而是复用了 Mark Word 中原本存储 hashCode 的空间。这就是为什么偏向锁与 hashCode 互斥——它们在 Mark Word 中占用相同的位。
1 | |
这种设计的权衡是:
- 优点:零额外空间开销,偏向锁检查只需读取 Mark Word 并比较 threadId
- 缺点:hashCode 和 threadId 互斥,调用
hashCode()会导致偏向锁失效
age 与 epoch 的本质区别
观察上述 Mark Word 结构,可以发现两个容易混淆的字段:age(4 bits)和 epoch(2 bits)。它们虽然都存储在 Mark Word 中,但属于完全不同的子系统,服务于不同的目的:
| 字段 | 位数 | 所属子系统 | 作用 | 变化时机 |
|---|---|---|---|---|
| age | 4 bits | GC 子系统 | 记录对象的分代年龄 | 每次 Minor GC 存活后 +1,达到阈值(默认 15)后晋升到老年代 |
| epoch | 2 bits | 锁子系统 | 偏向锁的"批次戳",用于批量重偏向 | 当某个类的偏向锁撤销次数超过阈值时,该类的 epoch +1 |
age 的语义:这是 GC 的概念。新生代对象每经历一次 Minor GC 且存活,age 就加 1。当 age 达到 -XX:MaxTenuringThreshold(默认 15,正好是 4 bits 能表示的最大值)时,对象会被晋升到老年代。age 与锁机制无关,它在所有锁状态下都保持不变(除非被 GC 更新)。
epoch 的语义:这是偏向锁特有的概念。每个类(Klass)维护一个 epoch 值,每个偏向锁对象的 Mark Word 中也存储一个 epoch 值。当 JVM 检测到某个类的偏向锁被频繁撤销(超过 BiasedLockingBulkRebiasThreshold,默认 20 次),会触发批量重偏向(Bulk Rebias):类的 epoch 值加 1,使得所有该类对象的旧偏向锁"过期"。后续线程获取这些对象的锁时,发现对象的 epoch 与类的 epoch 不匹配,可以直接重偏向到当前线程,而无需执行昂贵的偏向锁撤销操作。
简言之:age 是对象在 GC 中的"年龄",epoch 是偏向锁的"版本号"。两者在 Mark Word 中共存,但互不干扰。
偏向锁的偏,是偏心的。这个锁会偏向于第一个获得它的线程,如果接下来的执行过程中,该锁没有被其他线程获取,则持有偏向锁的线程将永远不需要再进行同步。从这点来看,偏向锁导致同步消除了,等同于锁消除了。但锁消除并不等同于偏向锁,可能有 JIT 自己去掉同步代码的优化。
当对象在第一次被线程锁定的时候,虚拟机会把标志位设置为"01"(至此标志位已经被用尽了)。同时使用 CAS 模式(因为此时还不能保证没有竞争)试图把线程 ID 写入 Mark Word 中(此处就真的写入线程号了)。如果 CAS 成功,那么以后再进入同步块,都不需要执行任何同步操作。
如果这个时候发生锁竞争,则会发生撤销偏向(Revoke Bias),对象会短暂回到未锁定状态,然后进入轻量级锁的竞争阶段。注意:偏向锁撤销后是先升级到轻量级锁,而不是直接膨胀为重量级锁。只有在轻量级锁竞争失败(CAS 自旋超过阈值)时,才会进一步膨胀为重量级锁。偏向锁是默认打开的,很多推荐的 JVM 配置都关掉它,因为多线程竞争很激烈的情况下,偏向锁的假定往往会失效(轻量级锁实际上也会失效)。所以可以用 -XX:-UseBiasedLocking 来关闭偏向锁。
hashCode 与偏向锁的互斥
偏向锁有一个容易被忽略但非常重要的限制:偏向锁与 identity hashCode 是互斥的。
观察 64 位 JVM 下偏向锁的 Mark Word 结构:
1 | |
可以发现:偏向锁的 Mark Word 中没有 hashCode 的存储位置。54 位被线程 ID 占用,剩余的位用于 epoch、age 和标志位。而无锁状态下的 Mark Word 结构是:
1 | |
hashCode 占用了 31 位。由于 Mark Word 空间有限,threadId 和 hashCode 无法同时存储,这导致了以下行为:
- 对象从未调用过
hashCode():可以正常进入偏向锁状态,Mark Word 存储线程 ID - 对象已经调用过
hashCode():hashCode 已经占用了 Mark Word 的相应位,无法再存储 threadId,因此直接跳过偏向锁,进入轻量级锁 - 对象已经处于偏向锁状态时调用
hashCode():偏向锁会被立即撤销。如果当前线程持有该偏向锁,则升级为轻量级锁(hashCode 存入 Lock Record 的 Displaced Mark Word);如果存在其他线程竞争,则膨胀为重量级锁(hashCode 存入 ObjectMonitor 的_header字段)
这里需要区分两种 hashCode:
- identity hashCode:由
Object.hashCode()或System.identityHashCode()返回,基于对象地址计算,需要存储在 Mark Word 中 - 重写的 hashCode:如
String.hashCode(),基于对象内容计算,不需要存储在 Mark Word 中,不影响偏向锁
实践建议: 如果一个对象主要用作锁,应避免对其调用 hashCode() 或将其放入 HashMap/HashSet,否则偏向锁优化将失效。
轻量级锁(Lightweight Lock)
轻量级锁本身是 JDK 1.6 以后才加入的新型锁机制,它名字中的"轻量级"是相对于使用操作系统互斥量来实现的传统锁而言的(Mutex 等于重量锁,在不同的场景下又称 Mutex Lock、fat lock。可以认为 OS 的系统调用提供了并发机制-线程,就会必然提供互斥量机制。)。它不是用来代替重量级锁的,用意是在多线程竞争不激烈的情况下,减少重量级锁的使用,来减少性能消耗。
轻量级锁 vs 重量级锁:到底"轻"在哪里?
要理解轻量级锁为什么"轻",首先需要理解重量级锁为什么"重"。
重量级锁的实现机制:
当 synchronized 升级为重量级锁时,JVM 会为锁对象关联一个 ObjectMonitor 对象(也称为 Monitor 或管程)。这个 Monitor 对象是 HotSpot 在 C++ 层面实现的,它内部封装了操作系统的 Mutex(互斥量) 和 Condition Variable(条件变量)。
轻量级锁的实现机制:
轻量级锁完全不使用 ObjectMonitor,而是利用线程栈帧中的 Lock Record 和 CAS 操作 来实现同步。
核心对比:轻量级锁的"轻"体现在哪里
| 对比维度 | 轻量级锁 | 重量级锁 |
|---|---|---|
| 锁状态存储 | 栈上的 Lock Record(自动管理) | 堆外的 ObjectMonitor(需要分配/回收) |
| 加锁操作 | 一次 CAS 指令(~10-20 CPU cycles) | 可能涉及系统调用(~1000+ CPU cycles) |
| 竞争失败时 | 自旋重试(仍在用户态) | 线程阻塞(park,进入内核态) |
| 解锁操作 | 一次 CAS 指令 | 可能需要唤醒等待线程(unpark) |
| 内存开销 | 无额外开销(Lock Record 随栈帧销毁) | ObjectMonitor 对象(约 200+ bytes) |
| 上下文切换 | 无 | 有(阻塞/唤醒时) |
| 适用场景 | 无竞争或轻度竞争 | 高竞争场景 |
使用 Lock Record 而非 Monitor 的原因
- 避免内存分配:Lock Record 在线程栈帧中,随方法调用自动创建和销毁,无需 GC 管理
- 避免系统调用:CAS 是 CPU 指令,不需要进入内核态
- 避免线程阻塞:轻量级锁失败时可以自旋重试,不会立即阻塞线程
- 缓存友好:栈内存通常在 CPU 缓存中,访问速度快
Lock Record 不是锁本身,Mark Word 才是
谁存了“哪个线程拥有锁”,谁就是锁。
这里有一个容易混淆的概念需要澄清:Lock Record 本身并不是锁,它只是锁状态的"证据"。
很多人会疑惑:Lock Record 存储在线程私有的栈帧中,其他线程根本访问不到,它怎么能起到锁的作用呢?这与 ObjectMonitor 不同——Monitor 在堆上(虽然是 C++ 堆),所有线程都可以访问它、竞争它。
实际上,真正的锁不是 Lock Record,而是锁对象的 Mark Word。
轻量级锁的工作原理可以这样理解:
- 锁对象的 Mark Word 是全局共享的:所有线程都可以读取堆上对象的 Mark Word
- CAS 竞争的是 Mark Word:线程通过 CAS 尝试将 Mark Word 从"无锁状态"修改为"指向自己 Lock Record 的指针"
- Mark Word 指向谁,谁就是锁的持有者:其他线程读取 Mark Word,发现它指向某个 Lock Record,就知道锁已被占用
- Lock Record 只是"持锁证明"的存储位置:它保存了原始 Mark Word 的备份,用于解锁时恢复
类比理解:
可以把这个机制类比为"停车位":
| 概念 | 类比 |
|---|---|
| 锁对象的 Mark Word | 停车位上的"占用标识牌" |
| Lock Record | 车主的"停车凭证"(存在车主口袋里) |
| CAS 操作 | 原子地在标识牌上写下自己的车牌号 |
| 竞争失败 | 发现标识牌上已经有别人的车牌号 |
停车凭证(Lock Record)确实是私有的,但停车位的标识牌(Mark Word)是所有人都能看到的。其他车主通过看标识牌就知道这个位置被谁占了,而不需要去翻别人的口袋。
Lock Record vs ObjectMonitor 的本质区别:
| 维度 | Lock Record(轻量级锁) | ObjectMonitor(重量级锁) |
|---|---|---|
| 锁状态存储 | Mark Word 存储指向 Lock Record 的指针 | Mark Word 存储指向 Monitor 的指针 |
| 竞争机制 | CAS 自旋(用户态) | Mutex 阻塞(内核态) |
| 等待队列 | 无(自旋重试) | 有(_EntryList、_WaitSet) |
| 线程阻塞 | 不阻塞(自旋消耗 CPU) | 阻塞(释放 CPU) |
| 适用场景 | 竞争少、持锁时间短 | 竞争多、持锁时间长 |
轻量级锁不需要全局可见 Monitor 的原因
因为轻量级锁的设计假设是:锁竞争很少发生。在这种假设下:
- 不需要等待队列:竞争失败的线程直接自旋重试,不需要排队
- 不需要阻塞/唤醒机制:没有线程会被挂起,也就不需要 Mutex 和 Condition Variable
- Mark Word 足以表达锁状态:只需要知道"锁被谁持有",不需要复杂的等待/通知语义
一旦竞争变得激烈(自旋超过阈值),轻量级锁就会膨胀为重量级锁,此时才需要 ObjectMonitor 来管理等待队列和线程阻塞。
ObjectMonitor 的创建时机与生命周期
当 synchronized 需要使用重量级锁时,ObjectMonitor 并非凭空 new 出来,而是按需延迟创建(Lazy Allocation)。
ObjectMonitor 的生命周期:
- 创建时机:只有当轻量级锁竞争失败,需要膨胀为重量级锁时,才会创建或获取 ObjectMonitor
- 关联方式:通过将 Mark Word 替换为指向 ObjectMonitor 的指针(锁标志位为 10)
- 回收时机:当锁不再被使用时,ObjectMonitor 可能被回收或放入池中复用
- 池化复用:HotSpot 维护了一个 ObjectMonitor 的空闲池,避免频繁分配/销毁
Mark Word 与 Monitor 的关系图:
关键理解:synchronized(obj) 的加锁过程
1 | |
这段代码的加锁过程:
- 首先尝试偏向锁(如果启用):检查 Mark Word 是否已偏向当前线程
- 偏向失败,尝试轻量级锁:在栈帧创建 Lock Record,CAS 替换 Mark Word
- 轻量级锁失败,膨胀为重量级锁:
- 创建或获取 ObjectMonitor
- 将 Mark Word 替换为指向 Monitor 的指针
- 当前线程成为 Monitor 的 owner
- 竞争失败的线程进入 Monitor 的等待队列并阻塞
小结:轻量级锁与重量级锁的核心差异
| 问题 | 答案 |
|---|---|
| 轻量级锁"轻"在哪里? | 不使用 OS 的 Mutex,只用 CAS 指令,无系统调用,无线程阻塞 |
| 为什么用 Lock Record 而不是 Monitor? | Lock Record 在栈上自动管理,无需分配/回收,CAS 操作比 Mutex 快几十倍 |
| synchronized(obj) 靠 Mark Word 引用 Monitor 吗? | 是的,重量级锁状态下 Mark Word 存储的就是指向 ObjectMonitor 的指针 |
| Monitor 是凭空 new 出来的吗? | 是延迟创建的,只有膨胀为重量级锁时才会创建,且可能从池中复用 |
"Mark Word"被设计成一个非固定的数据结构,以便在极小的空间内存储尽量多的信息。因此,它的内存布局是可变的。要动态地理解对象的数据结构,可以采用 jol 工具:
1 | |
在代码进入同步块的时候,如果此同步对象没有被锁定(锁标志位为"01"),虚拟机首先将在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,用于存储所对象目前的 Mark Word的拷贝(实际上被命名为 Displaced Markd Word)。也就是说,试图求锁的线程局部栈帧可能是不一样的。
线程栈帧与 Lock Record 的结构
要理解轻量级锁的工作原理,首先需要理解线程栈帧的结构以及 Lock Record 在其中的位置。
线程栈帧(Stack Frame)的基本结构:
每个线程在执行方法时,JVM 会为该方法创建一个栈帧(Stack Frame),栈帧是方法执行的基本单位。栈帧包含以下几个核心部分:
| 组成部分 | 说明 |
|---|---|
| 局部变量表(Local Variable Table) | 存储方法参数和方法内定义的局部变量 |
| 操作数栈(Operand Stack) | 用于执行字节码指令时的临时数据存储 |
| 动态链接(Dynamic Linking) | 指向运行时常量池中该方法的引用 |
| 方法返回地址(Return Address) | 方法正常退出或异常退出后的返回位置 |
| Lock Record(锁记录) | 仅在进入 synchronized 块时创建,用于轻量级锁的实现 |
图:线程栈帧结构与 Lock Record 的位置
Lock Record 的内部结构:
Lock Record 是轻量级锁实现的核心数据结构,它包含两个关键字段:
| 字段 | 类型 | 说明 |
|---|---|---|
| Displaced Mark Word | markOop | 对象原始 Mark Word 的备份。当获取轻量级锁时,对象的 Mark Word 会被替换为指向 Lock Record 的指针,原始信息(hashCode、GC age 等)就保存在这里。解锁时需要将其恢复回对象头。 |
| owner(对象指针) | oop | 指向被锁定对象的指针。用于标识这个 Lock Record 锁定的是哪个对象,也用于 GC 时的对象引用追踪。 |
图:轻量级锁加锁过程中 Lock Record 与对象的交互
图:Lock Record 在锁重入场景下的表现
Lock Record 放在栈帧中的原因
- 生命周期自动管理:栈帧随方法调用创建、随方法返回销毁,Lock Record 也随之自动管理,无需额外的内存分配和回收
- 线程私有:每个线程有自己的栈,Lock Record 天然是线程私有的,无需同步
- 支持锁重入:每次进入 synchronized 块都会创建新的 Lock Record,通过栈的 LIFO 特性天然支持锁重入的计数和恢复
- 快速访问:栈帧在 CPU 缓存中的命中率高,访问速度快
Lock Record 的生命周期(生老病死)
理解 Lock Record 的完整生命周期,是理解轻量级锁机制的关键:
Lock Record 生命周期的关键问题解答:
| 问题 | 答案 |
|---|---|
| 每个线程都有 Lock Record 吗? | 只有尝试获取锁的线程才会创建 Lock Record。每次进入 synchronized 块时创建,退出时销毁。 |
| 竞争失败的线程还有 Lock Record 吗? | 有。竞争失败的线程的 Lock Record 仍然存在于其栈帧中,只是它的 Displaced Mark Word 没有被"采用"——锁对象的 Mark Word 没有指向它。 |
| Lock Record 什么时候销毁? | 当线程退出 synchronized 块时,栈帧弹出,Lock Record 随之自动销毁。这是栈内存的天然特性。 |
| 锁膨胀后 Lock Record 怎么办? | 锁膨胀后,Lock Record 仍然存在,但解锁时需要按重量级锁的方式处理(唤醒等待线程等)。Lock Record 中的 Displaced Mark Word 会被转移到 ObjectMonitor 的 _header 字段。 |
轻量级锁没有 WaitSet 和 EntryList 的原因
重量级锁的 ObjectMonitor 有 _EntryList(阻塞等待队列)和 _WaitSet(wait 等待队列),但轻量级锁的 Lock Record 完全没有这些结构。这是因为轻量级锁的设计假设是"竞争很少发生",因此不需要管理等待线程。
轻量级锁 vs 重量级锁的数据结构对比:
| 数据结构 | 轻量级锁(Lock Record) | 重量级锁(ObjectMonitor) | 为什么轻量级锁不需要 |
|---|---|---|---|
| Displaced Mark Word | 有 | 有(_header 字段) |
都需要备份原始 Mark Word |
| owner 指针 | 有 | 有(_owner 字段) |
都需要记录持锁线程 |
| 重入计数 | 有(多个 Lock Record) | 有(_recursions 字段) |
都需要支持锁重入 |
| EntryList | 无 | 有 | 轻量级锁不阻塞线程,自旋重试 |
| WaitSet | 无 | 有 | 轻量级锁不支持 wait/notify |
| cxq(竞争队列) | 无 | 有 | 轻量级锁不排队,直接自旋 |
| Mutex/Condition | 无 | 有 | 轻量级锁不使用 OS 同步原语 |
自旋与阻塞:锁等待机制的优化
在理解了偏向锁和轻量级锁的实现原理后,我们需要深入讨论一个关键问题:当线程获取锁失败时,应该如何等待?
自旋优化的必要性
一个已经拥有 CPU 执行时间的线程,在求锁的时候,如果直接被阻塞(进入内核态等待),会带来显著的性能开销:
| 操作 | 开销 | 说明 |
|---|---|---|
| 用户态/内核态切换 | ≈ 1000+ CPU 周期 | 需要保存/恢复寄存器、切换特权级别 |
| 线程上下文切换 | ≈ 5000+ CPU 周期 | 需要保存/恢复线程状态、刷新 TLB |
| CAS 操作 | ≈ 10-100 CPU 周期 | 仅需原子指令,无需内核介入 |
如果锁的持有时间很短(如简单的 getter/setter),线程阻塞后很快就会被唤醒,那么阻塞和唤醒的开销可能远超过实际执行临界区代码的开销。这就是自旋锁存在的意义:用 CPU 空转换取避免线程切换的开销。
自旋锁的实现原理
自旋锁的核心思想是:当线程获取锁失败时,不立即阻塞,而是执行一个忙循环(busy waiting),不断尝试获取锁。
图:自旋锁 vs 阻塞锁的执行流程对比
忙循环是怎么实现的?
在 HotSpot JVM 中,自旋锁的实现并不是简单的 while(true) 空循环。实际的实现涉及以下几个层面:
- 字节码层面:
monitorenter指令在获取锁失败时,会进入 JVM 运行时的自旋逻辑 - JVM 运行时层面:在
ObjectMonitor::enter()方法中实现自旋逻辑 - CPU 指令层面:使用
PAUSE指令(x86)或类似指令来优化自旋
1 | |
PAUSE 指令的作用:
| 作用 | 说明 |
|---|---|
| 降低 CPU 功耗 | 告诉 CPU 当前处于自旋等待状态,可以降低时钟频率 |
| 减少总线竞争 | 避免频繁的缓存行失效(cache line invalidation) |
| 提高超线程效率 | 让出执行资源给同一物理核心上的其他超线程 |
| 避免流水线惩罚 | 防止 CPU 错误预测分支导致的流水线刷新 |
自旋次数的控制
固定自旋(JDK 6 之前):
循环的次数通常不会很多,默认是 10 次。这个次数可以通过 -XX:PreBlockSpin 参数调整。
1 | |
自适应自旋(Adaptive Spinning,JDK 6+):
固定自旋次数的问题在于:不同的锁、不同的场景,最优的自旋次数是不同的。JDK 6 引入了自适应自旋锁,让 JVM 根据运行时统计信息动态调整自旋次数:
自适应自旋的核心思想是:让 JVM 自己学习最优的自旋策略。如果对于某个锁,自旋经常能成功获取,那么下次就多自旋几次;如果自旋很少成功,那么下次就少自旋甚至直接阻塞。
自旋锁的适用场景
图:自旋锁 vs 阻塞锁的性能对比
| 场景 | 推荐策略 | 原因 |
|---|---|---|
CAS 操作(如 AtomicInteger.incrementAndGet()) |
自旋 | 操作极快(几个 CPU 周期),自旋几乎总能成功 |
| 简单 getter/setter | 自旋 | 临界区代码很短,锁持有时间极短 |
| 数据库操作、网络 I/O | 阻塞 | 操作耗时长,自旋会浪费大量 CPU |
| 高并发竞争 | 阻塞 | 多个线程同时自旋会导致 CPU 资源浪费 |
自旋锁的本质权衡:
1 | |
这也是为什么 JVM 会结合使用自旋和阻塞:先自旋一定次数,如果还获取不到锁,再阻塞。这种策略在大多数场景下都能取得较好的平衡。
锁膨胀的必然性:自旋失败是常态而非例外
理解了自旋的本质权衡后,一个重要的推论是:在存在真实竞争的场景下,从轻量级锁膨胀到重量级锁几乎是必然的。
考虑以下推理链条:
-
synchronized 的语义是无限等待:与
tryLock(timeout)不同,synchronized没有超时机制。一旦线程尝试进入同步块,它必须获取锁才能继续,否则就一直等待。 -
自旋次数是有限的:无论是固定自旋(默认约 10 次)还是自适应自旋,自旋次数都有上限。假设自旋循环执行 10-20 次,每次循环消耗约 100-200 个 CPU 周期,整个自旋过程大约持续 1-4 微秒。
-
同步块执行时间通常远超自旋时间:即使是一个简单的同步块,只要包含任何有意义的业务逻辑(如一次 HashMap 操作、一次日志记录),其执行时间通常在微秒到毫秒级别。
-
结论:如果线程 A 持有轻量级锁正在执行同步块,线程 B 尝试获取锁并开始自旋。只要同步块的执行时间超过自旋等待时间(这在绝大多数情况下都成立),线程 B 的自旋必然失败,锁必然膨胀为重量级锁。
图:自旋失败的时间竞赛
1 | |
这个分析的实践意义:
-
不要对轻量级锁抱有不切实际的期望:轻量级锁的真正价值在于处理"交替访问"(线程 A 释放锁后,线程 B 才来获取)的场景,而非"同时竞争"的场景。
-
自旋的作用是"延迟膨胀"而非"避免膨胀":自旋给了持锁线程一个短暂的窗口期来释放锁。如果持锁线程恰好在这个窗口期内释放锁,就避免了膨胀;否则膨胀不可避免。
-
自旋可以看作轻量级的"等待队列":在重量级锁中,等待线程被放入
_EntryList队列并阻塞。而在轻量级锁阶段,自旋循环实际上起到了类似的"等待"作用——只不过是忙等待而非阻塞等待。自旋失败后,线程才真正进入 ObjectMonitor 的等待队列。 -
synchronized 的"无限等待"语义决定了最终必须有阻塞机制:既然 synchronized 承诺"一定能获取锁",那么在自旋失败后,唯一的选择就是阻塞等待。这就是为什么锁升级的终点必然是重量级锁——只有重量级锁才有完整的阻塞/唤醒机制。
什么是"竞争"?如何判定竞争程度?
在锁优化的语境下,"竞争"有精确的技术定义:
竞争的判定标准:
| 竞争程度 | 判定条件 | 锁状态 | 线程行为 |
|---|---|---|---|
| 无竞争 | 始终只有一个线程访问锁 | 偏向锁 | 仅比较 threadId,零开销 |
| 轻度竞争 | 多个线程交替访问锁,但不同时 | 轻量级锁 | CAS 总能成功,无需等待 |
| 中度竞争 | 多个线程同时尝试获取锁,但持锁时间短 | 轻量级锁 + 自旋 | CAS 偶尔失败,自旋几次后成功 |
| 重度竞争 | 多个线程同时竞争,持锁时间长 | 重量级锁 | CAS 频繁失败,自旋超时,线程阻塞 |
轻量级锁如何判定"竞争发生"?
轻量级锁通过 CAS 操作的成功与否 来判定竞争:
关键理解:轻量级锁的"竞争"触发锁膨胀
轻量级锁的设计哲学是:一旦检测到真正的竞争(CAS 失败且不是锁重入),就认为轻量级锁的假设被打破,应该膨胀为重量级锁。
这与偏向锁的区别在于:
- 偏向锁:第二个线程出现就撤销偏向,升级到轻量级锁
- 轻量级锁:CAS 失败后先自旋重试,自旋超过阈值才膨胀为重量级锁
自旋的存在是为了应对"短暂竞争"的场景——如果持锁线程很快就释放锁,等待线程自旋几次就能获取锁,比阻塞后再唤醒更高效。
然后虚拟机试图使用 CAS 操作尝试将对象的Mark Word 更新为指向 Lock Record 的指针(注意是整个Mark Word)。如果更新成功了,那么线程就拥有了该对象的锁,并且 Mark Word 的**锁标志位(Markword的最后两位)**转变为“00”。如果这个更新失败了,虚拟机首先会检查对象的 Mark Word是否指向当前线程的栈帧,如果是就说明当前线程已经拥有了这个对象的锁,可以直接进入同步块继续执行,否则说明这个锁对象已经被其他线程抢占了。如果有两条以上的线程争用同一个锁,那轻量级锁就不再有效,要膨胀为重量级锁,锁标志的状态值变为“10”,Mark Word也就变成指向重量级锁的指针(也就是说,不再指向 Lock Record)。
轻量级锁的解锁过程,也必须借助 CAS 操作,把 Displaced Mark Word 的值写到 Mark Word 上。如果替换完成,同步结束。如果替换失败,证明有其他线程尝试获取过该锁,说明在持有轻量级锁期间发生了锁膨胀。此时需要按重量级锁的方式释放锁并唤醒被挂起的线程。这确实是一个"不对称"的操作——加锁时使用轻量级锁,但解锁时需要按重量级锁处理。这种设计是合理的:锁膨胀发生在持锁期间,持锁线程在解锁时必须负责处理膨胀后的状态。
轻量级锁在发生竞争时,依然会出现锁膨胀,而且还加上了CAS的开销,反而比直接使用重量级锁更慢。使用偏向锁只能根据一种经验假定,“绝大部分锁,在同步周期内是不存在竞争的”。
从这个过程我们可以看出来,mark word里并不是存了线程号,而是直接把mark word指向了目标线程的栈帧,轻量级锁和重量级锁的差别就在于底层是不是会触发 Mutex。
锁竞争的详细场景分析
前面我们介绍了偏向锁、轻量级锁、重量级锁的基本原理,本节将通过详细的场景分析来说明当多个线程同时竞争锁时,具体会发生什么。
轻量级锁竞争失败的处理策略
当线程尝试获取轻量级锁时 CAS 失败,JVM 会先自旋重试,自旋失败后才膨胀为重量级锁。
关键理解:轻量级锁的自旋发生在哪里?
| 阶段 | 锁状态 | 自旋行为 | 说明 |
|---|---|---|---|
| CAS 首次失败 | 仍是轻量级锁 | 开始自旋 | Mark Word 仍指向其他线程的 Lock Record,锁标志位仍是 00 |
| 自旋期间 | 仍是轻量级锁 | 持续 CAS 重试 | 期望持锁线程释放锁,Mark Word 恢复为无锁状态 |
| 自旋成功 | 轻量级锁 | 停止自旋 | CAS 成功,获取轻量级锁 |
| 自旋超时 | 膨胀为重量级锁 | 停止自旋 | 创建 Monitor,线程进入阻塞状态 |
HotSpot 中的实现细节:
1 | |
重要澄清:自旋发生在两个阶段
- 轻量级锁阶段的自旋:CAS 失败后,在膨胀之前,会先进行一定次数的自旋重试
- 重量级锁阶段的自旋:即使膨胀为重量级锁,在真正阻塞之前,
ObjectMonitor::enter()也会先自旋
这意味着:线程从 CAS 失败到真正阻塞,中间会经历两轮自旋机会。
偏向锁竞争:多线程场景详解
偏向锁的竞争场景比轻量级锁更复杂,因为涉及到偏向锁撤销这个需要安全点的操作。
场景1:原偏向线程仍在同步块内,其他线程尝试获取锁
场景2:原偏向线程已退出同步块,其他线程尝试获取锁
场景3:多个线程同时竞争偏向锁
这是最复杂的场景:假设对象偏向线程 A,此时线程 B、C、D 同时尝试获取锁。
多线程竞争的关键点:
| 问题 | 答案 |
|---|---|
| 谁触发偏向锁撤销? | 第一个检测到竞争的线程。其他线程会等待撤销完成。 |
| 撤销期间其他线程在做什么? | 等待安全点。所有线程都会在安全点暂停。 |
| 撤销后谁能获得锁? | 取决于原偏向线程的状态。如果原线程仍持有锁,它会继续持有(升级为轻量级锁);如果原线程已释放,则竞争线程通过 CAS 竞争。 |
| 多个竞争线程如何排序? | 轻量级锁阶段没有排序,谁 CAS 成功谁获得锁。膨胀为重量级锁后,由 Monitor 的等待队列管理。 |
批量重偏向与批量撤销
当一个类的对象频繁发生偏向锁撤销时,JVM 会采取更激进的策略:
epoch 机制详解:
每个类都有一个 epoch 值,每个偏向锁对象的 Mark Word 中也存储了一个 epoch 值。当发生批量重偏向时:
- 类的
epoch值 +1 - 当线程尝试获取偏向锁时,会比较对象的
epoch和类的epoch - 如果不相等,说明对象的偏向锁已"过期",可以直接重偏向到当前线程,无需撤销
这个机制避免了频繁的偏向锁撤销开销。
锁升级流程总结
综合以上所有内容,我们可以总结出 synchronized 锁的完整升级流程:
1 | |
锁升级的触发条件:
| 当前状态 | 升级条件 | 目标状态 |
|---|---|---|
| 无锁 | 第一个线程获取锁 | 偏向锁 |
| 偏向锁 | 第二个线程尝试获取锁(竞争) | 轻量级锁 |
| 偏向锁 | 调用 hashCode() |
轻量级锁或重量级锁 |
| 轻量级锁 | CAS 自旋超过阈值 | 重量级锁 |
| 轻量级锁 | 调用 wait()/notify() |
重量级锁 |
关键理解:
- 锁只能升级,不能降级:一旦升级为重量级锁,即使竞争消失,也不会降级回轻量级锁或偏向锁
- 偏向锁撤销需要安全点:偏向锁的撤销是一个 STW 操作,需要等待所有线程到达安全点
- 轻量级锁膨胀是即时的:轻量级锁膨胀为重量级锁不需要等待安全点,可以立即进行
安全点(Safepoint):从锁问题诊断到 JVM 底层机制
引子:锁问题诊断与日志分析
在前面的章节中,我们深入探讨了 synchronized 的锁升级机制:偏向锁、轻量级锁、重量级锁。理论知识只是第一步——当生产系统出现锁相关的性能问题时,诊断和定位的主要手段是查看日志。
但这里有一个关键的认知:锁的运行时行为,大部分是"静默"的。
| 锁操作 | 是否有日志 | 原因 |
|---|---|---|
| 偏向锁获取/释放 | 无 | 仅比较 threadId,零开销操作 |
| 轻量级锁 CAS | 无 | 用户态原子指令,不涉及 JVM 状态切换 |
| 重量级锁获取/释放 | 无 | 虽然涉及 Monitor,但记录日志开销太大 |
| 偏向锁撤销 | 有 | 必须触发安全点,这是一个"重"操作 |
| 锁膨胀 | 有限 | 可通过 -Xlog:monitorinflation 观察 |
从中可以观察到一个规律:只有那些需要"暂停世界"的锁操作,才会留下日志痕迹。
偏向锁撤销需要暂停世界并出现在日志中的原因,与 JVM 的一个底层机制密切相关——安全点(Safepoint)。
日志与安全点的关系
从一个具体的日志条目开始分析:
1 | |
这条日志告诉我们:
- JVM 进入了一个安全点区域
- 触发安全点的原因是
RevokeBias(撤销偏向锁) - 所有应用线程被暂停了 0.8 毫秒
关键洞察:偏向锁撤销之所以有日志,是因为它必须触发安全点。安全点是 JVM 的"全局暂停"机制,任何触发安全点的操作都会被记录。
讨论锁的日志诊断必须先理解安全点,两者的关系如下:
下面深入分析安全点的工作原理。
安全点的本质:JVM 的"红绿灯"
安全点是什么?
想象一个十字路口:当交通灯变红时,所有车辆都必须停下来,让行人(或其他方向的车辆)安全通过。安全点就是 JVM 的"红绿灯"——当 JVM 需要执行某些全局操作时,它会"亮红灯",让所有应用线程暂停。
安全点的必要性
GC 需要遍历堆中的所有对象,标记哪些是存活的、哪些是垃圾。但应用线程还在不断地修改对象引用——创建新对象、修改字段、调用方法。如果 GC 在遍历的同时,应用线程修改了对象引用,GC 看到的就是一个"不一致"的堆状态,可能会错误地回收存活对象,或者遗漏垃圾对象。
这就像你在数一群不断移动的羊——你数到第 50 只的时候,第 30 只可能已经跑到第 60 只的位置了。
解决方案有两种思路:
-
并发标记 + 写屏障:让 GC 和应用线程并发执行,通过写屏障(Write Barrier)记录引用的变化。这是 G1、ZGC、Shenandoah 等现代 GC 的做法,但实现复杂,且某些阶段仍需要 STW。
-
Stop-The-World(STW):让所有应用线程暂停,GC 独占 CPU 完成工作。这是最简单、最可靠的方式,但会造成应用停顿。
安全点就是实现 STW 的机制。 但这里有一个关键的洞察:
STW 依赖安全点实现,但安全点不只是为 GC 服务。
安全点是一个通用的线程协调机制,GC 只是它最常见的使用者之一。
安全点的本质:线程状态的一致性快照
安全点的定义: 程序执行过程中的一个特定位置,在这个位置上,线程的状态是完全可描述的——所有的寄存器、栈帧、局部变量、锁状态都处于一个"干净"的状态,JVM 可以安全地检查和修改它们。
换句话说,安全点是线程的一个"可拍照"时刻。在这个时刻:
- 所有 GC Roots(栈上的对象引用)都是可枚举的
- 所有锁的持有状态都是明确的
- 所有 JIT 编译的代码都可以被安全地去优化
安全点在哪里?
JVM 不会在每条字节码指令后都插入安全点检查——那样开销太大。安全点只会出现在以下位置:
| 安全点位置 | 原因 |
|---|---|
| 方法调用/返回 | 方法边界是自然的状态一致点 |
| 循环的回边(back edge) | 防止长时间运行的循环阻塞安全点(见下文详解) |
| 异常抛出点 | 异常处理需要完整的栈状态 |
| JNI 调用返回 | 从 native 代码返回时需要同步状态 |
| 线程阻塞点 | Object.wait()、Thread.sleep()、锁等待等 |
什么是循环的回边(Back Edge)?
在控制流图(Control Flow Graph)中,回边是指从循环体末尾跳回到循环头部的边。简单来说,就是循环每次迭代结束、准备开始下一次迭代时的那个"跳转点"。
1 | |
JVM 在回边处插入安全点检查的原因是:如果一个循环执行时间很长,而循环内部没有方法调用,那么线程可能长时间无法到达安全点。在回边处插入检查,可以确保每次循环迭代都有机会响应安全点请求。
但这里有一个重要的例外——可数循环(Counted Loop):
关键问题:可数循环(Counted Loop)的陷阱
1 | |
JIT 编译器会将这种"可数循环"(循环次数在编译时可确定)优化为不包含安全点检查的紧凑代码。这意味着如果 GC 请求 STW,必须等待这个循环执行完毕。这就是著名的 “Time To Safepoint”(TTSP)问题。
安全点的工作机制
Polling Page 机制详解:
HotSpot 使用一种巧妙的"陷阱"机制来实现安全点检查:
- 正常状态:Polling Page 是一个可读的内存页,线程周期性地读取它(在安全点位置插入读取指令)
- 请求 STW:VM Thread 将 Polling Page 设置为不可读(通过
mprotect系统调用) - 触发信号:当应用线程读取不可读的内存页时,会触发
SIGSEGV(段错误)信号 - 信号处理:JVM 的信号处理器捕获这个信号,让线程进入安全点并阻塞
- 恢复执行:VM 操作完成后,恢复 Polling Page 为可读,唤醒所有线程
这种设计的精妙之处在于:正常情况下,安全点检查只是一次内存读取,开销极小(通常在 L1 缓存命中)。只有在需要 STW 时,才会触发信号处理的开销。
需要安全点的 VM 操作
GC 是最常见的安全点使用者,但远不是唯一的。以下是 HotSpot 中需要安全点的主要操作:
| 操作类别 | 典型操作 | 为什么需要安全点 |
|---|---|---|
| GC | Young GC、Full GC、并发 GC 的 STW 阶段 | 需要枚举所有 GC Roots,确保堆状态一致 |
| 偏向锁 | RevokeBias、BulkRevokeBias | 需要修改对象头,遍历线程栈查找 Lock Record |
| 代码去优化 | Deoptimize | 需要将 JIT 编译的栈帧转换为解释器栈帧 |
| 类重定义 | ClassRedefinition(热替换) | 需要更新所有引用该类的代码和对象 |
| 诊断 | ThreadDump、HeapDump | 需要获取所有线程的一致状态快照 |
安全点 vs 安全区域
除了安全点,还有一个相关概念——安全区域(Safe Region)。
如果一个线程正在 Thread.sleep() 或等待 I/O,它不会执行代码,也就不会到达安全点。此时 VM Thread 不需要等待它,因为存在安全区域机制:当线程进入阻塞状态时,它会标记自己处于"安全区域"。在安全区域内,线程不会修改任何对象引用,因此 GC 可以安全地进行。
Thread Handshake:更细粒度的线程协调
传统的安全点是全局的——要么所有线程都停,要么都不停。但有些操作只需要停止特定的线程,全局 STW 就显得过于"重"了。
JEP 312: Thread-Local Handshakes(JDK 10 引入)解决了这个问题:
Thread Handshake 允许 VM 对单个线程执行操作,而不影响其他线程。这对于以下场景特别有用:
- 单个线程的栈遍历
- 单个对象的偏向锁撤销(JDK 15+ 的改进)
- 线程局部的去优化
安全点与锁的关系
STW 与安全点的关系: STW 的本质就是让所有应用线程到达安全点并阻塞。
安全点的适用范围: 安全点是一个通用的线程协调机制,GC 只是最常见的使用者。偏向锁撤销、代码去优化、类重定义、线程转储等都需要安全点。
偏向锁撤销需要安全点的原因:
- 修改对象头(Mark Word)
- 遍历偏向线程的栈帧,查找 Lock Record
- 确保没有线程正在使用这个锁
这些操作必须在目标线程"静止"的状态下进行,否则会出现竞态条件。
这也解释了为什么偏向锁在 JDK 15 被废弃——每次偏向锁撤销都需要全局安全点,在高并发场景下,频繁的安全点会严重影响性能。
偏向锁在 JVM 内部的实现实在太复杂了,从 Java 15 开始要逐步 deprecated。偏向锁的废弃历程如下:
| JDK 版本 | 偏向锁状态 | 说明 |
|---|---|---|
| JDK 6 ~ JDK 14 | 默认开启 | 需要 -XX:-UseBiasedLocking 显式关闭 |
| JDK 15 | 默认关闭 | JEP 374 将其标记为废弃,需要 -XX:+UseBiasedLocking 显式开启 |
| JDK 18+ | 废弃 | 偏向锁代码仍存在但标记为废弃,-XX:+UseBiasedLocking 会产生警告 |
废弃偏向锁的主要原因:
- 实现复杂度高:偏向锁的撤销(revocation)需要在安全点(safepoint)进行,涉及复杂的栈遍历和对象头修改
- 维护成本大:偏向锁的代码与 HotSpot 的其他子系统(如 GC、栈遍历)深度耦合,增加了代码维护难度
- 收益递减:现代应用中,无竞争的同步场景越来越少,偏向锁的优化收益不再显著
- 替代方案成熟:轻量级锁的 CAS 操作在现代 CPU 上已经足够高效
在日常的 JVM 调优中,很多团队为了避免偏向锁撤销带来的性能抖动,也会主动关闭偏向锁。
锁相关的 JVM 日志诊断
锁的状态切换(尤其是偏向锁撤销和锁膨胀)会触发 Safepoint(安全点),导致所有应用线程暂停。通过 JVM 日志可以诊断这些问题。
JDK 8 及之前版本的日志参数:
1 | |
JDK 9+ 的 Unified Logging(统一日志):
JDK 9 引入了 JEP 158: Unified JVM Logging,所有 JVM 日志统一使用 -Xlog 语法:
1 | |
典型的安全点日志输出示例:
1 | |
常见的锁相关 Safepoint 操作(vmop):
| Safepoint 操作 | 含义 | 触发场景 |
|---|---|---|
| RevokeBias | 撤销单个对象的偏向锁 | 偏向锁对象被其他线程访问 |
| BulkRevokeBias | 批量撤销某个类的所有对象的偏向锁 | 某个类的对象频繁发生偏向锁撤销,触发批量撤销阈值 |
| EnableBiasedLocking | 启用偏向锁 | JVM 启动后延迟启用偏向锁(默认延迟 4 秒) |
| Deoptimize | 去优化 | JIT 编译的代码需要回退到解释执行 |
| G1IncCollectionPause | G1 增量收集暂停 | G1 GC 的 Young GC |
| CGC_Operation | 并发 GC 操作 | CMS/G1 的并发标记等阶段 |
诊断偏向锁性能问题的实战示例:
如果你在日志中看到大量短暂但频繁的 RevokeBias 安全点:
1 | |
虽然每次暂停时间很短(约 0.1ms),但如果每秒发生数百次,累积的停顿时间就会显著影响应用的吞吐量和延迟。此时应考虑:
- 关闭偏向锁:
-XX:-UseBiasedLocking(JDK 15+ 默认已关闭) - 分析锁竞争:使用
jstack或 JFR(Java Flight Recorder)分析哪些锁存在竞争 - 优化代码:减少锁的粒度或使用无锁数据结构
JDK 17+ 的注意事项:
由于偏向锁在 JDK 15 被默认禁用并标记为废弃(JEP 374),在 JDK 15+ 中默认情况下你将不再看到 RevokeBias 和 BulkRevokeBias 相关的安全点日志。取而代之的是更多的轻量级锁 CAS 操作,这些操作不需要安全点,因此不会出现在安全点日志中。
各种锁优化的可观测性对比
除了偏向锁,其他锁优化大部分没有直接的运行时日志,因为它们要么发生在编译期,要么是纯用户态操作。
| 锁优化类型 | 是否有 JVM 日志 | 原因 | 观测方式 |
|---|---|---|---|
| 偏向锁 | 有(间接) | 偏向锁撤销必须在安全点进行,需要暂停所有线程修改对象头 | Safepoint 日志:RevokeBias、BulkRevokeBias |
| 轻量级锁 | 无 | 纯 CAS 操作,用户态原子指令,不涉及 JVM 状态切换 | jstack 查看线程状态;JFR 记录锁竞争事件 |
| 重量级锁 | 有限 | Monitor 膨胀/收缩可记录,但锁的获取/释放本身无日志 | -Xlog:monitorinflation=debug(JDK 11+);jstack 查看 BLOCKED 状态 |
| 锁消除 | 无(运行时) | JIT 编译时优化,发生在编译阶段而非运行时 | -XX:+PrintEliminateLocks(需 debug 版本);JIT 编译日志 |
| 锁粗化 | 无(运行时) | 同上,JIT 编译时优化 | JIT 编译日志 |
重量级锁的 Monitor 膨胀日志(JDK 11+):
1 | |
输出示例:
1 | |
使用 jstack 观察锁竞争:
当线程因为重量级锁而阻塞时,jstack 会显示:
1 | |
关键信息:
waiting for monitor entry:线程正在等待获取 MonitorBLOCKED (on object monitor):线程状态为阻塞waiting to lock <0x...>:等待锁定的对象地址locked <0x...>:已持有锁的对象地址
使用 JFR 记录锁竞争事件:
Java Flight Recorder 可以记录详细的锁竞争信息:
1 | |
JFR 可以记录的锁相关事件:
jdk.JavaMonitorEnter:进入 synchronized 块jdk.JavaMonitorWait:调用Object.wait()jdk.JavaMonitorInflate:Monitor 膨胀事件jdk.ThreadPark:LockSupport.park()事件(AQS 相关)
小结:大部分锁操作没有日志的原因
这是一个性能与可观测性的权衡:
- 轻量级锁的设计目标就是"零开销",如果每次 CAS 都记录日志,性能损耗会远超锁本身的开销
- 锁消除和锁粗化发生在 JIT 编译期,编译完成后就是普通的代码执行,没有"锁"的概念了
- 偏向锁撤销之所以有日志,是因为它必须触发安全点,这本身就是一个"重"操作,记录日志的边际成本很低
因此,在生产环境中诊断锁问题,通常需要结合多种工具:
- Safepoint 日志:诊断偏向锁撤销问题
- jstack:快速查看当前的锁竞争状态
- JFR:长期记录和分析锁竞争模式
- Async-profiler:采样分析锁等待时间
无锁和轻量级锁的差别是:
- 无锁是自旋修改同步资源:无锁的特点就是修改操作在循环内进行,线程会不断的尝试修改共享资源。如果没有冲突就修改成功并退出,否则就会继续循环尝试。如果有多个线程修改同一个值,必定会有一个线程能修改成功,而其他修改失败的线程会不断重试直到修改成功。上面我们介绍的CAS原理及应用即是无锁的实现。无锁无法全面代替有锁,但无锁在某些场合下的性能是非常高的。
- 轻量级锁是自旋抢锁而不是阻塞抢锁:是指当锁是偏向锁的时候,被另外的线程所访问,偏向锁就会升级为轻量级锁,其他线程会通过自旋的形式尝试获取锁,不会阻塞,从而提高性能。
如果硬要对比:
- 无锁更像是直接使用
AtomicInteger.compareAndSet()进行乐观更新,失败就重试,不涉及任何锁的概念 - 轻量级锁更像是 AQS 中
tryAcquire()的非阻塞尝试部分——通过 CAS 竞争锁,失败后自旋重试,但不会立即阻塞线程
两者的本质区别在于:无锁是对数据的 CAS 操作(修改共享变量本身),而轻量级锁是对锁状态的 CAS 操作(竞争 Mark Word 的所有权)。
从 AQS 看锁的抽象模型
在深入 LockSupport 之前,有必要先建立一个关于锁的抽象模型。这个模型将帮助我们理解:无论是 synchronized 的 ObjectMonitor,还是 ReentrantLock 的 AQS,它们本质上都在管理相同的核心信息。
锁的核心职责与次级职责
任何锁机制都需要回答以下问题:
| 职责类型 | 核心问题 | 必要性 |
|---|---|---|
| 核心职责 | 谁拥有锁?(owner) | 必须——这是锁存在的根本意义 |
| 次级职责 | 谁在等待锁?(entry queue) | 可选——可以用自旋代替队列 |
| 次级职责 | 谁在等待条件?(wait set) | 可选——只有支持条件变量时才需要 |
| 次级职责 | 锁被重入了多少次?(recursion count) | 可选——只有可重入锁才需要 |
核心职责是不可或缺的:无论锁的实现多么简化,"谁拥有锁"这个信息必须存在。偏向锁把 threadId 写入 Mark Word,轻量级锁通过 Lock Record 指针隐含 owner,重量级锁在 ObjectMonitor 的 _owner 字段显式记录——形式不同,但本质相同。
次级职责可以有不同的实现策略:
- 自旋实现:不显式维护等待队列,让竞争线程在 for 循环中反复尝试 CAS。这是轻量级锁的策略。
- 队列实现:显式维护等待线程的队列,竞争失败的线程入队并阻塞。这是重量级锁和 AQS 的策略。
AQS 如何具象化这个抽象模型
AbstractQueuedSynchronizer(AQS)是 Doug Lea 设计的同步器框架,它将上述抽象模型具象化为具体的数据结构:
AQS 与 ObjectMonitor 的对位关系:
| 抽象概念 | AQS 实现 | ObjectMonitor 实现 |
|---|---|---|
| 谁拥有锁 | exclusiveOwnerThread |
_owner |
| 锁状态/重入计数 | state |
_recursions |
| 等待获取锁的线程 | 同步队列(CLH 变体) | _EntryList + _cxq |
| 等待条件的线程 | ConditionObject 的条件队列 |
_WaitSet |
| 线程阻塞原语 | LockSupport.park() |
pthread_cond_wait() |
锁对象 → 同步器 → 线程的引用关系:
synchronized 的内存引用关系
synchronized 的内存引用关系不是简单的循环引用,而是根据锁状态有不同的实现:
关键技术说明:
| 锁状态 | MarkWord 内容 | 线程如何引用锁对象 | 是否循环引用 |
|---|---|---|---|
| 偏向锁 | 存储线程 ID(54 位) | 线程栈帧不需要持有引用 | ❌ 否 |
| 轻量级锁 | 指向 Lock Record(栈中) | Lock Record 包含对象引用字段 | ❌ 否 |
| 重量级锁 | 指向 ObjectMonitor(堆中) | 栈帧局部变量持有对象引用 | ✅ 是 |
关键技术依据(OpenJDK HotSpot Wiki):
“When a method synchronizes on an object, the header word and a pointer to the object are stored in a lock record within the current stack frame.”
这明确说明:线程栈帧直接持有锁对象的堆引用(通过 Lock Record 的 Object Reference 字段),不需要通过 MarkWord 来指回锁对象。
ReentrantLock 的内存引用关系
ReentrantLock 是纯 Java 实现,不依赖 MarkWord 机制:
关键点:
- ReentrantLock 不依赖 MarkWord:所有同步状态都存储在
volatile int state字段中 - 所有引用都是正常的 Java 对象引用:不需要 JVM 特殊支持
- 始终存在循环引用:但这在可达性分析的 GC 中不是问题
两种锁机制的对比
| 特性 | synchronized | ReentrantLock |
|---|---|---|
| MarkWord 使用 | 依赖(偏向锁和轻量级锁) | 不使用 |
| 线程引用锁对象 | 栈帧直接持有引用 | 栈帧局部变量持有引用 |
| 循环引用 | 仅重量级锁有 | 始终有 |
| 实现位置 | JVM 层面 | Java 层面 |
GC 如何处理循环引用?
Java 的 GC 使用可达性分析(Reachability Analysis)而非引用计数:
- GC Roots:线程栈、静态变量、JNI 引用等
- 回收条件:从 GC Roots 无法到达的对象才会被回收
- 循环引用:只要从 GC Roots 可以到达,循环引用不会导致内存泄漏
因此,无论是 synchronized 的重量级锁还是 ReentrantLock,循环引用都不会造成内存泄漏问题。
这个抽象模型的意义
理解这个模型有助于:
- 理解锁升级的本质:从偏向锁到重量级锁,本质上是"次级职责"的实现方式从"无"到"自旋"再到"队列"的演进
- 理解 LockSupport 的定位:
park()/unpark()是实现"队列等待"的底层原语,它让线程能够阻塞和唤醒,但不负责管理队列本身
AQS 深入解析
前面我们介绍了锁的抽象模型,现在让我们深入 AQS(AbstractQueuedSynchronizer)的具体实现。AQS 是 Doug Lea 设计的同步器框架,是 java.util.concurrent 包中大多数同步器的基础。
为什么 AQS 不使用 OS 底层的 mutex/conditionVariable
一个常见的问题是:既然操作系统已经提供了成熟的 mutex(互斥锁)和 condition variable(条件变量),为什么 AQS 要在 Java 层面重新实现一套同步机制?
答案的核心在于减少用户态与内核态之间的切换开销。
OS 原语的开销分析
使用 OS 原语的问题:
| 操作 | 开销来源 | 典型耗时 |
|---|---|---|
| 系统调用 | 用户态→内核态切换 | 数百到数千 CPU 周期 |
| 上下文保存 | 保存/恢复寄存器、栈指针 | 数十到数百 CPU 周期 |
| 调度开销 | 内核调度器决策 | 不确定,可能很长 |
| 缓存失效 | 切换后 CPU 缓存可能失效 | 数百 CPU 周期 |
AQS 的设计策略
AQS 采用了一种分层优化的策略:
AQS 的核心优化思想:
- 快路径优化:在无竞争或低竞争场景下,通过 CAS 操作在用户态完成锁的获取和释放,完全避免系统调用
- 延迟阻塞:只有在 CAS 失败且自旋无效后,才调用
LockSupport.park()进入内核态阻塞 - 精确唤醒:使用
LockSupport.unpark(thread)精确唤醒特定线程,而不是像Object.notifyAll()那样唤醒所有等待线程
synchronized 与 AQS 进入内核态的时机对比
理解两种同步机制何时进入内核态,是掌握 Java 并发性能特征的关键。
关键差异总结:
| 对比维度 | synchronized | AQS (ReentrantLock) |
|---|---|---|
| 进入内核态的触发条件 | 轻量级锁自旋失败,膨胀为重量级锁后 | CAS 失败入队,设置好 SIGNAL 状态后调用 park |
| 内核态切换的实现 | ObjectMonitor 调用 pthread_mutex_lock | LockSupport.park 调用 pthread_cond_wait 或 futex |
| 自旋策略 | 自适应自旋(JVM 根据历史成功率动态调整) | 无忙等待自旋,仅 2-3 次状态检查循环 |
| 锁状态管理 | JVM 内置,对象头 Mark Word | 用户态 volatile state 字段 |
| 唤醒机制 | notify/notifyAll(可能唤醒多个) | unpark(精确唤醒单个线程) |
简化理解:
- synchronized:偏向锁/轻量级锁阶段在用户态;竞争激烈时膨胀为重量级锁,此后每次竞争失败都进入内核态
- AQS:首次 tryAcquire 的 CAS 在用户态;失败后入队,经过 2-3 次状态设置循环后 park 进入内核态
性能对比
| 场景 | OS mutex | AQS |
|---|---|---|
| 无竞争 | 2次系统调用 | 2次 CAS(纯用户态) |
| 低竞争 | 2次系统调用 + 可能阻塞 | 几次 CAS + 短暂自旋 |
| 高竞争 | 系统调用 + 阻塞 | CAS + 自旋 + park(进内核) |
在无竞争和低竞争场景下(这是大多数实际应用的常态),AQS 的性能优势非常明显。只有在高竞争场景下,两者的开销才趋于接近。
为什么 AQS 的自旋是"短暂"的
上表中"低竞争"场景提到的"短暂自旋"需要特别说明。与 synchronized 轻量级锁的自适应自旋不同,AQS 的"自旋"本质上是入队后到真正 park 之间的状态检查循环,而非忙等待式的自旋。
关键点:AQS 最多循环 2-3 次就会 park
- 第一次循环:前驱节点状态为 0(初始状态),
shouldParkAfterFailedAcquire将其 CAS 设置为 SIGNAL,返回 false - 第二次循环:前驱节点状态已是 SIGNAL,
shouldParkAfterFailedAcquire返回 true,线程 park
这就是"短暂自旋"的真正含义:不是像 synchronized 那样在忙循环中等待锁释放,而是在设置好唤醒机制后立即阻塞。这种设计的优势在于:
| 对比项 | synchronized 自适应自旋 | AQS “短暂自旋” |
|---|---|---|
| 自旋目的 | 等待锁持有者释放锁 | 设置前驱节点的 SIGNAL 状态 |
| 自旋次数 | 动态调整(几十到几百次) | 固定 2-3 次 |
| CPU 消耗 | 高(忙等待) | 极低(仅状态检查) |
| 适用场景 | 锁持有时间短 | 所有场景 |
LockSupport.park() 与 OS 原语的关系
虽然 AQS 避免了直接使用 OS 的 mutex,但 LockSupport.park() 最终仍然依赖操作系统的线程阻塞机制:
关键区别:AQS 不是完全避免内核调用,而是尽可能延迟和减少内核调用。通过在用户态完成大部分同步逻辑,只在真正需要阻塞时才进入内核态,从而在整体上显著降低了同步开销。
state:同步状态的统一抽象
AQS 的核心是一个 volatile int state 字段,它是锁状态的统一抽象。不同的同步器对 state 有不同的解释:
1 | |
state 在不同同步器中的语义
| 同步器 | state 的含义 | 值的解释 |
|---|---|---|
| ReentrantLock | 重入次数 | 0=未锁定,1=锁定一次,n=重入n次 |
| ReentrantReadWriteLock | 读写锁状态 | 高16位=读锁持有数,低16位=写锁重入数 |
| Semaphore | 可用许可数 | n=剩余n个许可 |
| CountDownLatch | 剩余计数 | n=还需等待n个countdown |
| CyclicBarrier | 等待线程数 | 通过 ReentrantLock 间接使用 |
ReentrantReadWriteLock 的 state 位分割
ReentrantReadWriteLock 巧妙地将 32 位的 state 分成两部分:
1 | |
state 在互斥模式与共享模式的本质差异
AQS 的设计精妙之处在于:同一个 state 字段,在不同的同步器中承载完全不同的语义。理解这一点是理解 AQS 灵活性的关键。
互斥模式 vs 共享模式的 state 语义
ReentrantLock 与 Semaphore 的 state 使用对比
这两个同步器对 state 的使用方式体现了 AQS 设计的灵活性:
| 维度 | ReentrantLock | Semaphore |
|---|---|---|
| state 初始值 | 0(锁空闲) | n(n 个许可) |
| 获取时 state 变化 | 0→1 或 n→n+1(重入) | n→n-1(消耗许可) |
| 释放时 state 变化 | n→n-1 | n→n+1(归还许可) |
| state=0 的含义 | 锁空闲,可获取 | 无许可,必须等待 |
| state>0 的含义 | 锁被持有,其他线程等待 | 有许可,可以获取 |
| owner 概念 | 有,记录持有锁的线程 | 无,许可不绑定线程 |
| 可重入性 | 支持(同一线程多次获取) | 不适用(许可是资源计数) |
为什么这种设计是灵活的
AQS 的 state 设计体现了模板方法模式的精髓:
1 | |
AQS 不只是 ReentrantLock 的实现方式
一个常见的误解是把 AQS 等同于 ReentrantLock 的实现。实际上,AQS 是一个通用的同步器框架,支持多种完全不同的同步语义:
| 同步器 | 模式 | state 语义 | 典型用途 |
|---|---|---|---|
| ReentrantLock | 独占 | 重入计数 | 互斥访问 |
| Semaphore | 共享 | 许可计数 | 限流、资源池 |
| CountDownLatch | 共享 | 倒计数 | 等待多个事件完成 |
| ReentrantReadWriteLock | 混合 | 读写计数 | 读多写少场景 |
| ThreadPoolExecutor.Worker | 独占 | 0/1 | 工作线程状态 |
关键洞察:
- state 的语义完全由子类定义:AQS 只提供 CAS 操作和队列管理,不关心 state 代表什么
- 独占和共享可以混合使用:如 ReentrantReadWriteLock 同时使用两种模式
- tryAcquire 的返回值语义不同:
- 独占模式:返回 boolean(成功/失败)
- 共享模式:返回 int(负数=失败,0=成功但后续不能继续,正数=成功且后续可继续)
tryAcquireShared 返回值与唤醒传播
共享模式下 tryAcquireShared 的返回值不仅表示成功与否,还决定了是否需要传播唤醒后续等待线程:
| 返回值 | 含义 | 唤醒传播 | 典型场景 |
|---|---|---|---|
| 负数 | 获取失败 | 不传播,当前线程入队等待 | Semaphore 许可耗尽 |
| 0 | 获取成功,但资源已耗尽 | 不传播后续线程 | Semaphore 获取最后一个许可 |
| 正数 | 获取成功,还有剩余资源 | 传播唤醒后续共享节点 | Semaphore 还有剩余许可 |
这个设计的精妙之处:
- CountDownLatch:
tryAcquireShared在 state=0 时返回 1(正数),触发传播唤醒所有等待线程 - Semaphore:返回剩余许可数,如果还有许可就继续唤醒后续线程,实现"批量放行"
- ReadLock:返回 1 表示成功,允许多个读线程同时获取
1 | |
1 | |
这种设计使得 AQS 成为一个真正的同步器构建工具包,而不仅仅是某种特定锁的实现。
AQS 的两种使用思路:直接继承 vs 包装组合
在实际开发中,使用 AQS 有两种主要的思路,它们代表了不同层次的抽象和复杂度。
思路一:直接继承 AQS 实现自定义同步器
这是底层框架实现者的方式,适用于需要创建新的同步器类型时。
使用场景:
- 实现全新的同步器(如 JUC 框架开发者)
- 需要完全控制同步语义
- 对性能有极致要求
实现步骤:
- 继承
AbstractQueuedSynchronizer - 重写核心方法(
tryAcquire、tryRelease或tryAcquireShared、tryReleaseShared) - 定义
state的语义 - 提供对外 API(通常通过组合方式暴露)
示例:实现一个简单的互斥锁
1 | |
JUC 框架中的例子:
| 同步器 | 继承方式 | 实现的 AQS 方法 |
|---|---|---|
| ReentrantLock.Sync | 内部类继承 AQS | tryAcquire, tryRelease |
| Semaphore.Sync | 内部类继承 AQS | tryAcquireShared, tryReleaseShared |
| CountDownLatch.Sync | 内部类继承 AQS | tryAcquireShared, tryReleaseShared |
| ReentrantReadWriteLock.Sync | 内部类继承 AQS | 独占+共享模式混合实现 |
1 | |
思路二:包装现有 AQS 同步器
这是应用开发者的方式,适用于使用已有的同步器构建更复杂的同步工具。
使用场景:
- 构建业务特定的同步工具
- 组合多个同步器实现复杂协调
- 快速实现线程安全的数据结构
实现步骤:
- 组合现有同步器(如
ReentrantLock、Semaphore等) - 在其基础上添加业务逻辑
- 提供更高层次的抽象接口
示例:实现一个限流器
1 | |
示例:实现一个阻塞队列
1 | |
两种思路的对比
| 对比维度 | 直接继承 AQS | 包装现有同步器 |
|---|---|---|
| 抽象层次 | 底层(框架级别) | 高层(应用级别) |
| 复杂度 | 高(需理解 AQS 内部机制) | 低(使用已有的高级抽象) |
| 灵活性 | 极高(完全控制同步语义) | 中等(受限于基础同步器的功能) |
| 开发成本 | 高(需实现队列、状态管理等细节) | 低(直接使用现成组件) |
| 适用对象 | JUC 框架开发者、库作者 | 应用开发者、业务开发 |
| 典型例子 | ReentrantLock、Semaphore、CountDownLatch | 阻塞队列、线程池、并发集合 |
| 性能 | 可优化到极致 | 取决于基础同步器 |
| 可维护性 | 需要深入理解并发原理 | 更容易理解和维护 |
文章中提到的同步器的使用思路分析
回顾文章中提到的同步基础设施:
| 同步器 | 使用思路 | 说明 |
|---|---|---|
| ReentrantLock | 直接继承 AQS | 内部类 Sync 继承 AQS,重写 tryAcquire 和 tryRelease |
| Semaphore | 直接继承 AQS | 内部类 Sync 继承 AQS,重写 tryAcquireShared 和 tryReleaseShared |
| CountDownLatch | 直接继承 AQS | 内部类 Sync 继承 AQS,实现共享模式的倒计数 |
| ReentrantReadWriteLock | 直接继承 AQS | 内部类 Sync 继承 AQS,混合使用独占和共享模式 |
| CyclicBarrier | 包装 ReentrantLock | 内部使用 ReentrantLock 和 Condition,不直接继承 AQS |
关键发现:
- JUC 框架中的核心同步器(ReentrantLock、Semaphore、CountDownLatch)都是直接继承 AQS 的实现
- 更高级的同步工具(CyclicBarrier、ThreadPoolExecutor、BlockingQueue)通常是包装现有同步器的实现
- 这体现了分层设计的原则:底层提供基础构建块,上层组合构建复杂工具
两种思路的优缺点深度分析
直接继承 AQS 的优缺点
优点:
-
极致的性能优化
- 可以针对特定场景优化同步逻辑
- 避免不必要的抽象层开销
- 精确控制队列行为
-
完全的语义控制
- 可以实现任意复杂的同步语义
- 不受限于现有同步器的功能
- 可以自定义公平性、超时、中断等行为
-
框架级别的复用
- 创建的可复用的同步器组件
- 可以被其他开发者包装使用
- 成为 JUC 生态系统的一部分
缺点:
-
高学习成本
- 需要深入理解 AQS 的内部机制
- 需要理解 CLH 队列、waitStatus、节点状态等概念
- 容易出现并发 bug(死锁、活锁、饥饿等)
-
开发复杂度高
- 需要正确实现多个钩子方法
- 需要处理中断、超时、取消等边缘情况
- 测试和验证困难
-
维护成本高
- 代码难以理解和维护
- 需要并发专家级别的知识
- 文档和注释要求高
适用场景:
- ✅ 实现 JUC 框架级别的同步器
- ✅ 需要特殊的同步语义(如 StampedLock)
- ✅ 对性能有极致要求的场景
- ✅ 开发可复用的并发库
- ❌ 一般业务应用开发
- ❌ 快速实现简单的同步需求
包装现有同步器的优缺点
优点:
-
开发简单快速
- 直接使用成熟的同步器
- 专注于业务逻辑而非同步细节
- 代码简洁易懂
-
可靠性高
- 基于经过充分测试的组件
- 避免常见的并发陷阱
- 降低 bug 风险
-
易于维护
- 代码可读性好
- 团队成员容易理解
- 便于调试和优化
缺点:
-
性能可能不是最优
- 多层抽象可能带来开销
- 无法针对特定场景优化
- 受限于基础同步器的实现
-
灵活性受限
- 只能实现基础同步器支持的功能
- 无法改变核心同步语义
- 某些特殊需求无法满足
适用场景:
- ✅ 大部分业务应用开发
- ✅ 快速实现线程安全的数据结构
- ✅ 组合多个同步器实现复杂协调
- ✅ 团队成员并发经验有限
- ❌ 需要特殊的同步语义
- ❌ 对性能有极致要求
最佳实践建议
选择直接继承 AQS 的情况:
1 | |
选择包装现有同步器的情况:
1 | |
决策树:
关键原则:
- 优先使用现有同步器:除非有明确的需求,否则不要直接继承 AQS
- 组合优于继承:包装现有同步器通常比直接继承更灵活
- 渐进式复杂度:从简单的包装开始,遇到限制时再考虑继承
- 团队技能匹配:选择团队有能力理解和维护的方式
- 文档和测试:无论选择哪种方式,都要充分测试和文档化
CLH 队列:等待线程的组织方式
AQS 使用一个 CLH 队列的变体 来管理等待获取锁的线程。CLH(Craig, Landin, and Hagersten)队列是一种基于链表的自旋锁队列。
Node 节点结构
1 | |
waitStatus 状态转换
| waitStatus | 值 | 含义 |
|---|---|---|
| CANCELLED | 1 | 线程已取消等待(超时或中断) |
| SIGNAL | -1 | 当前节点释放锁时需要唤醒后继节点 |
| CONDITION | -2 | 节点在条件队列中等待 |
| PROPAGATE | -3 | 共享模式下,释放操作需要传播 |
| 0 | 0 | 初始状态 |
同步队列结构
关键设计点:
- head 是哨兵节点:head 节点的 thread 字段为 null,它代表当前持有锁的线程(或初始的空节点)
- 双向链表:支持高效的节点取消和遍历
- FIFO 顺序:新节点从 tail 入队,从 head 后继出队
- waitStatus 的传递:节点的 waitStatus 表示的是后继节点的状态
为什么同步队列是双向链表,而条件队列是单向链表?
这是一个经常被问到的设计问题。AQS 中有两种队列:
| 队列类型 | 数据结构 | 链接方式 | 原因 |
|---|---|---|---|
| 同步队列(Sync Queue) | 双向链表 | prev + next | 需要支持节点取消时的向前遍历 |
| 条件队列(Condition Queue) | 单向链表 | nextWaiter | 只需要 FIFO 顺序转移到同步队列 |
同步队列为什么必须是双向的?
当 Node 2 被取消时,需要:
- 向前遍历:找到前面第一个未取消的节点(Node 1)
- 更新链接:让 Node 1 的 next 指向 Node 3
- 唤醒后继:如果需要,唤醒 Node 3
1 | |
如果没有 prev 指针会怎样?
- 取消节点时,无法高效地找到前驱节点
- 只能从 head 开始遍历整个队列,时间复杂度从 O(1) 变为 O(n)
- 在高并发场景下,这会严重影响性能
条件队列为什么只需要单向?
条件队列的操作特点:
- 只在持锁时操作:
await()和signal()都需要先获取锁,因此不存在并发修改 - 只需要 FIFO 转移:
signal()只需要将头节点转移到同步队列 - 不需要取消处理:条件队列中的节点不会被"取消",只会被转移
1 | |
为什么不使用循环队列?
| 设计选择 | 循环队列 | 非循环链表 |
|---|---|---|
| 内存分配 | 需要预分配固定大小数组 | 按需动态分配节点 |
| 容量限制 | 有最大容量限制 | 无限制(受内存限制) |
| 节点取消 | 需要移动元素或标记空洞 | 直接修改指针 |
| 适用场景 | 固定大小、高频入队出队 | 动态大小、需要灵活删除 |
AQS 选择非循环链表的原因:
- 等待线程数量不可预测:无法预先确定队列大小
- 节点取消是常见操作:超时、中断都会导致节点取消
- 内存效率:只在需要时分配节点,不浪费空间
与 FutureTask Treiber Stack 的对比
AQS 和 FutureTask 都需要管理等待线程,但采用了截然不同的数据结构:
| 维度 | AQS CLH Queue 变种 | FutureTask Treiber Stack |
|---|---|---|
| 结构 | 双向链表(prev + next) | 单向链表(仅 next) |
| 入队顺序 | FIFO(先入先出) | LIFO(后入先出) |
| 节点摘除 | 支持任意节点 cancel 后摘除 | 不支持中间节点摘除(只标记 thread=null) |
| 内存开销 | 较高(每节点 2 个指针 + waitStatus) | 较低(每节点 1 个指针) |
| 适用场景 | 锁竞争(需公平性、可取消) | 结果等待(无公平性要求、节点数少) |
核心差异:传递唤醒 vs 广播唤醒
AQS 和 FutureTask 采用不同数据结构的根本原因在于唤醒机制不同:
AQS 场景(传递唤醒,必须物理摘除):
1 | |
FutureTask 场景(广播唤醒,标记删除即可):
1 | |
为什么设计不同:
- AQS 是「传递唤醒」:A 只唤醒下一个,链式传递,中间断链会死锁,所以必须物理摘除取消节点
- FutureTask 是「广播唤醒」:
finishCompletion()遍历所有节点,跳过无效节点即可,无需物理摘除
获取锁的核心流程
以独占模式(如 ReentrantLock)为例,获取锁的核心流程如下:
1 | |
acquireQueued:在队列中自旋等待
1 | |
shouldParkAfterFailedAcquire:判断是否需要阻塞
1 | |
这个方法的设计精妙之处:
- 延迟设置 SIGNAL:只有在确定要阻塞时才设置前驱的 SIGNAL 状态
- 跳过已取消节点:自动清理队列中的无效节点
- 返回 false 触发重试:设置 SIGNAL 后返回 false,让调用者再尝试一次获取锁
释放锁的核心流程
1 | |
1 | |
条件队列:Condition 的实现
AQS 还支持条件变量,通过内部类 ConditionObject 实现。每个 Condition 对象维护一个独立的条件队列。
1 | |
同步队列与条件队列的关系
关键区别:
| 特性 | 同步队列 | 条件队列 |
|---|---|---|
| 用途 | 等待获取锁 | 等待条件满足 |
| 结构 | 双向链表 | 单向链表 |
| 入队条件 | 获取锁失败 | 调用 await() |
| 出队条件 | 获取锁成功 | 被 signal() 或中断 |
| 节点状态 | SIGNAL, CANCELLED 等 | CONDITION |
await 和 signal 的完整流程
公平锁与非公平锁
AQS 本身不区分公平与非公平,这由具体的同步器实现决定。以 ReentrantLock 为例:
1 | |
| 特性 | 公平锁 | 非公平锁 |
|---|---|---|
| 获取顺序 | 严格 FIFO | 允许插队 |
| 吞吐量 | 较低 | 较高 |
| 饥饿风险 | 无 | 有(但概率低) |
| 上下文切换 | 较多 | 较少 |
| 适用场景 | 对公平性有要求 | 大多数场景 |
非公平锁的详细机制:唤醒 head 的问题
用户可能会有一个疑问:非公平锁是否免去了唤醒队列 head 的操作?
答案是:不是的,非公平锁仍然会唤醒 head,只是允许新线程在 head 被唤醒之前抢先获取锁。
让我们通过时序图来详细说明:
关键理解:
- 非公平锁仍然会唤醒 head:释放锁时,
unparkSuccessor()仍然会被调用,head 节点的线程会被唤醒 - 新线程可以"插队":在 head 被唤醒的过程中(这需要时间),新到达的线程可以直接尝试 CAS 获取锁
- head 被唤醒后可能失败:如果新线程抢先获取了锁,head 唤醒后 CAS 会失败,需要重新 park
为什么非公平锁吞吐量更高?
AQS 中非公平锁的实现:
1 | |
synchronized 的非公平性:
synchronized 也是非公平的,其机制类似:
- 持锁线程释放锁时,会从 EntryList 或 cxq 中唤醒一个线程
- 新到达的线程可以直接尝试 CAS 获取
_owner - 如果新线程抢先成功,被唤醒的线程会重新进入等待状态
总结:
| 方面 | 公平锁 | 非公平锁 |
|---|---|---|
| 是否唤醒 head | 是 | 是(仍然唤醒) |
| 新线程能否插队 | 否(先检查队列) | 是(直接 CAS) |
| head 唤醒后 | 一定能获取锁 | 可能失败,需重新等待 |
| 锁空闲时间 | 较长(等待唤醒) | 较短(可被插队者利用) |
小结
AQS 的设计体现了几个重要的工程智慧:
- state 的多态性:一个 int 字段,通过不同的解释方式支持多种同步语义
- CLH 队列的变体:结合了 CLH 队列的低竞争特性和双向链表的灵活性
- 模板方法模式:
acquire/release定义骨架,tryAcquire/tryRelease由子类实现 - 条件队列的分离:将"等待锁"和"等待条件"分成两个队列,职责清晰
- 公平性的可选:通过简单的
hasQueuedPredecessors()检查实现公平锁
理解 AQS 的实现细节,有助于:
- 正确使用
ReentrantLock、Semaphore等同步器 - 在需要时实现自定义的同步器
- 分析和解决并发问题
AQS 源码深度分析
前面我们介绍了 AQS 的核心设计和基于 AQS 的同步器实现。本节将深入 AQS 的源码实现细节,帮助读者理解其内部工作机制。
AQS 的五层结构
从整体架构来看,AQS 框架可以分为五层,自上而下由浅入深:
上图中有颜色的为 Method,无颜色的为 Attribution。
总的来说,AQS 框架共分为五层,自上而下由浅入深,从 AQS 对外暴露的 API 到底层基础数据。
同步队列与条件队列
AQS 内部维护两种队列:同步队列(Sync Queue) 和 条件队列(Condition Queue)。
Condition 有一个单独的 condition queue,和 main queue 使用同一批节点,但使用 additional link。
为什么同步队列是 CLH 变体,而条件队列不是?
这是一个很好的问题。两种队列的设计差异源于它们面临的不同需求:
| 对比项 | 同步队列(Sync Queue) | 条件队列(Condition Queue) |
|---|---|---|
| 队列类型 | CLH 变体(双向链表) | 普通单向链表 |
| 链接方式 | prev + next 双向链接 |
只有 nextWaiter 单向链接 |
| 是否需要取消 | ✅ 需要支持取消(如 tryLock(timeout)) |
❌ 不需要,await 被中断时直接转移到同步队列 |
| 是否需要从中间移除 | ✅ 需要(取消的节点需要从队列中移除) | ❌ 不需要 |
| 遍历方向 | 需要双向遍历(找前驱、找后继) | 只需单向遍历(signal 时从头开始) |
CLH 变体的核心特性:
原始的 CLH 队列是单向的,每个节点只需要知道前驱。但 AQS 的同步队列做了以下改进:
- 添加了
next指针:用于唤醒后继节点 - 添加了
prev指针:用于取消时找到有效的前驱节点
为什么同步队列需要支持取消?
1 | |
当 tryLock 超时或被中断时,当前线程的节点需要从同步队列中移除。这需要:
- 找到前驱节点(通过
prev) - 将前驱的
next指向当前节点的后继 - 将后继的
prev指向当前节点的前驱
为什么条件队列不需要 CLH 变体?
条件队列的操作模式更简单:
- await:将节点加入条件队列尾部(只需
nextWaiter) - signal:将节点从条件队列头部移除,转移到同步队列
- 中断处理:如果 await 被中断,节点直接转移到同步队列,不需要从条件队列中间移除
1 | |
由于条件队列只需要 FIFO 的入队和出队操作,不需要从中间移除节点,所以简单的单向链表就足够了。
Node 节点的详细结构
Node 的构造器有两种形式:一个需要 waitStatus,一个需要 nextWaiter,但都需要 Thread。
1 | |
独占模式源码详解
addWaiter:入队操作
这是一个 AQS 自身的成员方法,会生成一个节点,加入到自身的队列:
1 | |
在外面虽然有一个 enqued 的操作,但实质上对 tail 的修改——也就是入队,是被收敛到 enq(node) 这个方法调用里的,这一个 addWaiter 方法里完成了全部的入队操作。
enq:完整入队
1 | |
acquireQueued:在队列中自旋等待
这个方法提供了一个内部的自旋和 park 和从 unpark 中醒来的全部流程:
1 | |
shouldParkAfterFailedAcquire:判断是否需要阻塞
| waitStatus | 值 | 含义 | 设置时机 |
|---|---|---|---|
| 0 | 0 | 初始状态 | 节点创建时 |
| -1 | SIGNAL | 后继节点需要被唤醒 | 后继节点入队时设置前驱为 SIGNAL |
| -2 | CONDITION | 节点在条件队列中 | await() 时创建条件节点 |
| -3 | PROPAGATE | 共享模式下传播唤醒 | releaseShared 时设置 |
| 1 | CANCELLED | 节点已取消 | 超时、中断、异常时 |
cancelAcquire:取消获取锁
cancelAcquire 方法在节点取消时被调用,它需要处理三种不同的场景:
| 场景 | 条件 | 处理方式 | 效果 |
|---|---|---|---|
| 尾节点 | node == tail | CAS 更新 tail,清空 pred.next | 直接移除尾节点 |
| 中间节点 | pred != head && pred 有效 | CAS 连接 pred.next → node.next | 跳过当前节点 |
| head 后继 | pred == head 或 pred 无效 | unparkSuccessor(node) | 唤醒后继让其重新竞争 |
公平锁与非公平锁流程对比
关于公平锁/非公平锁的经典面试题:
-
ReentrantLock 和 synchronized 默认是公平还是非公平?
- 答:都是非公平。synchronized 偏向锁/轻量级锁/重量级锁的升级过程本身就是带有竞争和插队的,重量级锁底层 ObjectMonitor 的 EntryList 竞争也不保证顺序。
-
什么情况下应该强制使用公平锁?
- 答:当持有锁的时间相对较长,或者业务上强烈要求"先来后到"的顺序(例如按顺序打印、按顺序处理请求),且不在乎吞吐量下降时。
-
非公平锁会导致"线程饥饿"吗?如何解决?
- 答:理论上会,如果不断有新线程插队,队列里的线程可能一直等。但在 AQS 设计中,一旦插队失败进入队列,就只能排队,所以长期来看不会完全饿死,只是等待时间变数大。如果必须避免,请用公平锁。
-
AQS 中 hasQueuedPredecessors() 的作用是什么?
- 答:这是公平锁判断"是否需要排队"的核心逻辑。如果队列不为空且头节点的后继不是当前线程,说明有人在排队,当前线程必须放弃 CAS,去排队。
独占与共享模式
Condition 条件队列详解
从 Doug Lea 的原始设计思路来讲,一个 Condition 是要引出一组多线程共用的 wait-set。它和锁的抽象实现了 arbitrary lock 和 condition implementation 的正交组合。
Where a Lock replaces the use of synchronized methods and statements, a Condition replaces the use of the Object monitor methods. 在原始的 Java 锁机制里面,synchronized 被叫作 synchronized methods,而 condition 被叫作 monitor methods。
条件有很多种叫法,可以叫作 condition queue,也可以叫作 condition variable。一个线程的使用语序总是先 wait,然后再由外部 notify,由内外协同来实现对执行上下文的切换。
ConditionObject 的结构
1 | |
我们可以看到,Node 仍然是 AQS 的 node,但它们的 ws 是 Node.CONDITION。
条件等待的三种形式
- interruptible waiting:会抛出中断异常
- non-interruptible waiting:不会抛出中断异常
- timed waiting:带超时的等待
不管采用哪种形式,从 await 中返回过来,这个线程要 guaranteed to hold this lock。
CountDownLatch 源码分析
CountDownLatch 是一个 one-shot phenomenon,它的 state 是不能被复用的。
这个类的功能底层依赖于自身的 sync 的两个实现:
1 | |
await 的入队过程
在互斥类的 acquire 里面,只有 state 不为 0(已被其他线程获取锁)会导致入队。在共享类的 acquire 里,只要 state 不为 0,也入队,反复自旋,直到 state 为 0 才导致出队,让 await 降为 0。
countDown 的释放过程
countDown() -> releaseShared(1) -> tryReleaseShared(1) -> doReleaseShared()
CyclicBarrier 详解
cyclic 既有周期的意思,也有循环的意思。
CyclicBarrier 的源码实现和 CountDownLatch 大相径庭,CountDownLatch 基于 AQS 的共享模式的使用,而 CyclicBarrier 基于 Condition 来实现。
CyclicBarrier 和 CountDownLatch 的区别
- CyclicBarrier 使用互斥而不是共享机制来实现多线程协同,共享状态是由 party 生成的 count。CyclicBarrier 依托 Condition。
- CountDownLatch 通过共享而不是互斥来实现多线程协同,共享状态是 AQS 的 state。CountDownLatch 有自己的 sync。
- CyclicBarrier 只需要每个内线程做完自己的事自动 await,不需要 signal,到齐了自动就通过;CountDownLatch 让内线程 countDown。CyclicBarrier 外线程等待这个到齐结果 join 所有内线程;CountDownLatch 对 latch 进行 await。
CyclicBarrier 状态转换
Semaphore 源码分析
| 特性 | Semaphore | CountDownLatch | CyclicBarrier |
|---|---|---|---|
| state 语义 | 可用许可数 | 剩余计数 | 等待线程数 |
| 增减方向 | 双向(acquire-/release+) | 单向(只减) | 单向(只减) |
| 可重用 | 是 | 否 | 是(自动重置) |
| 典型场景 | 资源池限流 | 等待多任务完成 | 多线程同步点 |
Semaphore 使用数字维护一个共享状态池,使用共享加解锁的思路来修改 state。
创建 Semaphore 实例的时候,需要一个参数 permits,这个基本上可以确定是设置给 AQS 的 state 的,然后每个线程调用 acquire 的时候,执行 state = state - 1,release 的时候执行 state = state + 1,当然,acquire 的时候,如果 state = 0,说明没有资源了,需要等待其他线程 release。
官方示例
作者认为这是一个 permit pool:
1 | |
在这个示例里对称很重要,在 op 以前就要 acquire permit,在 op 成功以后才 release permit。它的 state 恰好不是以互斥的方式 accumulated 的,是以共享的方式 accumulated 的。
mutex = binary semaphore = nonreentrant exclusive lock
这种锁是不计较 owner 的,在死锁恢复场景下尤其有用。比如有个线程 a 拿走了一个 permit 没有还,其他线程可以通过把自己的 permit 归还,来暂时制造流动性。
构造器
因为信号量也是不易察觉的"类锁"的方案,所以它也有公平和非公平的实现:
1 | |
不公平抢锁可以减少调度,被称作 barging;公平抢锁可以减少饥饿。
获取资源:四大 acquire 方法
1 | |
有可中断和不可中断两大类 API,也有无参数和固定参数两大 API,不过 API 命名的习惯没有得到遵循,默认的 Semaphore 的方法是响应中断的。
tryAcquireShared 的公平与非公平实现
公平实现:
1 | |
非公平实现:
1 | |
公平和非公平的差别只在 hasQueuedPredecessors 的调用结果里。
释放资源
不管用什么方式获取,释放总是用同一个方法:
1 | |
LockSupport:线程阻塞的基础设施
有了上述抽象模型的铺垫,我们现在可以深入理解 LockSupport。它是 JDK 提供的线程阻塞原语,是 AQS 等高级同步器的基础设施。
AQS 与 LockSupport 的关系
在深入细节之前,先澄清一个常见的误解:
AQS 不是 LockSupport 的"封装",而是 LockSupport 的"使用者"。
关键理解:LockSupport 的职责边界
| 层次 | 组件 | 管理的状态 | 不管理的状态 |
|---|---|---|---|
| LockSupport | park()/unpark() |
线程是否阻塞(permit: 0/1) | 锁被谁持有、重入次数、等待队列 |
| AQS | 同步器框架 | state、owner、CLH 队列 | 线程如何阻塞(委托给 LockSupport) |
| ReentrantLock | 具体锁 | 公平/非公平策略 | 底层状态管理(委托给 AQS) |
LockSupport 的使用者不只有 AQS:
ForkJoinPool直接使用LockSupport.park()实现工作窃取的等待Phaser在某些路径上直接使用LockSupport- 用户代码也可以直接使用
LockSupport实现自定义的线程协调
因此,正确的理解是:LockSupport 是一个通用的线程阻塞原语,它只管理"线程是否让出 CPU"这一件事。锁的状态(谁持有锁、重入次数、等待队列)由上层的 AQS 或其他同步器管理。AQS 是 LockSupport 最重要的使用者之一,但不是唯一的使用者,更不是它的"封装"。
本节将从以下维度全面剖析 LockSupport:
- API 概览:park/unpark 的基本语义与设计哲学
- permit 机制:解决唤醒丢失问题的核心设计
- 与其他阻塞方式的对比:sleep、wait/notify、park/unpark 的本质差异
- blocker 参数:诊断与可观测性支持
API 概览:park/unpark 的设计哲学
LockSupport 提供了一组静态方法,用于阻塞和唤醒线程:
1 | |
设计哲学的核心体现:
| 设计决策 | 体现 | 原因 |
|---|---|---|
| 阻塞是自愿的 | park() 总是阻塞调用它的当前线程 |
线程只能阻塞自己,不能强制阻塞别人 |
| 唤醒是外部的 | unpark(Thread) 必须指定目标线程 |
线程需要被别人唤醒,必须知道唤醒谁 |
| 诊断与功能分离 | blocker 参数仅用于诊断,不影响行为 | 核心逻辑保持简洁,诊断作为可选旁路 |
| 不抛受检异常 | park 不抛 InterruptedException | 作为底层原语,让上层决定如何处理中断 |
park 与 sleep/wait 的对比
从 API 形态上看,LockSupport.park() 与 Thread.sleep() 存在显著的相似性:两者都是使当前线程进入阻塞状态的静态方法,都不需要持有任何锁,都可以响应中断。然而,它们在语义设计上存在本质差异。
API 签名对比
1 | |
核心差异分析
| 维度 | Thread.sleep() |
LockSupport.park() |
|---|---|---|
| 唤醒机制 | 仅时间到期或中断 | unpark()、中断、虚假唤醒、时间到期 |
| 配对方法 | 无 | unpark(Thread) |
| permit 机制 | 无 | 有(二元许可) |
| 唤醒顺序 | 不适用 | unpark() 可先于 park() 调用 |
| 中断响应 | 抛出 InterruptedException,清除中断状态 |
静默返回,保留中断状态 |
| 线程状态 | TIMED_WAITING |
WAITING 或 TIMED_WAITING |
| 设计目的 | 简单的定时等待 | 构建同步器的底层原语 |
sleep() 没有对应 unsleep() 方法的原因
这触及了两者设计哲学的核心差异。sleep() 的语义是"休眠指定时长",其唤醒条件在调用时就已完全确定——时间到期。这是一个自包含的操作,不需要外部协作。如果需要提前唤醒,使用 interrupt() 即可,这与 sleep() 的"可中断休眠"语义一致。
相比之下,park() 的语义是"等待许可",其唤醒条件依赖于外部线程的协作——调用 unpark()。这是一个协作式操作,必须有配对的唤醒机制。
1 | |
中断处理:park 与 sleep 的差异
两者对中断的处理方式体现了不同的设计取向:
1 | |
深入理解 Thread.interrupted():清除还是恢复?
这是一个容易困惑的问题:为什么 Thread.interrupted() 要清除中断状态?什么时候应该恢复中断状态?
中断状态的本质:一个"一次性消费"的信号
两种检查方法的区别:
| 方法 | 作用 | 是否清除中断状态 | 典型使用场景 |
|---|---|---|---|
Thread.interrupted() |
检查当前线程的中断状态 | 是 | 响应中断后,表示"我已处理" |
thread.isInterrupted() |
检查指定线程的中断状态 | 否 | 仅查询,不改变状态 |
为什么要清除中断状态?——"消费"语义
中断状态类似于 permit:它是一个"信号",被检测到后应该被"消费"。如果不清除:
1 | |
为什么有时要恢复中断状态?——"传递"语义
这是另一个关键问题。考虑以下场景:
核心原则:谁应该"消费"中断?
1 | |
决策流程图:
总结:中断处理的三条黄金法则
- 不要吞掉中断:如果你不是最终处理者,必须恢复中断状态或抛出
InterruptedException - 清除意味着消费:调用
Thread.interrupted()表示"我已经处理了这个中断" - 恢复意味着传递:调用
Thread.currentThread().interrupt()表示"让上层来处理"
park() 的这种设计使其更适合作为同步器的构建块。AQS 中的 acquireQueued() 方法正是利用了这一特性:
1 | |
permit 机制解决的问题
park()/unpark() 的 permit 机制解决了一个 sleep() 无法处理的经典问题:唤醒信号与等待操作的执行顺序不确定性。
考虑以下场景:线程 A 需要等待线程 B 完成某项工作后再继续。
使用 sleep 的错误尝试:
1 | |
使用 wait/notify 的经典问题:
1 | |
使用 park/unpark 的解决方案:
1 | |
这个模式的关键在于:
- 等待方:先检查条件,条件不满足才 park。被唤醒后必须重新检查条件(因为可能是虚假唤醒)。
- 唤醒方:先设置条件,然后才 unpark。这确保了即使 unpark 先于 park 执行,等待方在下一次检查条件时也能发现条件已满足。
permit 机制解决的问题:即使 B 先执行 unpark,permit 会被保存。A 随后调用 park 时立即返回,不会阻塞。但 A 仍然会重新检查条件,发现 workDone == true,于是退出循环。
底层实现:从 Java 到操作系统
尽管 API 语义不同,sleep() 和 park() 在 HotSpot JVM 中的底层实现存在共性。两者最终都会调用操作系统的线程阻塞原语:
- POSIX 系统:
pthread_cond_wait()/pthread_cond_timedwait() - Windows:
WaitForSingleObject()/WaitForMultipleObjects()
关键差异在于唤醒条件的管理:
1 | |
sleep() 则没有这个 permit 机制,它直接进入定时等待,唯一的提前唤醒途径是中断。
使用场景的选择
| 场景 | 推荐使用 | 原因 |
|---|---|---|
| 简单的定时延迟 | Thread.sleep() |
语义清晰,无需配对操作 |
| 限流、节流 | Thread.sleep() |
固定时间间隔,无需外部唤醒 |
| 线程间协调 | LockSupport.park()/unpark() |
精确的一对一唤醒 |
| 构建同步器 | LockSupport.park()/unpark() |
permit 机制避免信号丢失 |
| 等待条件变量 | Condition.await() |
高层抽象,内部使用 park |
| 等待对象监视器 | Object.wait() |
与 synchronized 配合使用 |
小结:park 与 sleep 的本质差异
park() 与 sleep() 的关系可以这样理解:sleep() 是一个自给自足的定时等待,而 park() 是一个需要外部配合的协作等待。sleep() 没有 unsleep() 方法,正如 park() 必须有 unpark() 方法一样,这不是 API 设计的疏漏,而是两者语义本质差异的必然体现。
从同步原语的角度看,park()/unpark() 提供了比 sleep() 更底层、更灵活的线程阻塞机制,它是 java.util.concurrent 包中各种同步器的基石。而 sleep() 则保持了其简单直接的定位:当你只需要让线程暂停一段时间时,它仍然是最佳选择。
从 Java 线程 API 到操作系统:自愿 vs 强制操作的统一视角
park/unpark 的参数不对称性并非孤例,而是反映了并发编程中一个更深层的设计原则:自愿操作作用于当前线程,强制操作必须指定目标线程。
Java 线程 API 的参数模式:
| 操作类型 | 方法签名 | 作用目标 | 设计原因 |
|---|---|---|---|
| 自愿阻塞 | Thread.sleep(long millis) |
当前线程 | 线程主动让出 CPU,无需指定自己 |
| 自愿阻塞 | Object.wait() |
当前线程 | 线程主动进入等待,释放锁 |
| 自愿阻塞 | LockSupport.park(Object blocker) |
当前线程 | blocker 仅用于诊断 |
| 强制唤醒 | Object.notify()/notifyAll() |
等待队列中的线程 | 由 Monitor 管理目标选择 |
| 强制唤醒 | LockSupport.unpark(Thread t) |
指定线程 | 必须明确唤醒谁 |
| 强制中断 | thread.interrupt() |
指定线程 | 必须明确中断谁 |
| 强制终止 | thread.stop() (已废弃) |
指定线程 | 必须明确终止谁 |
| 强制挂起 | thread.suspend() (已废弃) |
指定线程 | 必须明确挂起谁 |
操作系统层面的对应关系:
为什么会有这种设计?
-
安全性考量:如果允许线程 A 强制阻塞线程 B,可能导致死锁或资源泄漏。线程 B 可能正持有锁、正在执行关键操作,强制阻塞它会破坏系统一致性。
-
协作式 vs 抢占式:
- 阻塞是协作式的:线程在安全点主动放弃执行权
- 唤醒/中断是抢占式的:外部力量介入改变线程状态
-
信息不对称:
- 当前线程知道自己的状态,可以安全地阻塞自己
- 外部线程不知道目标线程的内部状态,只能发送"请求"(如中断),由目标线程决定如何响应
-
POSIX 信号模型的影响:Unix/Linux 的信号机制(
kill(pid, signal))就是"向指定目标发送异步通知"的典范,Java 的interrupt()和unpark()继承了这一思想。
stop()/suspend() 被废弃的教训:
Java 早期提供的 Thread.stop() 和 Thread.suspend() 正是"强制阻塞/终止其他线程"的尝试,但它们被证明是危险的:
1 | |
这些 API 的废弃证明了:强制改变其他线程的执行状态是危险的。正确的做法是:
- 使用
interrupt()发送中断请求,由目标线程自己决定如何响应 - 使用
volatile标志位进行协作式停止 - 使用
park()/unpark()进行精确的线程协调
permit 机制:二元许可的设计与实现
park/unpark 的核心是一个二元许可(permit)机制。这是 LockSupport 区别于其他阻塞方式的关键特性,也是理解 AQS 等同步器实现的基础。
从 Dijkstra 的 P/V 信号量说起
1965 年,Dijkstra 提出了信号量(Semaphore)概念,使用两个原子操作:
- P 操作(Proberen,荷兰语"测试"):如果信号量 > 0,则减 1 并继续;否则阻塞
- V 操作(Verhogen,荷兰语"增加"):信号量加 1,唤醒一个等待者
1 | |
park/unpark 是"退化的二元信号量"
LockSupport 的 permit 机制可以理解为计数上限为 1 的信号量:
| 信号量概念 | park/unpark 对应 | 关键差异 |
|---|---|---|
| P 操作 | park() |
消耗 permit,若无则阻塞 |
| V 操作 | unpark() |
发放 permit,但最多累积 1 个 |
| 计数器 | permit(0 或 1) | 不是任意整数,只有两个状态 |
为什么 permit 最多只能累积 1 个?
这是一个关键的设计决策。考虑以下场景:
1 | |
这会导致唤醒信号的语义混乱:一次 unpark 本意是"唤醒一次",但如果可以累积,就变成了"预支多次唤醒"。park/unpark 的设计目标是精确的一对一唤醒,而非通用的计数信号量。
permit 与 AQS state 的本质区别
这是一个容易混淆的概念。LockSupport 的 permit 和 AQS 的 state 虽然都是整数,但语义完全不同:
| 特性 | LockSupport permit | AQS state |
|---|---|---|
| 取值范围 | 只有 0 和 1(二元) | 任意 int 值(可累加) |
| 累加性 | 多次 unpark 只累积 1 个 permit | 多次 release 可累加 state |
| 语义 | “线程是否可以继续执行” | “锁的状态/资源的数量” |
| 管理者 | JVM(Parker 对象) | 用户代码(AQS 子类) |
具体示例:多次 unpark 不能多次唤醒 park
1 | |
对比 Semaphore(基于 AQS state):
1 | |
设计意图的差异:
- permit:解决"唤醒丢失"问题,确保 unpark 先于 park 时不会丢失信号。但它不是用来"预支多次唤醒"的。
- AQS state:表达"资源数量"或"锁状态",支持可重入锁(state 表示重入次数)、信号量(state 表示可用许可数)等语义。
permit 机制解决的核心问题:竞态条件下的唤醒丢失
传统的 wait()/notify() 存在一个经典问题:
1 | |
如果线程 B 在线程 A 调用 wait() 之前就调用了 notify(),这个唤醒信号就丢失了。线程 A 随后调用 wait() 会永远阻塞。
park/unpark 的 permit 机制优雅地解决了这个问题:
1 | |
permit 的状态机:
为什么 JVM 使用 “dormant” 而非 “sleeping”?
JVM 规范中使用 dormant(休眠/蛰伏)而非 sleeping(睡眠)来描述 park() 后的线程状态,这是一个精确的术语选择:
| 术语 | 含义 | 对应 API | 唤醒方式 |
|---|---|---|---|
| Sleeping | 定时休眠,时间到自动醒来 | Thread.sleep(millis) |
时间到期、中断 |
| Dormant | 无限期蛰伏,必须被外部唤醒 | LockSupport.park() |
unpark()、中断、虚假唤醒 |
dormant 强调的是:
- 无自主唤醒能力:线程不会因为时间流逝而自动醒来
- 依赖外部干预:必须有其他线程调用
unpark()或发送中断 - 生物学隐喻:如同冬眠的动物,需要外部刺激才能苏醒
permit 使用不当的风险:无条件 park 的陷阱
虽然 permit 机制解决了唤醒丢失问题,但使用不当仍会导致问题。考虑以下错误示例:
1 | |
这个模式的问题在于 permit 的一次性消耗特性:
- 第一次循环:假设
unpark()先于park()调用,permit 被设置为 1 park()调用:发现 permit = 1,立即返回并将 permit 置为 0- 执行
doSomeWork():正常执行 - 第二次循环:再次调用
park(),此时 permit = 0,线程真正阻塞 - 问题:如果没有新的
unpark()调用,线程将永远阻塞
这不是活锁(活锁是线程一直运行但无法取得进展),而是意外阻塞——线程在不应该阻塞的时候被阻塞了。问题的根源是:permit 最多只能累积 1 个,一次 unpark() 只能抵消一次 park()。
正确的使用模式必须包含条件检查:
1 | |
这个模式的关键在于:
- 条件检查在循环开头:即使因为虚假唤醒或 permit 提前消耗而从
park()返回,也会重新检查条件 - 条件满足时退出循环:不会无谓地再次
park() - 条件不满足时才
park():确保每次park()都是有意义的等待
AQS 如何利用 permit 机制?
AQS 在 acquireQueued() 方法中展示了 permit 的正确使用:
1 | |
AQS 的设计确保了:
- 不会丢失唤醒:即使
unpark先于park,permit 会保证park立即返回 - 不会无限阻塞:每次唤醒后都会重新检查获取锁的条件
- 不会活锁:通过
shouldParkAfterFailedAcquire确保只有在确实需要等待时才 park
blocker 参数:诊断与可观测性
在深入实践之前,我们需要理解 LockSupport.park(Object blocker) 中的 blocker 参数。这是一个容易被忽视但对生产环境诊断至关重要的设计。
blocker 的本质
blocker 是一个纯粹用于诊断目的的标记对象,它告诉 JVM 和诊断工具"这个线程为什么被阻塞了"。
1 | |
blocker 的关键特性
| 特性 | 说明 |
|---|---|
| 不参与锁逻辑 | blocker 纯粹是一个"标签",不影响 park/unpark 的行为 |
| 不记录锁状态 | 它不知道锁被持有了多少次、有多少线程在等待 |
| 可传 null | 即使传 null,park 也能正常工作 |
| 唯一作用是可观测性 | 让 jstack、JFR 等工具能显示线程在等待什么 |
在 jstack 中的体现
当使用 jstack 查看线程状态时,blocker 参数会显示在输出中:
1 | |
其中 parking to wait for <0x000000076b4a8f90> 就是 blocker 对象的信息。
blocker 的生命周期
1 | |
这个"设置 → 阻塞 → 清除"的三段式确保了:
- 阻塞期间,诊断工具可以看到 blocker
- 唤醒后,blocker 被清除,不会造成内存泄漏
脱离 AQS 直接使用 park/unpark 实现线程协调
如果不依赖 AQS,仅使用 park/unpark 原语实现简单的线程阻塞与唤醒,需要解决一个核心问题:唤醒方如何获得被阻塞线程的引用?
基本模式:
1 | |
从零构建:基于 CAS + park/unpark 的简易互斥锁
下面展示如何仅使用 Unsafe.compareAndSwapInt(CAS)和 LockSupport.park/unpark 构建一个可工作的互斥锁,不依赖 AQS 的队列机制。
设计目标:
- 互斥性:同一时刻只有一个线程持有锁
- 可重入性:同一线程可以多次获取锁
- 阻塞等待:获取失败的线程阻塞而非忙等
Version 1:最简单的自旋锁(无 park)
1 | |
问题:纯自旋在高竞争下会浪费大量 CPU 时间。
Version 2:自旋 + park 的混合锁
1 | |
Version 3:支持可重入的完整实现
1 | |
时序图:两个线程竞争锁
这个简易锁的局限性:
| 局限 | 原因 | AQS 的解决方案 |
|---|---|---|
| 只支持一个等待者 | waiter 是单个引用 |
CLH 队列支持任意数量的等待者 |
| 不保证公平性 | 新来的线程可能抢先获取锁 | 公平模式下按队列顺序唤醒 |
| 无超时支持 | park() 无限等待 |
parkNanos() 支持超时 |
| 无中断响应 | 被中断后继续尝试获取 | lockInterruptibly() 响应中断 |
| 无条件变量 | 不支持 await()/signal() |
ConditionObject 提供完整支持 |
为什么需要 volatile?
在上述实现中,owner 和 waiter 都声明为 volatile,原因如下:
1 | |
-
owner的可见性:unlock()中设置owner = null必须对其他线程立即可见,否则可重入检查owner == current可能读到过期值。 -
waiter的可见性:lock()中设置waiter = current必须对unlock()线程可见,否则unlock()可能读到null而不唤醒等待者。 -
与 CAS 的配合:虽然
AtomicInteger.compareAndSet本身有内存屏障效果,但owner和waiter的读写发生在 CAS 之外,需要volatile保证顺序。
不使用 volatile 的替代方案:
如果想避免 volatile,可以将所有状态打包到一个原子变量中:
1 | |
结论:直接使用 park/unpark 构建锁是可行的,但一旦需要支持多等待者、公平性、超时、中断等特性,复杂度会急剧上升。AQS 的价值在于提供了一个经过充分测试的、功能完备的框架,让开发者只需实现 tryAcquire/tryRelease 即可获得完整的锁语义。
必须处理的边界情况:
| 场景 | 问题 | 解决方案 |
|---|---|---|
| unpark 先于 park | 线程 B 在线程 A 调用 park 前就调用了 unpark | unpark 会设置一个"许可",后续的 park 会立即返回而不阻塞 |
LockSupport 实战示例
以下示例展示 LockSupport 的核心特性,帮助理解 permit 机制的实际行为。
示例 1:park/unpark 与 wait/notify 的对比
使用 wait/notify 实现线程协调需要 synchronized 块:
1 | |
使用 LockSupport 则无需 synchronized:
1 | |
关键区别:
wait()必须在synchronized块内调用,否则抛出IllegalMonitorStateExceptionpark()可以在任何地方调用,无需持有任何锁notify()随机唤醒一个等待线程,unpark(thread)精确唤醒指定线程
示例 2:unpark 可以先于 park 调用
这是 permit 机制最重要的特性——解决了 wait/notify 的时序问题:
1 | |
这个特性在生产者-消费者模型中非常有用:生产者可能先于消费者准备好,此时 unpark 发放的许可会被保留,消费者随后调用 park 时立即返回。
示例 3:permit 不可累积(二元信号量语义)
1 | |
这就是为什么 permit 最多只能累积 1 个:LockSupport 设计为精确的一对一唤醒机制,而非通用的计数信号量。
示例 4:park 响应中断但不抛出 InterruptedException
1 | |
关键行为:
park()会响应中断——被中断后立即返回- 但
park()不会抛出InterruptedException - 中断状态被保留(
isInterrupted()返回true) - 这与
wait()、sleep()不同,后者会抛出异常并清除中断状态
这就是为什么 AQS 中的 parkAndCheckInterrupt() 要检查中断状态:
1 | |
示例 5:blocker 参数的诊断价值
1 | |
在 jstack 输出中,blocker 信息会显示为:
1 | |
| 虚假唤醒 | park 可能在没有 unpark 的情况下返回 | 必须在循环中检查条件:while (!condition) park() |
HotSpot 源码解析:Parker 类如何实现 permit 语义
LockSupport.park() 和 unpark() 最终调用的是 Unsafe.park() 和 Unsafe.unpark(),而这两个 native 方法在 HotSpot 中由 Parker 类实现。理解 Parker 的实现是理解 permit 机制的关键。
Parker 类的核心结构(Linux 平台):
1 | |
关键字段 _counter 的语义:
_counter == 0:没有可用的 permit,park()将阻塞_counter == 1:有一个可用的 permit,park()将立即返回并消费它
Parker::park() 的核心实现:
1 | |
Parker::unpark() 的核心实现:
1 | |
permit 语义的实现要点:
为什么使用 Atomic::xchg 而非简单的读取?
Atomic::xchg(0, &_counter) 是一个原子交换操作,它同时完成两件事:
- 读取
_counter的当前值 - 将
_counter设置为 0
这个操作具有完整的内存屏障语义,确保:
- 如果 permit 可用(
_counter == 1),立即消费它并返回 - 如果 permit 不可用(
_counter == 0),进入慢速路径
使用原子操作而非普通读取是为了避免竞态条件:如果先读取再设置,可能在读取和设置之间被其他线程的 unpark 打断。
虚假唤醒(Spurious Wakeup):为什么必须在循环中检查条件
park() 可能在以下情况下返回,即使没有对应的 unpark() 调用:
- POSIX 条件变量的虚假唤醒:
pthread_cond_wait()规范允许虚假唤醒 - 中断:线程被
interrupt()中断 - 超时:使用
parkNanos()或parkUntil()时超时返回
虚假唤醒的根本原因:
POSIX 标准明确指出,pthread_cond_wait() 可能在没有对应 pthread_cond_signal() 的情况下返回。这是一个有意的设计决策,允许实现在某些情况下(如信号处理、多处理器同步)更高效地工作。
错误的使用方式:
1 | |
正确的使用方式:
1 | |
AQS 中的正确模式:
1 | |
虚假唤醒与 permit 机制的关系:
| 唤醒类型 | permit 状态 | 条件检查结果 | 行为 |
|---|---|---|---|
| 正常唤醒(unpark) | 被消费 | 条件满足 | 退出循环,继续执行 |
| 虚假唤醒 | 未变化(仍为 0) | 条件不满足 | 继续循环,再次 park |
| 中断唤醒 | 未变化(仍为 0) | 条件不满足 | 根据策略处理中断 |
关键理解:permit 机制解决的是"唤醒丢失"问题(unpark 先于 park),而循环检查解决的是"虚假唤醒"问题。两者相辅相成,缺一不可。
超时等待的实现:ReentrantLock.tryLock(timeout, unit) 原理
tryLock(long timeout, TimeUnit unit) 是 ReentrantLock 提供的带超时的锁获取方法,它基于 LockSupport.parkNanos() 实现。理解其实现原理有助于掌握超时等待的正确模式。
tryLock 超时版本的使用场景:
1 | |
AQS 中 tryAcquireNanos 的核心实现:
1 | |
超时等待的时序图:
spinForTimeoutThreshold 的设计意图:
1 | |
当剩余等待时间小于 1 微秒时,AQS 选择自旋而非调用 parkNanos()。原因是:
- 系统调用开销:
parkNanos()最终会调用操作系统的pthread_cond_timedwait(),这是一个系统调用,开销约为几微秒 - 精度问题:操作系统的定时器精度有限,极短的超时可能不准确
- 性能权衡:对于极短的等待,自旋的 CPU 开销小于系统调用的开销
Condition.awaitNanos() 的类似实现:
1 | |
超时等待的返回值语义:
| 方法 | 返回值类型 | 成功 | 超时 | 中断 |
|---|---|---|---|---|
tryLock(timeout, unit) |
boolean |
true |
false |
抛出 InterruptedException |
awaitNanos(nanos) |
long |
剩余时间 > 0 | 剩余时间 ≤ 0 | 抛出 InterruptedException |
await(time, unit) |
boolean |
true |
false |
抛出 InterruptedException |
awaitUntil(deadline) |
boolean |
true |
false |
抛出 InterruptedException |
| 引用发布时机 | 线程 A 发布引用后、park 前,线程 B 就调用了 unpark | 同上,许可机制保证不会丢失唤醒 |
| 多个等待者 | 多个线程都在等待同一个条件 | 需要使用队列管理等待线程,这正是 AQS 所做的事情 |
为什么 AQS 不直接暴露这些细节?
AQS 封装了上述所有复杂性:
- CLH 队列:管理多个等待线程的引用
- Node 节点:每个节点持有等待线程的引用(
Node.thread) - 状态机:通过
waitStatus管理节点状态,确保正确的唤醒顺序 - 自旋 + park:在 park 前先自旋尝试,减少不必要的阻塞
直接使用 park/unpark 虽然可行,但一旦涉及多线程竞争、公平性、可重入等需求,手工管理的复杂度会急剧上升。AQS 的价值正在于此。
重要区分:blocker 与本文其他"记录对象"的本质区别
在本文中,我们讨论了多种"记录对象":锁对象、Lock Record、ObjectMonitor 等。blocker 与它们有本质的不同,这一点必须强调:
对比表:blocker vs 其他记录对象
| 维度 | Lock Record / Monitor / AQS.state | Thread.parkBlocker |
|---|---|---|
| 是否参与锁逻辑 | 是,核心组成部分 | 否,完全不参与 |
| 删除后果 | 锁机制崩溃 | 锁照常工作,仅丢失诊断信息 |
| 记录的信息 | 持锁线程、等待队列、锁状态 | “线程在等待什么”(纯标签) |
| 谁读取它 | JVM 锁实现代码 | jstack、JFR 等诊断工具 |
| 设置时机 | 获取锁时 | park() 前设置,unpark() 后清除 |
| 生命周期 | 与锁的持有周期一致 | 仅在线程阻塞期间有效 |
一句话总结:blocker 是"旁观者",不是"参与者"。 它就像一个便利贴,贴在线程上告诉诊断工具"我在等什么",但撕掉这个便利贴,线程该怎么等还是怎么等。
blocker 来自哪里?
blocker 的来源取决于调用 LockSupport.park() 的上下文。在 AQS 体系中,主要有两种来源:
1. 在 Sync Queue(同步队列)中等待锁时:blocker = AQS 实例本身
1 | |
此时 this 就是 ReentrantLock 内部的 Sync 对象(继承自 AQS)。
2. 在 Condition Queue(条件队列)中等待条件时:blocker = ConditionObject 实例
1 | |
此时 this 是 ConditionObject,它是 AQS 的内部类。
Condition.await() 与 signal() 的完整时序图
Condition 的 await() 和 signal() 是基于 LockSupport.park() 和 unpark() 实现的。理解它们的交互时序是掌握条件变量的关键。
完整时序图:await() 与 signal() 的协作
关键状态转换:节点在两个队列间的迁移
blocker 的切换过程详解:
| 阶段 | 线程状态 | 所在队列 | blocker 值 | park 调用位置 |
|---|---|---|---|---|
| 获取锁成功 | RUNNABLE | 无 | null | - |
| await() 阻塞 | WAITING | 条件队列 | ConditionObject | ConditionObject.await() |
| signal() 唤醒 | RUNNABLE | 同步队列 | null(短暂) | - |
| 竞争锁失败 | WAITING | 同步队列 | Sync | AQS.acquireQueued() |
| 获取锁成功 | RUNNABLE | 无 | null | - |
await() 的核心代码路径:
1 | |
signal() 的核心代码路径:
1 | |
AQS 中 park/unpark 的调用时机与状态变化
理解 AQS 何时调用 park() 和 unpark() 是理解 blocker 的关键。下面我们通过图解来说明 AQS 内部的调用时机、方法参数以及调用后的状态变化。
图1:ReentrantLock.lock() 的 park 调用时机
图2:ReentrantLock.unlock() 的 unpark 调用时机
图3:AQS 状态变化与 park/unpark 的关系
关键代码路径(简化):
1 | |
park 与 unpark 参数对比:
| 方法 | 参数 | 参数含义 | 示例 |
|---|---|---|---|
LockSupport.park(Object blocker) |
blocker | 诊断标记,告诉工具"线程在等什么" | park(this) → blocker = Sync |
LockSupport.unpark(Thread thread) |
thread | 要唤醒的线程对象 | unpark(node.thread) |
注意:unpark 的参数是线程对象,不是 blocker! 这是因为 unpark 的目的是唤醒特定线程,而 blocker 只是诊断信息,唤醒时不需要。
3. 其他场景的 blocker
| 场景 | blocker 是什么 | 示例 |
|---|---|---|
ReentrantLock.lock() |
ReentrantLock$Sync |
等待获取锁 |
Condition.await() |
ConditionObject |
等待条件满足 |
Semaphore.acquire() |
Semaphore$Sync |
等待许可 |
CountDownLatch.await() |
CountDownLatch$Sync |
等待计数归零 |
FutureTask.get() |
FutureTask 自身 |
等待任务完成 |
SynchronousQueue.take() |
SynchronousQueue$TransferStack 或 TransferQueue |
等待配对 |
选择 this 作为 blocker 的原因
设计者选择 this(即当前的同步器对象)作为 blocker,有几个重要原因:
1. 语义清晰:blocker 应该回答"线程在等待什么",而 AQS 子类正是"锁"的抽象。当你看到 parking to wait for <0x000000076ab12345> (a java.util.concurrent.locks.ReentrantLock$NonfairSync) 时,立刻就知道线程在等待一个非公平锁。
2. 便于死锁检测:通过 blocker 可以构建"线程 → 锁 → 持有者线程"的等待链,这是死锁检测的基础。
3. 与 synchronized 对称:synchronized 的重量级锁会将 ObjectMonitor 记录到线程的 _current_pending_monitor 字段,AQS 用 parkBlocker 实现了相同的功能。
synchronized 与 park 底层机制的差异
synchronized 的重量级锁不使用 LockSupport.park() 来阻塞线程。synchronized 和 AQS 在底层使用了完全不同的线程阻塞机制。
| 维度 | synchronized(重量级锁) | AQS(ReentrantLock 等) |
|---|---|---|
| 阻塞机制 | OS 原生的 Mutex/Condition | LockSupport.park()/unpark() |
| 实现层次 | JVM C++ 层(ObjectMonitor) |
Java 层 + Unsafe.park() |
| 线程状态 | BLOCKED |
WAITING 或 TIMED_WAITING |
| 可中断性 | 不可中断(monitorenter 不响应中断) |
可中断(lockInterruptibly()) |
| 诊断信息 | _current_pending_monitor(C++ 层) |
Thread.parkBlocker(Java 层) |
这种设计差异的原因:
-
历史原因:synchronized 是 Java 1.0 就存在的语言特性,其实现深度嵌入 JVM 内部。当时 Java 还没有
LockSupport(JDK 1.5 才引入),只能依赖 OS 原生的同步原语。 -
语义差异:synchronized 的
monitorenter指令被设计为不可中断的阻塞操作。而LockSupport.park()天然支持中断,这使得 AQS 可以实现lockInterruptibly()等可中断的锁获取方式。 -
性能优化空间:synchronized 的实现在 JVM 内部,可以做偏向锁、轻量级锁等深度优化。AQS 在 Java 层实现,虽然灵活性更高,但无法享受这些 JVM 级别的优化。
ObjectMonitor 的阻塞实现(简化):
1 | |
关键区别: os::PlatformEvent::park() 是 JVM 内部的 C++ 实现,直接调用操作系统的 pthread_cond_wait()(Linux)或 WaitForSingleObject()(Windows)。而 LockSupport.park() 虽然最终也会调用类似的系统调用,但它是通过 Unsafe.park() 暴露给 Java 层的,并且额外支持了 blocker 参数和中断响应。
blocker 在锁竞争中的切换
以 ReentrantLock + Condition 场景为例,追踪 blocker 的变化:
1 | |
blocker 的切换过程:
关键观察:
- blocker 在 park 前设置,在 unpark 后清除:这是
LockSupport.park(Object blocker)的标准模式 - blocker 会随着线程在不同队列间移动而切换:
- 在同步队列等待锁时:blocker = Sync
- 在条件队列等待条件时:blocker = ConditionObject
- blocker 只在线程被阻塞时有意义:一旦线程被唤醒,blocker 立即被清除为 null
代码层面的切换逻辑:
1 | |
这个"设置 → 阻塞 → 清除"的三段式是理解 blocker 生命周期的关键。
blocker 与 Monitor 的对位关系
blocker 和 Monitor 在功能上是对位的,但实现层次不同。以下是精确的对比:
| 维度 | synchronized (Monitor) | AQS (blocker) |
|---|---|---|
| 存储位置 | Thread._current_pending_monitor(C++ 层) |
Thread.parkBlocker(Java 层) |
| 记录的对象 | ObjectMonitor*(C++ 对象) |
AQS 子类实例(Java 对象) |
| 设置时机 | 进入 _EntryList 或 _WaitSet 时 |
调用 LockSupport.park() 前 |
| 清除时机 | 离开等待队列时 | park() 返回后立即清除 |
| 用途 | 死锁检测、jstack 输出 | 死锁检测、jstack 输出 |
| 是否参与锁逻辑 | 否(纯诊断) | 否(纯诊断) |
它们的对位关系体现在:
- 功能对位:都是为了回答"这个线程在等待哪个锁"
- 诊断对位:
jstack对两者的输出格式类似:1
2
3
4
5// synchronized
- waiting to lock <0x000000076ab12345> (a java.lang.Object)
// AQS
- parking to wait for <0x000000076ab67890> (a java.util.concurrent.locks.ReentrantLock$NonfairSync) - 设计意图对位:都是为了在不影响锁性能的前提下,提供可观测性
但它们也有本质区别:
- 层次不同:
_current_pending_monitor在 JVM 的 C++ 层,parkBlocker在 Java 层 - 生命周期不同:
_current_pending_monitor的生命周期由 JVM 管理,parkBlocker由LockSupport显式管理 - 灵活性不同:
parkBlocker可以是任意对象,而_current_pending_monitor只能是ObjectMonitor
图:Monitor 与 blocker 的对位关系
实战:通过 jstack 观察 blocker
以下是一个实际的 jstack 输出,展示 blocker 如何帮助诊断问题:
1 | |
从这个输出中,我们可以读出:
- 线程状态:
WAITING (parking)— 线程被LockSupport.park()阻塞 - blocker 对象:
<0x000000076ab67890> (a java.util.concurrent.locks.ReentrantLock$NonfairSync)— 线程在等待一个非公平锁 - 调用栈:从
MyService.doSomething到ReentrantLock.lock的完整路径
如果同时有另一个线程持有这个锁:
1 | |
通过对比两个线程的 blocker 地址(0x000000076ab67890),我们可以确定 Thread-1 在等待 Thread-0 持有的锁。这就是 blocker 的诊断价值。
小结:blocker 的设计哲学
LockSupport.park(Object blocker) 的 blocker 参数体现了一个重要的设计哲学:在不影响核心功能的前提下,提供最大化的可观测性。
- 零性能开销:blocker 只是一个引用赋值,不涉及任何同步操作
- 完全可选:即使传 null,park 也能正常工作
- 诊断友好:让 jstack、JFR 等工具能够准确报告线程的等待原因
- 与 synchronized 对称:在 Java 层实现了与 JVM 层
_current_pending_monitor相同的功能
这种设计思想值得我们在自己的代码中借鉴:核心逻辑保持简洁,诊断信息作为可选的"旁路"提供。
基于 AQS 的同步器实现
有了对 AQS 的深入理解,现在让我们看看 JDK 中几个重要的同步器是如何基于 AQS 实现的。
ReentrantLock:可重入独占锁
ReentrantLock 是最常用的显式锁,它提供了与 synchronized 类似的互斥语义,但具有更丰富的功能。
核心结构
1 | |
state 的语义
ReentrantLock vs synchronized
| 特性 | ReentrantLock | synchronized |
|---|---|---|
| 实现层次 | Java 层(AQS) | JVM 层(ObjectMonitor) |
| 锁获取方式 | 显式 lock()/unlock() | 隐式(进入/退出同步块) |
| 公平性 | 可选(构造函数参数) | 非公平 |
| 可中断 | lockInterruptibly() | 不支持 |
| 超时获取 | tryLock(timeout) | 不支持 |
| 条件变量 | 多个 Condition | 单个(wait/notify) |
| 性能 | 高竞争下更好 | 低竞争下更好(偏向锁优化) |
Condition 接口:显式锁的条件变量
Condition 接口是 Lock 的配套接口,提供了比 Object.wait()/notify() 更灵活的线程等待/通知机制。
Condition 接口方法
| 方法 | 说明 |
|---|---|
await() |
释放锁并等待,直到被 signal 或中断 |
awaitUninterruptibly() |
不响应中断的等待 |
awaitNanos(long) |
带超时的等待,返回剩余时间 |
awaitUntil(Date) |
等待直到指定时间点 |
signal() |
唤醒一个等待线程 |
signalAll() |
唤醒所有等待线程 |
Condition vs Object Monitor
| 特性 | Condition | Object.wait/notify |
|---|---|---|
| 绑定对象 | Lock | synchronized 对象 |
| 条件队列数量 | 多个(每个 Condition 一个) | 单个 |
| 等待方式 | await() |
wait() |
| 唤醒方式 | signal()/signalAll() |
notify()/notifyAll() |
| 超时等待 | awaitNanos()、awaitUntil() |
wait(long) |
| 不响应中断 | awaitUninterruptibly() |
不支持 |
使用示例:生产者-消费者
1 | |
多条件队列的优势:上例中使用两个 Condition(notFull 和 notEmpty),生产者只唤醒消费者,消费者只唤醒生产者,避免了 notifyAll() 的惊群效应。
实现细节:
Condition的标准实现是 AQS 的内部类ConditionObject,它维护了独立的条件队列。关于await()和signal()的实现原理,详见本文 AQS 深入解析 章节中的"Condition 条件队列详解"部分。
ReentrantReadWriteLock:读写锁
ReentrantReadWriteLock 允许多个读线程同时访问,但写线程独占。这在读多写少的场景下能显著提高并发性能。
核心结构
1 | |
读写锁的获取规则
锁降级
ReentrantReadWriteLock 支持锁降级(写锁 → 读锁),但不支持锁升级(读锁 → 写锁):
1 | |
为什么不支持锁升级?
StampedLock:Java 8 的高性能读写锁
StampedLock 是 Java 8 在 java.util.concurrent.locks 包中新增的锁实现,它通过引入**乐观读(Optimistic Reading)**机制,在读多写少的场景下提供了比 ReentrantReadWriteLock 更高的并发性能。
核心设计理念
- 票据(Stamp)机制:所有锁获取方法都返回一个
long类型的stamp,用于表示锁的状态和版本 - 三种访问模式:写锁、悲观读锁、乐观读
- 非基于 AQS:
StampedLock没有使用 AQS 框架,而是自己实现了基于 CLH 队列的同步机制 - 不可重入:不支持重入,这是设计上的权衡,换取了更高的性能
三种访问模式
1. 写锁(Write Lock)
独占锁,与 ReentrantLock 类似,写锁被持有时,其他任何锁都无法获取:
1 | |
2. 悲观读锁(Pessimistic Read Lock)
共享锁,允许多个线程同时持有读锁,但会阻塞写锁:
1 | |
3. 乐观读(Optimistic Read)
乐观读是 StampedLock 的核心创新。它不阻塞写线程,但需要通过 validate() 验证数据一致性:
1 | |
validate() 工作原理
StampedLock 使用一个 long 类型的 state 变量来维护锁状态:
依据:根据 java.util.concurrent.locks.StampedLock 源码(JDK 21)中的常量定义:
1 | |
位结构详解:
- bit 0-6(7位):读锁计数,最大值为 126(RFULL),超过时使用溢出机制
- bit 7(1位):写锁标志位(WBIT = 128),1 表示写锁被持有
- bit 8-63(56位):版本号,每次写锁释放时递增,用于乐观读验证
验证逻辑:
tryOptimisticRead()返回当前state的快照(版本号)- 如果读取期间有写操作,写锁获取和释放会改变
state的值(版本号递增) validate(stamp)比较当前state与保存的stamp,如果相等则验证成功
锁转换方法
StampedLock 提供了三个锁转换方法:
| 方法 | 功能 | 返回值 |
|---|---|---|
tryConvertToWriteLock(stamp) |
读锁/乐观读 → 写锁 | 成功返回新 stamp,失败返回 0 |
tryConvertToReadLock(stamp) |
写锁 → 读锁(锁降级) | 成功返回新 stamp,失败返回 0 |
tryConvertToOptimisticRead(stamp) |
读锁/写锁 → 乐观读 | 成功返回新 stamp,失败返回 0 |
1 | |
StampedLock vs ReentrantReadWriteLock
| 特性 | StampedLock | ReentrantReadWriteLock |
|---|---|---|
| 可重入性 | 不可重入 | 支持重入 |
| 公平性 | 非公平锁 | 支持公平/非公平 |
| Condition 支持 | 不支持 | 支持 |
| 锁降级 | 支持 | 支持 |
| 锁升级 | 支持(tryConvertToWriteLock) | 不支持 |
| 乐观读 | 支持 | 不支持 |
| 读锁饥饿 | 乐观读可避免写线程饥饿 | 读线程多时写线程可能饥饿 |
| 性能(读多写少) | 极高 | 一般 |
| API 复杂度 | 复杂 | 简单 |
| 基于框架 | 自实现(CLH 队列) | 基于 AQS |
使用注意事项
1. 不可重入导致的死锁
1 | |
2. 中断导致 CPU 飙升
1 | |
3. 乐观读必须验证
1 | |
适用场景
推荐使用:
- 读多写少,对性能要求高
- 短临界区,减少验证失败概率
- 无重入需求,无 Condition 需求
不推荐使用:
- 需要重入(递归调用)
- 需要 Condition 条件变量
- 写操作频繁,乐观读验证失败率高
- 简单场景,
synchronized或ReentrantLock足够
Semaphore:信号量
Semaphore 维护一组许可(permits),用于限制同时访问某个资源的线程数量。
核心实现
1 | |
state 的语义
使用场景
1 | |
CountDownLatch:倒计时门闩
CountDownLatch 允许一个或多个线程等待其他线程完成操作。
核心实现
1 | |
工作流程
使用场景
1 | |
CyclicBarrier:循环栅栏
CyclicBarrier 让一组线程互相等待,直到所有线程都到达某个屏障点。与 CountDownLatch 不同,它可以重复使用。
核心结构
1 | |
CyclicBarrier vs CountDownLatch
| 特性 | CyclicBarrier | CountDownLatch |
|---|---|---|
| 重用性 | 可重复使用 | 一次性 |
| 计数方向 | 递减到0后重置 | 递减到0后结束 |
| 等待方式 | 线程互相等待 | 线程等待计数归零 |
| 屏障动作 | 支持(最后一个线程执行) | 不支持 |
| 实现方式 | ReentrantLock + Condition | AQS 共享模式 |
| 典型场景 | 多阶段并行计算 | 等待多个任务完成 |
同步器对比总结
| 同步器 | state 语义 | 获取模式 | 典型场景 |
|---|---|---|---|
| ReentrantLock | 重入次数 | 独占 | 互斥访问 |
| ReentrantReadWriteLock | 高16位读/低16位写 | 共享+独占 | 读多写少 |
| Semaphore | 可用许可数 | 共享 | 限流、资源池 |
| CountDownLatch | 剩余计数 | 共享 | 等待多任务完成 |
| CyclicBarrier | 通过 Lock+Condition | N/A | 多阶段并行计算 |
自定义同步器示例
理解了 AQS 的原理后,我们可以轻松实现自定义同步器。以下是一个简单的二元闭锁(只能打开一次的门):
1 | |
使用示例:
1 | |
锁的活性问题
在并发编程中,正确性不仅仅意味着"不出错",还意味着程序能够持续向前推进。活性(Liveness)是指程序最终能够完成其预期工作的能力。与活性相对的是活性问题,主要包括死锁、活锁和饥饿。
死锁(Deadlock)
定义:两个或多个线程互相等待对方持有的资源,导致所有线程都无法继续执行。
死锁的四个必要条件
死锁的发生必须同时满足以下四个条件(Coffman 条件):
| 条件 | 说明 |
|---|---|
| 互斥条件 | 资源不能被多个线程同时使用 |
| 持有并等待 | 线程持有至少一个资源,同时等待获取其他资源 |
| 不可剥夺 | 资源只能由持有它的线程主动释放,不能被强制剥夺 |
| 循环等待 | 存在一个线程等待链,形成环路 |
经典死锁示例
1 | |
死锁的预防策略
1. 破坏"持有并等待"条件:一次性获取所有资源
1 | |
2. 破坏"循环等待"条件:按固定顺序获取锁
1 | |
3. 使用 tryLock 超时机制
1 | |
死锁的检测
使用 jstack 检测死锁:
1 | |
输出示例:
1 | |
使用 ThreadMXBean 编程检测:
1 | |
活锁(Livelock)
定义:线程没有被阻塞,但由于某些条件不满足,一直在重复尝试某个操作,无法向前推进。
活锁与死锁的区别:
- 死锁:线程被阻塞,完全不执行
- 活锁:线程在执行,但做的是无用功
活锁示例:过于礼貌的线程
1 | |
活锁的解决方案
1. 引入随机性
1 | |
2. 引入优先级或仲裁机制
1 | |
饥饿(Starvation)
定义:某个线程长时间无法获取所需的资源,导致无法执行。
饥饿通常发生在以下场景:
- 优先级不公平:高优先级线程持续抢占资源
- 锁的非公平性:非公平锁可能导致某些线程长期等待
- 资源竞争激烈:某些线程运气不好,总是抢不到资源
饥饿示例
1 | |
饥饿的解决方案
1. 使用公平锁
1 | |
2. 避免长时间持有锁
1 | |
3. 使用信号量限制并发
1 | |
三种活性问题的对比
| 问题 | 线程状态 | 原因 | 解决方案 |
|---|---|---|---|
| 死锁 | 阻塞(BLOCKED/WAITING) | 循环等待资源 | 破坏四个必要条件之一 |
| 活锁 | 运行(RUNNABLE) | 不断重试但无进展 | 引入随机性或优先级 |
| 饥饿 | 就绪/阻塞 | 资源分配不公平 | 使用公平锁、限制持锁时间 |
小结
活性问题是并发编程中必须关注的重要问题:
-
死锁:最严重的活性问题,程序完全停止。预防的关键是破坏四个必要条件之一,最常用的方法是按固定顺序获取锁。
-
活锁:线程在执行但无进展。解决方法是引入随机性或优先级机制,打破同步的"礼貌"行为。
-
饥饿:某些线程长期无法获取资源。解决方法是使用公平锁、限制持锁时间、合理设置优先级。
在实际开发中,应该:
- 尽量减少锁的使用范围和持有时间
- 使用成熟的并发工具类(如
java.util.concurrent包) - 定期使用 jstack 等工具检测潜在的活性问题
- 在设计阶段就考虑并发安全,而不是事后修补
java.util.concurrent.atomic 包体系
java.util.concurrent.atomic 包提供了一组原子类,用于在无锁的情况下实现线程安全的操作。这些类基于 CAS(Compare-And-Swap)指令实现,避免了传统锁带来的线程阻塞和上下文切换开销。
原子类的分类
atomic 包中的原子类可以分为以下五类:
基本类型原子类
| 类名 | 说明 | 核心方法 |
|---|---|---|
| AtomicInteger | 原子更新 int 值 | get(), set(), getAndIncrement(), compareAndSet() |
| AtomicLong | 原子更新 long 值 | 同上 |
| AtomicBoolean | 原子更新 boolean 值 | get(), set(), compareAndSet() |
1 | |
引用类型原子类
| 类名 | 说明 | 适用场景 |
|---|---|---|
| AtomicReference<V> | 原子更新引用类型 | 需要原子更新对象引用 |
| AtomicStampedReference<V> | 带版本号的原子引用 | 解决 ABA 问题 |
| AtomicMarkableReference<V> | 带标记位的原子引用 | 需要标记状态的场景 |
1 | |
数组原子类
数组原子类可以原子地更新数组中的某个元素:
1 | |
字段更新器
字段更新器用于原子更新某个类的 volatile 字段,适用于无法修改类定义的场景:
1 | |
字段更新器的限制:
- 字段必须是
volatile修饰的 - 字段不能是
static的(对于实例字段更新器) - 字段不能是
final的 - 字段的可见性必须允许更新器访问
ABA 问题与解决方案
什么是 ABA 问题
ABA 问题是 CAS 操作的一个经典问题:当一个值从 A 变成 B,再变回 A 时,CAS 操作无法检测到这个变化,仍然认为值没有被修改过。
ABA 问题的实际影响
在大多数场景下,ABA 问题不会造成实际问题。但在某些场景下会导致严重错误:
场景:无锁栈的 pop 操作
1 | |
AtomicStampedReference:版本号方案
AtomicStampedReference 通过维护一个版本号(stamp)来解决 ABA 问题:
1 | |
工作原理:
AtomicMarkableReference:标记位方案
AtomicMarkableReference 使用一个 boolean 标记位,适用于只需要知道"是否被修改过"的场景:
1 | |
两种方案的对比
| 特性 | AtomicStampedReference | AtomicMarkableReference |
|---|---|---|
| 额外信息 | int 版本号(可无限递增) | boolean 标记位 |
| 检测能力 | 可检测任意次数的修改 | 只能检测"是否被修改过" |
| 内存开销 | 较大(需要存储 int) | 较小(只需 boolean) |
| 适用场景 | 需要精确追踪修改次数 | 只需知道是否被修改 |
LongAdder:高并发累加器
AtomicLong 的性能瓶颈
在高并发场景下,AtomicLong 的性能会急剧下降。原因是所有线程都在竞争同一个变量,CAS 失败率很高:
LongAdder 的分段累加策略
LongAdder 采用分段累加策略:将一个变量拆分成多个 Cell,不同线程更新不同的 Cell,最后汇总求和。
LongAdder 的内部结构
1 | |
关键设计:
- base 字段:无竞争时直接更新 base,避免创建 Cell 数组
- Cell 数组:竞争激烈时,不同线程更新不同的 Cell
- @Contended 注解:避免 Cell 之间的伪共享(False Sharing)
- 动态扩容:Cell 数组会根据竞争程度动态扩容
LongAdder 的工作流程
性能对比
| 场景 | AtomicLong | LongAdder | 性能差异 |
|---|---|---|---|
| 低并发(1-2 线程) | 快 | 略慢 | AtomicLong 更优 |
| 中等并发(4-8 线程) | 较慢 | 快 | LongAdder 2-3 倍 |
| 高并发(16+ 线程) | 很慢 | 很快 | LongAdder 10-15 倍 |
1 | |
LongAdder vs AtomicLong:如何选择
| 考虑因素 | 选择 AtomicLong | 选择 LongAdder |
|---|---|---|
| 并发度 | 低并发(< 4 线程) | 高并发(> 8 线程) |
| 操作类型 | 需要精确的当前值 | 只需要最终累加结果 |
| 内存敏感 | 内存受限 | 内存充足 |
| 一致性要求 | 需要强一致性 | 可接受最终一致性 |
注意事项:
LongAdder.sum()不是原子操作,在并发更新时可能返回不精确的值LongAdder不支持compareAndSet操作- 如果需要精确的当前值,应使用
AtomicLong
LongAccumulator:通用累加器
LongAccumulator 是 LongAdder 的通用版本,支持自定义累加函数:
1 | |
Java 9+ 的 VarHandle
Java 9 引入了 VarHandle,作为 Unsafe 和原子类的替代方案,提供了更安全、更灵活的变量访问方式。
VarHandle 的优势
| 特性 | Unsafe | 原子类 | VarHandle |
|---|---|---|---|
| 安全性 | 不安全 | 安全 | 安全 |
| 性能 | 最高 | 高 | 高 |
| 灵活性 | 高 | 低 | 高 |
| 内存访问模式 | 无 | 固定 | 可选 |
| 官方支持 | 不推荐 | 推荐 | 推荐 |
VarHandle 的使用
1 | |
VarHandle 的内存访问模式
VarHandle 提供了四种内存访问模式,对应不同的内存语义:
| 访问模式 | 方法 | 内存语义 |
|---|---|---|
| Plain | get(), set() |
无内存屏障,最弱保证 |
| Opaque | getOpaque(), setOpaque() |
保证原子性,不保证顺序 |
| Acquire/Release | getAcquire(), setRelease() |
单向内存屏障 |
| Volatile | getVolatile(), setVolatile() |
完整的 volatile 语义 |
并发数据结构
java.util.concurrent 包提供了丰富的线程安全数据结构,它们采用不同的同步策略来实现线程安全。本节将按照实现机制对这些数据结构进行分类介绍。
并发数据结构总览
基于 AQS 的阻塞容器
这类容器的阻塞操作(如 put()、take())依赖 ReentrantLock + Condition 实现,而 ReentrantLock 基于 AQS。
BlockingQueue 接口体系
| 类名 | 底层结构 | 有界性 | 锁策略 | 特点 |
|---|---|---|---|---|
| ArrayBlockingQueue | 数组 | 有界 | 单锁 | FIFO,支持公平/非公平 |
| LinkedBlockingQueue | 链表 | 可选 | 双锁(读写分离) | 高吞吐,默认无界 |
| PriorityBlockingQueue | 堆 | 无界 | 单锁 | 优先级排序 |
| DelayQueue | 堆 | 无界 | 单锁 | 延迟元素,需实现 Delayed |
| SynchronousQueue | 无存储 | 零容量 | 无锁/CAS | 直接传递,生产者等待消费者 |
| LinkedTransferQueue | 链表 | 无界 | 无锁/CAS | 支持 transfer(),Java 7+ |
ArrayBlockingQueue vs LinkedBlockingQueue
选择建议:
- ArrayBlockingQueue:容量固定,内存占用可控,适合资源受限场景
- LinkedBlockingQueue:读写分离锁,吞吐量更高,适合高并发场景
SynchronousQueue:零容量队列
SynchronousQueue 不存储任何元素,每个 put() 必须等待一个 take():
1 | |
适用场景:
- 线程间直接传递数据
Executors.newCachedThreadPool()使用 SynchronousQueue
DelayQueue:延迟队列
DelayQueue 中的元素必须实现 Delayed 接口,只有到期的元素才能被取出:
1 | |
适用场景:
- 定时任务调度
- 缓存过期处理
- 订单超时取消
BlockingDeque 接口
LinkedBlockingDeque 是唯一的 BlockingDeque 实现,支持双端操作:
1 | |
适用场景:
- 工作窃取(Work Stealing)模式
- 需要 LIFO 和 FIFO 混合操作的场景
基于 CAS 的非阻塞容器
这类容器使用 CAS 操作实现线程安全,不会阻塞线程,适合高并发读写场景。
ConcurrentHashMap
ConcurrentHashMap 是最常用的并发 Map 实现,其实现在不同 Java 版本有显著变化:
| 版本 | 实现方式 | 锁粒度 |
|---|---|---|
| Java 7 | 分段锁(Segment) | 16 个段,每段一把锁 |
| Java 8+ | CAS + synchronized | Node 级别,更细粒度 |
Java 8+ 的关键优化:
- 取消分段锁:锁粒度从 Segment 级别细化到 Node 级别
- CAS 插入首节点:桶为空时使用 CAS 插入,无需加锁
- synchronized 锁链表头:桶非空时,只锁链表头节点
- 红黑树优化:链表长度超过 8 时转为红黑树
1 | |
ConcurrentSkipListMap / ConcurrentSkipListSet
基于**跳表(Skip List)**实现的有序并发容器:
ConcurrentHashMap 的 size() 与 mappingCount()
ConcurrentHashMap 提供两种获取元素数量的方法:
| 方法 | 返回类型 | 说明 |
|---|---|---|
size() |
int | 返回元素数量,最大值为 Integer.MAX_VALUE |
mappingCount() |
long | Java 8+ 新增,返回 long 类型,适合大容量场景 |
1 | |
ConcurrentSkipListMap/Set
ConcurrentSkipListMap 是基于跳表(Skip List)实现的并发有序 Map,ConcurrentSkipListSet 基于它实现。
跳表结构
跳表是一种基于链表的数据结构,通过多层索引实现 O(log n) 的查找效率:
跳表 vs 红黑树:
| 特性 | 跳表 | 红黑树 |
|---|---|---|
| 时间复杂度 | O(log n) 平均 | O(log n) 最坏 |
| 实现复杂度 | 简单 | 复杂 |
| 并发友好性 | 高(局部锁) | 低(需要全局锁) |
| 范围查询 | 高效 | 需要中序遍历 |
1 | |
ConcurrentLinkedQueue / ConcurrentLinkedDeque
基于 Michael-Scott 算法 实现的无锁并发队列:
Michael-Scott 算法特点:
- 两阶段入队:先 CAS 更新 tail.next,再 CAS 更新 tail
- 惰性更新 tail:tail 可能落后于实际尾节点
- 无锁设计:所有操作通过 CAS 完成
1 | |
ConcurrentLinkedDeque 是双端版本,支持 addFirst()、addLast()、pollFirst()、pollLast() 等操作。
基于 Copy-On-Write 的容器
Copy-On-Write(写时复制)容器在写操作时复制整个底层数组,读操作无需加锁。
CopyOnWriteArrayList
1 | |
CopyOnWriteArraySet
CopyOnWriteArraySet 基于 CopyOnWriteArrayList 实现,通过 addIfAbsent() 保证元素唯一性:
1 | |
适用场景:
- 读多写少(读写比例 > 10:1)
- 数据量较小(复制成本可接受)
- 需要迭代器弱一致性(不抛出 ConcurrentModificationException)
不适用场景:
- 写操作频繁
- 数据量大(复制成本高)
- 需要强一致性迭代
同步器
Phaser:多阶段屏障
Phaser 是 Java 7 引入的灵活同步器,可以看作 CyclicBarrier 和 CountDownLatch 的增强版:
| 特性 | CountDownLatch | CyclicBarrier | Phaser |
|---|---|---|---|
| 可重用 | 否 | 是 | 是 |
| 动态参与者 | 否 | 否 | 是 |
| 多阶段 | 否 | 否 | 是 |
| 层次结构 | 否 | 否 | 是 |
1 | |
Phaser 状态机:
Exchanger:线程间数据交换
Exchanger 用于两个线程之间交换数据:
1 | |
适用场景:
- 遗传算法中的基因交换
- 流水线设计中的数据传递
- 双缓冲技术
Fork/Join 框架
Fork/Join 框架是 Java 7 引入的并行计算框架,采用分治策略和工作窃取算法。
工作窃取算法
工作窃取特点:
- 每个工作线程有自己的双端队列
- 工作线程从队列头部取任务(LIFO)
- 空闲线程从其他队列尾部窃取任务(FIFO)
- 减少竞争,提高并行效率
ForkJoinPool
1 | |
RecursiveAction vs RecursiveTask
| 类型 | 返回值 | 适用场景 |
|---|---|---|
| RecursiveAction | void | 无返回值的并行任务(如并行排序) |
| RecursiveTask |
V | 有返回值的并行任务(如并行求和) |
1 | |
CountedCompleter
CountedCompleter 是 Java 8 引入的抽象类,适合处理异步完成的场景:
1 | |
异步编程工具
CompletableFuture
CompletableFuture 是 Java 8 引入的异步编程工具,支持链式调用和组合操作:
创建方式
| 方法 | 说明 |
|---|---|
supplyAsync(Supplier) |
异步执行有返回值的任务 |
runAsync(Runnable) |
异步执行无返回值的任务 |
completedFuture(value) |
创建已完成的 Future |
转换操作
| 方法 | 说明 |
|---|---|
thenApply(Function) |
同步转换结果 |
thenApplyAsync(Function) |
异步转换结果 |
thenCompose(Function) |
扁平化嵌套的 CompletableFuture |
消费操作
| 方法 | 说明 |
|---|---|
thenAccept(Consumer) |
消费结果,无返回值 |
thenRun(Runnable) |
执行动作,不使用结果 |
组合操作
| 方法 | 说明 |
|---|---|
thenCombine(other, BiFunction) |
合并两个 Future 的结果 |
allOf(futures...) |
等待所有 Future 完成 |
anyOf(futures...) |
等待任一 Future 完成 |
异常处理
| 方法 | 说明 |
|---|---|
exceptionally(Function) |
处理异常,返回默认值 |
handle(BiFunction) |
同时处理结果和异常 |
whenComplete(BiConsumer) |
完成时回调(不改变结果) |
1 | |
FutureTask
FutureTask 是 Future 接口的基本实现,可以包装 Callable 或 Runnable:
1 | |
FutureTask 状态常量:
| 状态 | 值 | 说明 |
|---|---|---|
| NEW | 0 | 初始状态 |
| COMPLETING | 1 | 正在设置结果 |
| NORMAL | 2 | 正常完成 |
| EXCEPTIONAL | 3 | 异常完成 |
| CANCELLED | 4 | 已取消 |
| INTERRUPTING | 5 | 正在中断 |
| INTERRUPTED | 6 | 已中断 |
volatile 的可见性影响
下图展示了 volatile 变量如何影响线程间的可见性:
锁的分类与选择
Java 主流锁概览
乐观锁与悲观锁
- 悲观锁适合写操作多的场景,先加锁可以保证写操作时数据正确。
- 乐观锁适合读操作多的场景,不加锁的特点能够使其读操作的性能大幅提升。
自旋锁与非自旋锁
锁升级的思路
线程状态的可视化观察
synchronized 导致的 BLOCKED 状态
使用 JVisualVM 和 JMC 观察 synchronized 导致的线程阻塞状态:
JVisualVM 会专门把 BLOCKED 状态显示为 “Monitor” 态。
LockSupport.park() 导致的 WAITING 状态
使用 JVisualVM 和 JMC 观察 LockSupport.park() 导致的线程等待状态:
需要注意的是,只有 JVisualVM 会显示"驻留"这个状态,JMC 和 JConsole 都是直接显示标准的 WAITING 状态。基于 AQS 的 Lock 和 await 语义都会导致 WAITING 状态,这会和 Object.wait() 产生的 WAITING 状态混淆。JVisualVM 能够把 Object.wait() 和 LockSupport.park() 导致的 WAITING 状态区分开来。
Java 线程模型的演化:从绿色线程到虚拟线程
Java 的线程模型经历了三个重要阶段:绿色线程(Green Threads)→ 原生线程(Native Threads)→ 虚拟线程(Virtual Threads)。理解这一演化历程,有助于我们理解为什么 Java 在 JDK 21 中重新引入了用户态线程。
三种线程模型对比
演化历程详解
第一阶段:绿色线程(JDK 1.0 ~ JDK 1.2)
背景:1995 年 Java 诞生时,许多操作系统(如早期的 Solaris、经典 Mac OS)要么不支持原生线程,要么线程实现不成熟。为了实现"Write Once, Run Anywhere"的目标,Sun 选择在 JVM 层面实现线程调度。
实现方式:
- 所有 Java 线程由 JVM 在用户态调度
- 多个绿色线程映射到单个操作系统进程/线程
- JVM 自己实现线程切换、调度算法
优点:
- 线程创建和切换开销极低(无需系统调用)
- 跨平台一致性好
- 不依赖操作系统的线程支持
致命缺陷:
- 无法利用多核 CPU:所有绿色线程共享一个内核线程,即使有多个 CPU 核心也只能使用一个
- 阻塞问题:任何一个绿色线程执行阻塞系统调用(如 I/O),整个进程都会阻塞
- 无法利用操作系统的抢占式调度:需要线程主动让出 CPU
第二阶段:原生线程(JDK 1.3 ~ JDK 20)
背景:到 1990 年代末,主流操作系统(Windows NT、Solaris、Linux)都提供了成熟的内核级线程支持。多核 CPU 开始普及,绿色线程的单核限制成为严重瓶颈。
转变原因:
| 因素 | 绿色线程的问题 | 原生线程的优势 |
|---|---|---|
| 多核利用 | 只能使用单核 | 可以并行使用所有 CPU 核心 |
| 阻塞处理 | 一个线程阻塞,全部阻塞 | 一个线程阻塞,其他线程继续运行 |
| 调度质量 | JVM 实现的协作式调度 | OS 提供的抢占式调度,更公平 |
| 系统集成 | 无法与 OS 工具集成 | 可以用 top、ps 等工具监控 |
| Native 代码 | 与 JNI 交互困难 | 与 C/C++ 库无缝集成 |
实现方式:
- 每个
java.lang.Thread直接映射到一个操作系统线程 - 线程调度完全由操作系统负责
- JVM 只负责线程的创建和生命周期管理
优点:
- 真正的并行执行,充分利用多核 CPU
- 阻塞操作不影响其他线程
- 与操作系统深度集成
新的问题:
- 线程创建成本高:每个线程需要分配 1MB 左右的栈空间
- 上下文切换开销大:涉及用户态/内核态切换
- 线程数量受限:通常只能创建数千个线程
- C10K 问题:难以处理大量并发连接
1 | |
第三阶段:虚拟线程(JDK 21+)
背景:随着微服务、云原生架构的普及,应用需要处理的并发连接数从数千增长到数十万甚至数百万。Go 语言的 goroutine 证明了 M:N 线程模型在高并发场景下的优势。
为什么不是简单回归绿色线程?
虚拟线程与早期绿色线程有本质区别:
| 特性 | 绿色线程(JDK 1.x) | 虚拟线程(JDK 21+) |
|---|---|---|
| 映射模型 | N:1(多对一) | M:N(多对多) |
| 多核利用 | 无法利用 | 完全利用 |
| 阻塞处理 | 阻塞整个进程 | 只阻塞当前虚拟线程,载体线程可执行其他虚拟线程 |
| 调度器 | 简单的协作式调度 | 基于 ForkJoinPool 的工作窃取调度 |
| 栈管理 | 固定大小栈 | 动态增长的栈(初始仅几百字节) |
| 与平台线程关系 | 替代关系 | 共存关系(虚拟线程运行在平台线程之上) |
虚拟线程的核心创新:
关键技术突破:
- Continuation(续体):虚拟线程的栈可以被"冻结"并保存到堆内存,之后再"解冻"恢复执行
- 非阻塞 I/O 透明化:JDK 内部将阻塞 I/O 改造为非阻塞实现,对用户代码透明
- 工作窃取调度:基于 ForkJoinPool,空闲的载体线程可以窃取其他载体线程的虚拟线程
三种模型的综合对比
| 维度 | 绿色线程(N:1) | 原生线程(1:1) | 虚拟线程(M:N) |
|---|---|---|---|
| 线程创建成本 | 极低(~1μs) | 高(~1ms) | 极低(~1μs) |
| 内存占用/线程 | 低(~KB) | 高(~1MB) | 极低(~几百字节起) |
| 最大线程数 | 理论无限 | 数千~数万 | 数百万 |
| 多核利用 | 无法利用 | 完全利用 | 完全利用 |
| 阻塞处理 | 阻塞整个进程 | 只阻塞当前线程 | 只阻塞当前虚拟线程 |
| 调度开销 | 低(用户态) | 高(内核态切换) | 低(用户态为主) |
| 与 OS 集成 | 差 | 好 | 中等 |
| 适用场景 | 早期单核系统 | 计算密集型、线程数适中 | I/O 密集型、高并发 |
JVM 的设计考量
为什么 JDK 1.3 放弃绿色线程?
- 多核 CPU 普及:绿色线程无法利用多核是致命缺陷
- 操作系统线程成熟:主流 OS 都提供了高质量的线程实现
- 阻塞问题无解:在 N:1 模型下,阻塞系统调用会阻塞所有线程
- JNI 兼容性:原生代码期望与真正的 OS 线程交互
为什么 JDK 21 引入虚拟线程?
- 高并发需求:现代应用需要处理数十万并发连接
- I/O 密集型负载:大多数服务器应用是 I/O 密集型,线程大部分时间在等待
- Go 的成功验证:goroutine 证明了 M:N 模型的可行性
- 技术突破:Continuation 技术使得栈的保存/恢复成为可能
- 保持编程模型:虚拟线程与传统线程 API 兼容,无需学习新的异步编程模型
小结
Java 线程模型的演化体现了工程上的务实选择:
- 绿色线程时代:在操作系统线程不成熟时,JVM 自己实现线程是合理的
- 原生线程时代:当 OS 线程成熟且多核 CPU 普及时,1:1 模型是最佳选择
- 虚拟线程时代:当并发需求超过原生线程的承载能力时,M:N 模型成为必然
虚拟线程不是简单地"回到过去",而是在保留原生线程优势(多核利用、阻塞隔离)的基础上,解决了其资源消耗问题。这是技术螺旋式上升的典型案例。
为什么虚拟线程在当今时代更具优势
前文解释了 Java 线程模型的演化历程,但一个核心问题尚未深入分析:为什么虚拟线程(或更广义的协程)在当今时代突然比平台线程更受欢迎? 这不是简单的技术时尚,而是由工作负载特征、硬件架构和编程模型三个维度的根本变化所驱动的。
工作负载特征的根本转变
现代服务端应用的工作负载已经从 CPU 密集型 转变为 I/O 密集型。这一转变是理解虚拟线程价值的关键。
一个典型的微服务请求处理过程中,线程的实际 CPU 执行时间可能只占总时间的 1-5%,其余 95-99% 的时间都在等待:
- 等待数据库查询返回(网络 I/O + 磁盘 I/O)
- 等待下游 RPC 服务响应(网络 I/O)
- 等待外部 HTTP API 响应(网络 I/O)
- 等待消息队列确认(网络 I/O)
在这种场景下,平台线程的 1MB 栈空间和内核调度开销成为纯粹的资源浪费。线程大部分时间处于 WAITING 或 TIMED_WAITING 状态,却仍然占用着宝贵的内存资源。
并发规模的数量级跃升
从 C10K 问题(单机处理 1 万并发连接)到 C10M 问题(单机处理 1000 万并发连接),并发连接数增长了三个数量级。
平台线程的资源消耗使其无法支撑这种规模的并发。即使不考虑业务逻辑的内存占用,仅线程栈空间就会耗尽服务器内存。
编程模型的权衡:阻塞 vs 异步
在虚拟线程出现之前,处理高并发 I/O 有两条主要路径:
虚拟线程提供了第三条路径:阻塞语义 + 非阻塞实现,兼顾了编程简单性和高并发能力。这正是 JEP 444 所追求的目标——让开发者用熟悉的同步代码风格,获得异步编程的性能收益。
不同场景下的适用性分析
虚拟线程并非在所有场景下都优于平台线程。选择的核心因素取决于 任务的阻塞特征 和 并发规模需求。
| 场景特征 | 平台线程 | 虚拟线程 | 分析 |
|---|---|---|---|
| CPU 密集型计算 | 适合 | 无优势 | 线程数应等于 CPU 核心数,虚拟线程无法提供额外收益。计算密集型任务不会主动让出 CPU,虚拟线程的调度优势无从发挥。 |
| I/O 密集型 + 短等待(<1ms) | 可用 | 略优 | 短等待场景下,平台线程的上下文切换开销相对可接受,虚拟线程优势不明显。 |
| I/O 密集型 + 长等待(>10ms) | 浪费资源 | 显著更优 | 等待期间虚拟线程不占用平台线程资源,系统可以用有限的载体线程服务大量并发请求。 |
| 高并发连接(>10K) | 受限 | 显著更优 | 突破平台线程数量限制,支持百万级并发。 |
| 需要 I/O 多路复用 | 需要 Selector API | 透明支持 | JDK 内部已将阻塞 I/O 改造为非阻塞实现,开发者无需直接使用 Selector。 |
| 需要与 native 代码交互 | 适合 | 需注意 Pinning | JNI 调用可能导致虚拟线程被钉住,需要评估影响。 |
关键洞察
虚拟线程的价值不在于让单个任务执行更快,而在于:
- 资源效率:让系统能够同时处理更多的等待中的任务,而不浪费平台线程资源
- 编程简单性:保持简单的同步编程模型,避免回调地狱和响应式编程的复杂性
- 可观测性:保留完整的调用栈信息,便于调试和问题定位
何时应该选择虚拟线程:
- 应用是 I/O 密集型,大部分时间在等待外部资源
- 需要处理大量并发连接或请求
- 希望保持简单的同步编程模型
- 不需要频繁与 native 代码交互
何时应该继续使用平台线程:
- 应用是 CPU 密集型,计算是主要瓶颈
- 并发需求适中(数百到数千)
- 需要与 synchronized 块内的阻塞操作共存(Pinning 问题)
- 需要精确控制线程优先级和调度
I/O 多路复用与虚拟线程的关系
一个常见的疑问是:既然 Java NIO 已经提供了 I/O 多路复用(Selector),为什么还需要虚拟线程?
虚拟线程并没有取代 I/O 多路复用,而是在其之上提供了更高层次的抽象。JDK 内部将 java.net.Socket、java.io.InputStream 等阻塞 API 改造为基于非阻塞 I/O 的实现。当虚拟线程执行阻塞 I/O 操作时:
- JDK 内部将其转换为非阻塞操作
- 虚拟线程被挂起(unmount),释放载体线程
- I/O 完成后,虚拟线程被重新调度执行
这种设计让开发者享受同步编程的简单性,同时获得非阻塞 I/O 的性能优势。
虚拟线程与其他语言并发方案的对比
Java 虚拟线程并非凭空出现,而是在借鉴其他语言并发方案的基础上,结合 Java 生态的特点做出的设计选择。理解这些差异有助于更准确地把握虚拟线程的定位和适用场景。
核心概念辨析
在讨论具体实现之前,需要明确几个经常被混淆的概念:
| 概念 | 定义 | 调度方式 | 代表实现 |
|---|---|---|---|
| 协程(Coroutine) | 可以在特定点暂停和恢复执行的程序组件 | 协作式,需显式让出 | Kotlin Coroutines、Lua coroutines |
| 半协程(Semi-coroutine) | 只能让出到调用者的协程,不能让出到任意协程 | 协作式,受限让出 | Python generators、JavaScript generators |
| 纤程(Fiber) | 用户态线程,由运行时而非操作系统调度 | 通常协作式 | Ruby Fiber、Windows Fiber API |
| 绿色线程(Green Thread) | 由虚拟机/运行时调度的轻量级线程 | 可协作或抢占 | 早期 Java(JDK 1.1)、Erlang processes |
| 虚拟线程(Virtual Thread) | Java 21 引入的轻量级线程,由 JVM 调度 | 协作式(阻塞点让出) | Java Virtual Threads |
Go Goroutine:M:N 调度的先驱
Go 语言的 goroutine 是现代 M:N 调度模型的成功实践,其调度器采用 GMP 模型:
- G(Goroutine):轻量级执行单元
- M(Machine):操作系统线程
- P(Processor):逻辑处理器,持有本地运行队列
Go goroutine 的关键特性:
- 异步抢占(Go 1.14+):通过信号机制实现,goroutine 可在任意安全点被抢占,解决了长时间运行的 goroutine 阻塞调度的问题
- 动态栈:初始栈仅 2KB,可动态增长和收缩
- channel 通信:遵循 CSP(Communicating Sequential Processes)模型,通过通信共享内存而非通过共享内存通信
- 网络轮询器集成:所有网络 I/O 都通过 netpoller 实现非阻塞
与 Java 虚拟线程的关键差异:
| 维度 | Go goroutine | Java 虚拟线程 |
|---|---|---|
| 调度方式 | 异步抢占(信号驱动) | 协作式(阻塞点让出) |
| 栈管理 | 连续栈,动态增长/收缩 | 栈帧存储在堆上,按需分配 |
| 同步原语 | channel 为主,mutex 为辅 | 传统锁机制,ReentrantLock 推荐 |
| 线程局部存储 | 无 goroutine-local storage | 保留 ThreadLocal(但不推荐) |
| 与现有代码兼容 | 需要使用 Go 风格编写 | 现有 Java 代码无需修改 |
| 阻塞系统调用 | 自动创建新 M 处理 | 可能导致 Pinning |
Go 的设计更加激进,完全重新定义了并发编程模型;Java 虚拟线程则更保守,优先保证与现有生态的兼容性。
Ruby Fiber:显式协作的半协程
Ruby 的 Fiber 是典型的半协程实现,需要显式让出控制权:
1 | |
Ruby 3.0+ 的 Fiber Scheduler:
Ruby 3.0 引入了 Fiber Scheduler 接口,允许在 I/O 阻塞时自动切换 Fiber,这与 Java 虚拟线程的设计理念趋同:
1 | |
与 Java 虚拟线程的关键差异:
| 维度 | Ruby Fiber | Java 虚拟线程 |
|---|---|---|
| 让出方式 | 显式 Fiber.yield(传统)或 I/O 自动让出(3.0+) |
I/O 阻塞时自动让出 |
| 调度器 | 可插拔,用户可自定义 | JVM 内置,基于 ForkJoinPool |
| 与线程关系 | Fiber 运行在单个线程内 | 虚拟线程可在不同载体线程间迁移 |
| 并行能力 | 单线程内无真正并行 | 可利用多核并行 |
JavaScript async/await:单线程事件循环
JavaScript 的异步模型基于单线程事件循环,async/await 是 Promise 的语法糖:
1 | |
async/await 的"传染性"问题:
1 | |
与 Java 虚拟线程的关键差异:
| 维度 | JavaScript async/await | Java 虚拟线程 |
|---|---|---|
| 线程模型 | 单线程事件循环 | 多线程,可利用多核 |
| 语法标记 | 需要 async/await 关键字 | 无需特殊语法 |
| 函数签名 | async 函数返回 Promise | 普通方法,无签名变化 |
| 传染性 | 高,async 向上传播 | 无,同步代码直接运行 |
| 调用栈 | 异步边界处断开 | 完整保留 |
| 调试体验 | 需要特殊工具支持 | 与普通线程一致 |
设计取舍的深层分析
Java 虚拟线程的设计体现了以下核心取舍:
1. 兼容性优先于激进创新
Java 选择保持 java.lang.Thread 的完整语义,意味着:
- 现有代码无需修改即可在虚拟线程上运行
- 保留了 ThreadLocal、线程中断、线程状态等所有 API
- 代价是无法像 Go 那样完全重新设计线程模型
2. 隐式让出 vs 显式让出
| 方式 | 优点 | 缺点 | 采用者 |
|---|---|---|---|
| 显式让出 | 控制精确,行为可预测 | 需要修改代码,易遗漏 | Ruby Fiber、Kotlin Coroutines |
| 隐式让出 | 无需修改代码,透明 | 依赖运行时识别阻塞点 | Java 虚拟线程、Go(部分) |
Java 选择隐式让出,JDK 内部改造了所有阻塞 API(Socket、InputStream、Lock 等),使其在虚拟线程上自动让出。这带来了极大的便利性,但也引入了 Pinning 问题——当阻塞发生在 synchronized 块内或 native 方法中时,无法让出。
3. 调用栈完整性
Java 虚拟线程保留完整的调用栈信息,这对于调试和问题定位至关重要。当虚拟线程被挂起时,其栈帧被保存到堆内存;恢复时,栈帧被重新加载。这与 JavaScript 的 async/await 形成鲜明对比——后者在 await 边界处调用栈会断开。
各方案适用场景总结
| 场景 | 推荐方案 | 理由 |
|---|---|---|
| 新建高并发 Java 服务 | 虚拟线程 | 简单、高效、与现有库兼容 |
| 新建高并发服务(语言不限) | Go goroutine | 成熟、高性能、生态完善 |
| 现有 Java 系统升级 | 虚拟线程 | 迁移成本最低 |
| 前端/Node.js 服务 | async/await | 语言原生支持,生态成熟 |
| 需要精细控制的场景 | Kotlin Coroutines | 显式控制,结构化并发 |
虚拟线程与锁的交互(JDK 21+)
Java 21 正式引入了虚拟线程(Virtual Threads,JEP 444),这对锁的使用带来了新的考量。虚拟线程的核心优势在于阻塞操作时可以自动让出载体线程(Carrier Thread),但 synchronized 和某些 native 方法会导致"钉住"(Pinning)问题。
Pinning 问题
定义:根据 JEP 444,Pinning(钉住)是指虚拟线程被固定到载体线程上,无法在阻塞时让出的情况。
Pinning 发生的两种场景(依据 JEP 444):
- synchronized 块或方法内的阻塞操作:当虚拟线程在 synchronized 块内执行阻塞操作时,它会被钉住
- native 方法执行期间:当虚拟线程执行 native 方法(JNI 调用)时,它会被钉住
当虚拟线程在 synchronized 块内执行阻塞操作时,它会被"钉住"到载体线程上,无法让出:
synchronized vs ReentrantLock 在虚拟线程中的表现
| 特性 | synchronized | ReentrantLock |
|---|---|---|
| 虚拟线程阻塞时 | 钉住载体线程 | 可以让出载体线程 |
| 原因 | JVM 内置锁,与虚拟线程调度器集成不完善 | 基于 LockSupport.park(),已适配虚拟线程 |
| JDK 21+ 建议 | 避免在 synchronized 块内执行阻塞操作 | 推荐用于虚拟线程场景 |
最佳实践
1 | |
诊断 Pinning 问题
可以使用以下 JVM 参数诊断 Pinning:
1 | |
注意:虚拟线程的详细使用指南请参考 Java 线程池笔记 中的虚拟线程章节。
虚拟线程对中间件和框架的影响
虚拟线程的引入不仅影响锁的使用方式,还对整个 Java 生态系统产生深远影响。Servlet 容器、Spring 框架、数据库连接池、HTTP 客户端等中间件都需要适配或重新评估其设计。
Servlet 容器的变革
传统线程模型的局限
传统 Servlet 容器(Tomcat、Jetty、Undertow)采用"一请求一线程"模型:
线程池大小直接限制了并发处理能力。当所有线程都在等待 I/O(数据库查询、RPC 调用)时,新请求只能排队等待。
虚拟线程模型
Tomcat 10.1+、Jetty 12+、Undertow 2.3+ 已支持虚拟线程:
Tomcat 配置示例:
1 | |
Spring Boot 3.2+ 配置:
1 | |
1 | |
性能对比
| 指标 | 传统线程池(500线程) | 虚拟线程 |
|---|---|---|
| 最大并发连接 | ~500 | 数十万 |
| 内存占用(仅线程栈) | ~500MB | ~几十MB |
| I/O 等待时资源利用 | 线程被阻塞,资源浪费 | 自动让出,资源复用 |
| 配置复杂度 | 需要精细调优 | 大幅简化 |
Spring Framework 的适配
Spring MVC
Spring MVC 6.1+ 与虚拟线程配合良好:
关键变化:
- 简化异步编程:不再需要
@Async和CompletableFuture来避免阻塞 - 保持同步代码风格:Controller 方法可以直接返回结果,无需返回
Mono或Flux - 完整调用栈:异常堆栈信息完整,便于调试
1 | |
Spring WebFlux 的定位变化
Spring WebFlux 是响应式编程模型,与虚拟线程的关系需要重新审视:
| 场景 | 推荐方案 | 理由 |
|---|---|---|
| 新建 I/O 密集型服务 | 虚拟线程 + Spring MVC | 代码简单,性能优秀 |
| 需要流式处理/背压 | Spring WebFlux | 响应式流原生支持 |
| 现有 WebFlux 项目 | 继续使用 WebFlux | 迁移成本高,收益有限 |
| 混合场景 | 两者结合 | WebFlux 处理流,MVC 处理普通请求 |
Brian Goetz 的观点:虚拟线程不是要取代响应式编程,而是为那些不需要响应式编程复杂性的场景提供更简单的选择。
数据库访问层的影响
JDBC 驱动
大多数 JDBC 驱动使用阻塞 I/O,在虚拟线程上可以正常工作:
1 | |
潜在问题:
- 驱动内部的 synchronized:某些 JDBC 驱动内部使用 synchronized 块,可能导致 Pinning
- 连接验证:连接池的连接验证逻辑可能包含阻塞操作
主流驱动适配状态:
| 驱动 | 虚拟线程兼容性 | 备注 |
|---|---|---|
| MySQL Connector/J 8.1+ | 良好 | 已优化减少 Pinning |
| PostgreSQL JDBC 42.6+ | 良好 | 支持虚拟线程 |
| Oracle JDBC 23c+ | 良好 | 官方支持 |
| H2 | 需评估 | 部分场景有 Pinning |
连接池重新评估
虚拟线程场景下,连接池配置需要重新考量:
HikariCP 配置建议:
1 | |
关键洞察:虚拟线程解决了线程资源瓶颈,但数据库连接、外部服务连接等资源仍然是有限的。需要通过信号量或其他机制限制并发访问这些稀缺资源。
1 | |
HTTP 客户端的适配
Java HttpClient
JDK 11 引入的 java.net.http.HttpClient 已完全适配虚拟线程:
1 | |
第三方客户端
| 客户端 | 虚拟线程兼容性 | 备注 |
|---|---|---|
| Apache HttpClient 5.x | 良好 | 已适配 |
| OkHttp 4.x+ | 需评估 | 内部有 synchronized |
| RestTemplate | 良好 | 底层使用适配的客户端即可 |
| WebClient | 良好 | 响应式,与虚拟线程互补 |
消息中间件客户端
Kafka Client
1 | |
注意事项:
- Kafka 客户端内部有 synchronized 块,可能导致 Pinning
- 建议关注 Kafka 客户端的更新版本
RocketMQ / RabbitMQ
类似的考量适用于其他消息中间件客户端。建议:
- 使用最新版本的客户端
- 通过
-Djdk.tracePinnedThreads=full诊断 Pinning 问题 - 必要时使用 ReentrantLock 替换 synchronized
ThreadLocal 的重新审视
虚拟线程场景下,ThreadLocal 的使用需要特别注意:
问题:
- 内存放大:虚拟线程数量可能是平台线程的 1000 倍,ThreadLocal 内存占用同比放大
- 生命周期:虚拟线程通常是短生命周期的,ThreadLocal 的清理时机需要注意
替代方案:
1 | |
迁移策略与最佳实践
迁移检查清单:
- JDK 版本:升级到 JDK 21+
- 框架版本:
- Spring Boot 3.2+
- Tomcat 10.1+ / Jetty 12+ / Undertow 2.3+
- 依赖库检查:
- JDBC 驱动版本
- HTTP 客户端版本
- 消息中间件客户端版本
- 代码审查:
- 识别 synchronized 块内的阻塞操作
- 审查 ThreadLocal 使用
- 检查 native 方法调用
- 配置调整:
- 连接池大小
- 超时设置
- 资源限制策略
- 监控指标:
- 虚拟线程数量
- Pinning 事件
- 载体线程利用率
结构化并发(Structured Concurrency)
结构化并发的完整内容已独立成文,详见 Java 结构化并发。
结构化并发是 Java 并发编程的重要演进方向,与虚拟线程紧密配合,旨在解决传统并发编程中的线程泄漏、错误处理困难等问题。其核心原则是将并发任务的生命周期与代码的词法作用域绑定,通过 StructuredTaskScope API 实现自动的错误传播和取消传播。
关于 JVM 内存模型(JMM)的详细定义、八种内存操作、happens-before 规则、对象内存布局与指针压缩等内容,请参阅《JVM 的内存模型与线程》。






































