本文面向对并发编程有一定理解,但希望深入 JVM 锁实现内部机制的中高级工程师。我们将绕过 `synchronized` 关键字的表层用法,直面其在 HotSpot 虚拟机中从偏向锁、轻量级锁到重量级锁的自适应升级过程。这不仅是一次对 JVM 内部实现的探索,更是一场关于性能、资源消耗与并发模型之间工程博弈的深度复盘,帮助你理解为何一个简单的关键字背后隐藏着如此复杂的优化,以及如何在实际项目中做出更优的技术决策。
现象与问题背景
在绝大多数 Java 应用中,`synchronized` 是实现线程同步最直接、最常用的手段。然而,许多开发者对其性能的认知仍然停留在“它是一个昂贵的重量级操作”的陈旧观念上。当我们编写如下代码时,其内部发生的真实情况远比想象的复杂:
public class Counter {
private int count = 0;
public synchronized void increment() {
count++;
}
public synchronized int get() {
return count;
}
}
在早期的 JDK 版本(如 1.4 之前),`synchronized` 确实是重量级的。每一次加锁和解锁都涉及到用户态到内核态的切换,需要操作系统介入,通过互斥量(Mutex)来实现。这种上下文切换的开销极大,在并发度稍高的场景下,性能会急剧下降。然而,工程实践表明,绝大多数的锁在整个生命周期内都不存在多线程竞争。为了一个极小概率发生的事件,却要付出每次都进行重量级操作的代价,这显然是不合理的。因此,从 JDK 1.6 开始,HotSpot 虚拟机团队引入了锁升级(Lock Escalation)的优化,其核心目标就是:在无竞争或低竞争时,用极低的代价完成同步;仅在竞争激烈时,才退化为传统的重量级锁。
关键原理拆解
在深入锁升级的细节之前,我们必须回到计算机科学的基础原理,理解“锁”的本质代价是什么。这部分内容,我将以大学教授的视角来阐述。
- 1. 操作系统层面的互斥(Mutex)与上下文切换
重量级锁的底层依赖于操作系统提供的同步原语,如 Linux 下的 `futex` (Fast Userspace Mutex) 或 Windows 的 `CriticalSection`。当一个线程请求一个已被其他线程持有的锁时,它无法继续执行,操作系统调度器会剥夺其 CPU 时间片,将其状态置为阻塞(Blocked),并将其放入该锁的等待队列。当锁被释放时,操作系统再从等待队列中唤醒一个线程,将其状态置为就绪(Runnable),等待调度器分配 CPU 时间片。这个“阻塞 -> 唤醒”的过程,必然伴随着两次上下文切换(Context Switch):一次是当前线程从用户态切换到内核态进入阻塞,另一次是被唤醒后从内核态切回用户态。上下文切换涉及到保存和恢复寄存器状态、程序计数器、栈指针等,是一个非常耗费 CPU 周期的操作,通常在微秒级别。 - 2. CPU 指令层面的原子性与内存屏障
即便我们不涉及操作系统,在用户态尝试实现锁,也需要 CPU 硬件的支持。核心是原子指令,最经典的就是 `Compare-And-Swap` (CAS)。CAS 指令可以在一个不可分割的操作内完成“读取-比较-写入”的过程,保证了多核环境下的原子性。然而,CAS 并非“免费”。为了保证多核之间的可见性,执行 CAS 时,CPU 会通过总线锁或缓存锁来保证其原子性,并遵循缓存一致性协议(如 MESI)。这意味着一个核心对某个缓存行的修改,需要通知其他核心将它们对应的缓存行置为无效(Invalidate),这会引起总线流量,并在一定程度上造成性能开销。轻量级锁正是构建在 CAS 之上的。 - 3. 概率与统计假设
锁升级的整个设计哲学,建立在一个重要的工程统计假设上:“锁在绝大多数情况下由同一线程重复获取,且不存在竞争”。这个假设来自于对大量真实世界 Java 应用的观察。例如,一个对象的方法 A 和方法 B 都被 `synchronized` 修饰,同一个线程很可能连续调用 `o.A()` 和 `o.B()`。基于这个假设,JVM 设计了偏向锁,其目标是把这种场景下的同步开销降低到接近于零。
理解了这三点——操作系统调度的代价、CPU 原子指令的代价、以及优化的统计学基础——我们就能更好地理解为何 JVM 要设计出如此精巧的三级锁状态。
系统架构总览:Java 对象头与 Mark Word
锁升级的全部秘密都隐藏在 Java 对象头(Object Header)中。在 64 位 HotSpot 虚拟机中,一个普通 Java 对象在内存中由三部分组成:对象头(Header)、实例数据(Instance Data)和对齐填充(Padding)。对象头又包含两部分:Mark Word 和类型指针(Klass Pointer)。锁信息就存储在 Mark Word 中。Mark Word 是一个与机器字长相同的数据结构(64位JVM中为64位),它被设计成一个高度复用的字段,根据对象状态的变化存储不同的信息。
我们可以将 Mark Word 视为一个微型的状态机。其最低的 3 个 bit 用来表示锁状态和 GC 标记。以下是 64 位 JVM 中 Mark Word 的状态转换图(简化版):
- 状态 01 (无锁 / Normal):
- `[………………………| hashcode:31 | age:4 | 0 | 01]`
- 后 3 位是 `001`。高位存储对象的 `identityHashCode` 和 GC 分代年龄。
- 状态 01 (偏向锁 / Biased):
- `[thread_id:54 | epoch:2 | age:4 | 1 | 01]`
- 后 3 位是 `101`。高位存储持有锁的线程 ID 和一个称为 `epoch` 的时间戳。
- 状态 00 (轻量级锁 / Lightweight Locked):
- `[ptr_to_lock_record:62 | 00]`
- 后 2 位是 `00`。高位变成一个指针,指向持有锁的线程栈上的一个 `Lock Record` 结构。
- 状态 10 (重量级锁 / Heavyweight Locked):
- `[ptr_to_monitor:62 | 10]`
- 后 2 位是 `10`。高位变成一个指针,指向一个与对象关联的 `ObjectMonitor` C++ 对象。
- 状态 11 (GC 标记):
- 用于垃圾回收,这里不展开。
锁升级的过程,本质上就是对象 Mark Word 中这些比特位根据并发竞争情况,发生一系列原子性的、不可逆的转换过程:无锁 -> 偏向锁 -> 轻量级锁 -> 重量级锁。
核心模块设计与实现
现在,让我们扮演一位极客工程师,深入 HotSpot 虚拟机的 C++ 源码层面(以伪代码形式呈现),看看每一步升级是如何发生的。
第一站:偏向锁 (Biased Locking)
目标:在只有一个线程访问同步代码块的场景下,消除所有的同步开销。
实现机制:当一个线程第一次获取锁时,JVM 会使用一次 CAS 操作,将该线程的 ID 记录到锁对象的 Mark Word 中,并将锁状态标记为“偏向锁”。此后,只要是同一个线程再次进入该同步代码块,它只需检查 Mark Word 中记录的线程 ID 是否是自己。如果是,则无需任何原子操作,直接进入临界区,性能开销几乎为零。
// 伪代码: 偏向锁获取
void acquire_biased_lock(JNIEnv* env, jobject obj) {
MarkWord mark = obj->mark_word();
// 1. 检查是否为偏向锁模式,且偏向的线程就是当前线程
if (mark.is_biased() && mark.biased_to_thread() == Thread::current()) {
// 命中缓存,直接返回,无任何原子操作!
return;
}
// 2. 如果是无锁或可偏向状态,尝试CAS设置偏向
if (mark.is_unlocked() || (mark.is_biased() && mark.biased_to_thread() == null)) {
if (cas(&obj->mark_word, mark, new_mark_biased_to(Thread::current()))) {
// 成功偏向当前线程
return;
}
}
// 3. 失败:说明有竞争,或者对象已经是其他锁状态。进入慢速路径,准备撤销偏向。
revoke_bias_and_inflate(obj);
}
工程坑点与撤销(Revocation):偏向锁的收益巨大,但其撤销成本也很高。当另一个线程尝试获取这个偏向锁时,偏向模式必须被撤销。撤销过程需要等待一个全局安全点(Safepoint),在这个点上所有用户线程都暂停。然后,JVM 遍历所有线程栈,检查是否有栈帧持有着对这个对象的偏向锁,如果有,则恢复其锁记录,并将对象头设置为轻量级锁状态。这个过程非常昂贵,如果一个应用中存在大量不同线程交替获取同一把锁的场景(例如,线程池中的工作线程处理同一个队列),偏向锁反而会成为性能瓶颈。这就是为什么在 JDK 15 之后,偏向锁被默认关闭的原因。
第二站:轻量级锁 (Lightweight Locking)
目标:在线程交替执行同步块,但不存在真正“同时”竞争的情况下,避免操作系统层面的互斥。
实现机制:当偏向锁被撤销,或者一个线程在无锁对象上尝试加锁时,JVM 会首先尝试使用轻量级锁。它会在当前线程的栈帧中创建一个名为 `Lock Record` 的空间,用于拷贝锁对象当前的 Mark Word(我们称之为 Displaced Mark Word)。然后,JVM 尝试使用一个 CAS 操作,将对象的 Mark Word 更新为指向这个 `Lock Record` 的指针。如果 CAS 成功,则该线程获得锁。如果失败,说明此时有其他线程已经持有了该轻量级锁。
// 伪代码: 轻量级锁获取
bool acquire_lightweight_lock(jobject obj) {
Thread* self = Thread::current();
// 1. 在当前线程栈上创建 Lock Record
LockRecord* record = new(self->stack) LockRecord();
record->set_displaced_header(obj->mark_word()); // 保存原始 Mark Word
// 2. CAS 尝试将对象头指向 Lock Record
if (cas(&obj->mark_word, record->displaced_header(), (MarkWord)record) == record->displaced_header()) {
// CAS 成功,获取锁成功
return true;
}
// 3. CAS 失败,说明有竞争。
// 清理栈上的 Lock Record
// 进入自旋或膨胀为重量级锁的逻辑
return false;
}
自旋(Spinning):当 CAS 失败后,线程并不会立即阻塞。JVM 认为,持有锁的线程可能很快就会释放锁。因此,它会让当前线程执行一个忙循环(自旋),在循环中不断尝试获取锁。这避免了上下文切换的开销。自旋的次数不是固定的,JVM 引入了自适应自旋(Adaptive Spinning),如果一个锁之前自旋成功过,JVM 会认为它“值得”自旋更长时间,反之则会减少自旋次数,甚至直接膨胀。自旋消耗的是 CPU,它是在用 CPU 时间换取响应时间。
第三站:重量级锁 (Heavyweight Locking)
目标:处理长时间的、激烈的锁竞争,让拿不到锁的线程高效地等待。
实现机制:如果自旋了一定次数后仍然没有获取到锁,或者已经有其他线程在自旋等待同一个锁,锁就会膨胀(Inflate)为重量级锁。膨胀过程如下:JVM 在堆中创建一个 `ObjectMonitor` 对象。这个 C++ 对象内部封装了操作系统的互斥量、条件变量以及等待队列(`_cxq`, `_EntryList`, `_WaitSet`)。然后,锁对象的 Mark Word 被原子地修改为指向这个 `ObjectMonitor` 的指针,锁状态标志位改为 `10`。此后,所有对这个锁的请求,都会直接进入 `ObjectMonitor` 的逻辑,线程获取不到锁就会被放入等待队列并阻塞,直到被持有者唤醒。
// 伪代码: 锁膨胀并获取
void ObjectMonitor::enter(Thread* self) {
// 1. 尝试通过 CAS 获取 monitor 的所有权 (轻量级竞争)
if (cas(&this->_owner, NULL, self)) {
return;
}
// ... 省略重入等逻辑 ...
// 2. 竞争激烈,进入慢速路径
// 将线程包装成一个节点,放入 _EntryList 等待队列
// 调用操作系统原语 (如 futex_wait) 使线程阻塞
park_thread(self);
}
void ObjectMonitor::exit(Thread* self) {
// ... 释放锁所有权 ...
// 从 _EntryList 中唤醒一个等待的线程
// 调用操作系统原语 (如 futex_wake)
unpark_thread(next_waiter);
}
一旦锁膨胀为重量级锁,它就不会再降级回轻量级锁或偏向锁。这个 `ObjectMonitor` 会一直保留,直到对象变成垃圾被回收。这是为了避免在锁的竞争状态频繁波动时,反复进行膨胀和收缩带来的额外开销。
对抗层:性能与资源的 Trade-off 分析
锁升级的每一步都是一次精密的赌博,背后是深刻的工程权衡。
- 偏向锁
- 优势: 在无竞争场景下,将同步开销降至最低,只有一个 CAS(第一次)和后续的几次内存读取。
- 劣势: 撤销成本极高,需要 STW (Stop-The-World)。对于锁竞争频繁,或者大量不同线程轮流使用同一把锁的场景(如典型的生产者消费者模型),偏向锁会造成严重的性能回退。
- 轻量级锁
- 优势: 通过自旋避免了内核态和用户态的切换,适用于锁持有时间极短的场景。响应速度快。
- 劣势: 自旋会持续消耗 CPU 资源。如果锁被长时间持有,自旋就是在空转浪费 CPU,还不如直接阻塞让出 CPU 给其他线程。
- 重量级锁
- 优势: 不会空耗 CPU。线程进入等待队列,让出 CPU 资源,系统整体吞吐量更高。提供了公平性选项和复杂的等待/通知机制(`wait/notify`)。
- 劣势: 单次获取和释放的延迟最高,因为涉及上下文切换。
架构演进与落地路径
Java 锁的实现并非一成不变,其演进路径反映了硬件发展和软件编程范式的变迁。
- 洪荒时代 (JDK 1.4及以前): 只有重量级锁。`synchronized` 简单粗暴,但性能口碑差,导致当时许多高性能库(如 `java.util.concurrent` 的前身)选择自己实现锁。
- 优化革命 (JDK 1.6): 锁升级机制全面引入。偏向锁、轻量级锁、自适应自旋的加入,让 `synchronized` 的性能在绝大多数场景下得到了数量级的提升,甚至可以与 `ReentrantLock` 媲美。这标志着 JVM 开始承担更多的动态优化责任。
- 后优化时代 (JDK 15+): 随着多核处理器成为标配,以及异步、响应式编程范式的流行,程序中“一个锁长时间被一个线程持有”的经典假设变得越来越不成立。偏向锁在很多现代高并发应用中反而成为负担。因此,从 JDK 15 开始,偏向锁被默认禁用,但仍可通过 `-XX:+UseBiasedLocking` 手动开启。这表明 JVM 的优化策略正在向适应更多核、更高竞争度的方向演进。
给工程师的落地建议:
- 信任 JVM: 在绝大多数情况下,默认的锁升级策略都是最优的。不要轻易通过 JVM 参数去关闭或调整锁的行为,除非你通过精准的性能剖析(如 JFR, Java Flight Recorder)证明了锁升级(特别是偏向锁撤销)是你的应用瓶颈。
- 理解场景: 当你设计一个高并发组件时,要思考你的锁竞争模型。如果是一个任务队列,被线程池中的大量线程频繁地、短暂地加锁解锁,那么这个场景天生就不适合偏向锁。
- 放眼未来: `synchronized` 依然是简单场景下最清晰的选择。但对于更复杂的同步需求,如可中断锁、公平锁、读写分离,`java.util.concurrent` 包下的工具(`ReentrantLock`, `ReadWriteLock`, `StampedLock`)提供了更丰富的语义和更强的控制力。了解 `synchronized` 的底层,能帮助你更好地理解 J.U.C 包中锁实现的价值和设计动机。
总而言之,Java 的 `synchronized` 关键字不是一个静态的指令,而是一个动态的、自适应的并发控制系统。它体现了在多变的运行时环境下,通过牺牲一定的复杂性来换取极致性能的现代虚拟机设计哲学。
延伸阅读与相关资源
-
想系统性规划股票、期货、外汇或数字币等多资产的交易系统建设,可以参考我们的
交易系统整体解决方案。 -
如果你正在评估撮合引擎、风控系统、清结算、账户体系等模块的落地方式,可以浏览
产品与服务
中关于交易系统搭建与定制开发的介绍。 -
需要针对现有架构做评估、重构或从零规划,可以通过
联系我们
和架构顾问沟通细节,获取定制化的技术方案建议。