本文旨在为有经验的 Java 工程师彻底厘清 synchronized 关键字背后的锁升级机制。我们将不仅仅停留在偏向锁、轻量级锁、重量级锁的概念层面,而是深入到 JVM 对象头(Mark Word)的比特位布局、CPU 的原子操作(CAS)、操作系统用户态与内核态切换的昂贵成本,以及这些底层原理如何共同塑造了 HotSpot VM 中那个精妙绝伦的自适应锁策略。读完本文,你将对并发性能的底层脉络有更深刻的洞察。
现象与问题背景
在早期的 Java 版本(如 JDK 1.4 之前),synchronized 是一个性能口碑不佳的关键字。在那个年代,每一次对 synchronized 方法或代码块的调用,都会无可避免地陷入操作系统层面的互斥量(Mutex)操作,这意味着昂贵的用户态/内核态切换。在高并发场景下,这种无差别的重量级锁定,使得 Java 程序的伸缩性(Scalability)大受限制,甚至一度让资深开发者倾向于在代码中手动管理锁,或者寻求其他并发解决方案。
然而,从 JDK 1.6 开始,HotSpot 虚拟机团队对 synchronized 进行了翻天覆地的优化,引入了“锁升级”的概念,包括偏向锁(Biased Locking)和轻量级锁(Lightweight Locking)。这使得 synchronized 在绝大多数场景下的性能表现与显式的 java.util.concurrent.locks.ReentrantLock 不相上下,甚至在某些情况下更优。问题来了:这种性能飞跃的背后,究竟隐藏了怎样的技术变革?虚拟机是如何做到“智能地”选择不同类型的锁,以适应从无竞争到激烈竞争的各种并发场景的?这正是我们要剖析的核心问题——一个看似简单的关键字背后,隐藏着一个从用户空间到硬件指令,再到操作系统内核的复杂协作与权衡体系。
关键原理拆解
在我们深入 JVM 的实现之前,必须先回到计算机科学的基础原理。锁的实现在本质上是对几个核心问题的权衡与妥协。作为一名架构师,理解这些原理是做出正确技术决策的基石。
- 用户态(User Mode)与内核态(Kernel Mode)的切换成本
这是理解重量级锁性能瓶颈的关键。操作系统为了保护系统核心资源,将内存空间和 CPU 指令集划分为用户态和内核态。应用程序运行在用户态,而当它需要访问核心资源时(如文件I/O、网络、线程调度),必须通过系统调用(System Call)陷入内核态。这个过程远非一次简单的函数调用,它涉及到:- 保存当前用户态的 CPU 上下文(寄存器、程序计数器等)。
- 切换到内核态的栈和执行上下文。
- 执行内核代码。
- 恢复用户态上下文,返回用户空间。
这一系列操作的开销通常在微秒(μs)级别,在高并发下,成千上万次的切换会累积成巨大的性能损耗。传统的、完全依赖操作系统 Mutex 的锁,其线程阻塞和唤醒操作就必须经过这个流程。
- 原子操作与 CAS(Compare-And-Swap)
现代多核 CPU 提供了一类特殊的硬件指令,能够以原子方式完成“读-改-写”操作,CAS 是其中最著名的一种。其逻辑是:CAS(V, E, N),其中 V 是内存地址,E 是预期旧值,N 是新值。当且仅当 V 处的值等于 E 时,CPU 才会原子地将 V 处的值更新为 N,并返回成功;否则什么也不做,返回失败。在用户态,我们可以利用 CAS 来实现无锁(Lock-Free)的数据结构或实现轻量级的同步机制,因为它避免了系统调用,速度极快,开销仅限于一次总线锁定或缓存一致性协议的开销。Java 中的sun.misc.Unsafe类和java.util.concurrent.atomic包下的类,就是对 CAS 指令的封装。 - CPU 缓存一致性协议(MESI)
在多核处理器中,每个核心都有自己的高速缓存(L1, L2 Cache)。当多个核心同时操作主存中的同一份数据时,必须有一种机制来保证数据的一致性。MESI 协议就是其中一种主流实现。当一个核心通过 CAS 修改一个共享变量时,它需要向其他核心广播,使它们持有的该变量的缓存行(Cache Line)失效。这个过程虽然比系统调用快得多,但同样存在开销,尤其是在高争用(High Contention)下,缓存行在多个核心之间频繁失效和同步,这种现象被称为“缓存行伪共享”(False Sharing)或“缓存乒乓”(Cache Ping-Pong),会严重影响性能。 - 自旋(Spinning)与阻塞(Blocking)
当一个线程尝试获取一个已被占用的锁时,它有两个选择:- 自旋:执行一个忙等待(Busy-Waiting)循环,不断地尝试获取锁,期间不放弃 CPU 时间片。优点是避免了线程上下文切换的开销,如果锁很快被释放,自旋是最高效的。缺点是如果锁被长时间占用,自旋会白白浪费 CPU 资源。
- 阻塞:放弃 CPU,进入睡眠状态,等待锁被释放后由操作系统唤醒。优点是不消耗 CPU。缺点是线程的挂起和唤醒涉及两次昂贵的上下文切换和系统调用。
轻量级锁的核心思想就是利用自旋来应对短时间的锁争用,而重量级锁则是在长时间争用下选择阻塞。
Java 的锁升级策略,本质上就是基于对应用运行行为的经验假设和动态观察,在这几个基础原理之间做出的一系列精妙的权衡。
JVM 的实现核心:对象头与 Mark Word
现在,让我们切换到极客工程师的视角。要理解锁升级,就必须深入到每一个 Java 对象在内存中的布局。一个 Java 对象在 HotSpot VM 中,由对象头(Object Header)和实例数据(Instance Data)组成。对象头又包含两部分:Mark Word 和类型指针(Klass Pointer)。锁的信息,正是存储在 Mark Word 这个小小的空间里。
在 64 位虚拟机中,Mark Word 占用 8 字节(64 位)。它的比特位布局会根据对象的锁状态和 GC 状态动态变化,就像一个微型的状态机。我们重点关注与锁相关的状态:
|-----------------------------------------------------------------------------------------------------------------|
| Mark Word (64 bits) |
|-----------------------------------------------------------------------------------------------------------------|
| unused:25 | identity_hashcode:31 | unused:1 | age:4 | biased_lock:1 | lock:2 |
|-----------------------------------------------------------------------------------------------------------------|
Normal (Unlocked) State - 锁标志位: 01, 是否偏向: 0
|-----------------------------------------------------------------------------------------------------------------|
| thread_id:54 | epoch:2 | unused:1 | age:4 | biased_lock:1 | lock:2 |
|-----------------------------------------------------------------------------------------------------------------|
Biased Lock State - 锁标志位: 01, 是否偏向: 1
|-----------------------------------------------------------------------------------------------------------------|
| ptr_to_lock_record:62 | lock:2 |
|-----------------------------------------------------------------------------------------------------------------|
Lightweight Lock State - 锁标志位: 00
|-----------------------------------------------------------------------------------------------------------------|
| ptr_to_heavyweight_monitor:62 | lock:2 |
|-----------------------------------------------------------------------------------------------------------------|
Heavyweight Lock State - 锁标志位: 10
|-----------------------------------------------------------------------------------------------------------------|
| | lock:2 |
|-----------------------------------------------------------------------------------------------------------------|
Marked for GC - 锁标志位: 11
请注意最后 3 个比特位,`lock:2` 和 `biased_lock:1` 共同决定了当前锁的状态。锁升级的过程,就是 Mark Word 中这些比特位根据竞争情况被修改的过程。
锁升级的完整路径与实现细节
让我们沿着线程竞争的激烈程度,一步步追踪锁的升级路径。
第一站:无锁到偏向锁(Biased Locking)
场景假设:在大多数情况下,一个锁在它的生命周期内,只被同一个线程反复获取和释放。例如,一个 Vector 或 Hashtable 对象可能被某个特定线程创建并频繁操作,而其他线程很少访问它。
实现机制:
- 当一个线程首次访问一个
synchronized代码块,它会检查对象 Mark Word 的 `biased_lock` 位。如果是 1,表示处于可偏向状态。 - 线程会尝试使用 CAS 操作,将自己的线程 ID 写入 Mark Word 的高位。如果成功,它就获得了这个对象的偏向锁。
- 此后,只要这个线程再次进入该对象的
synchronized代码块,它只需要检查 Mark Word 中存储的线程 ID 是否是自己。如果是,它无需任何同步操作,直接执行代码,开销近乎为零。这是一种极致的优化。
升级的触发点(偏向锁的撤销):
当另一个线程(我们称之为线程 B)尝试获取这个已被线程 A 偏向的锁时,偏向模式就宣告结束。这个过程称为偏向锁撤销(Biased Lock Revocation)。这是一个重量级操作,需要等待一个全局安全点(Safepoint),暂停持有偏向锁的线程 A,然后检查线程 A 的状态:
- 如果线程 A 已经退出了同步块,那么很简单,直接将 Mark Word 恢复为无锁状态或设置为偏向于线程 B。
- 如果线程 A 仍在同步块内,情况就复杂了。此时,锁需要升级。JVM 会将 Mark Word 指向一个为轻量级锁准备的栈上结构(Lock Record),然后恢复线程 A 执行。这个过程就完成了从偏向锁到轻量级锁的膨胀。
极客坑点:偏向锁的撤销成本很高。如果在你的应用场景中,对象总是被多个线程交替访问,那么偏向锁不但不会带来好处,反而会因为频繁的撤销操作(需要进入 Safepoint)导致性能下降。这就是为什么在 JDK 15 及以后版本中,偏向锁被默认关闭的原因。对于锁竞争激烈、线程角色频繁切换的场景(如典型的 Web 应用线程池处理请求),关闭偏向锁(-XX:-UseBiasedLocking)往往是明智之举。
第二站:轻量级锁(Lightweight Locking)
场景假设:存在锁竞争,但竞争通常是交替的、短暂的。即线程 A 刚释放锁,线程 B 马上就来获取,并且线程 A 不会持有锁太长时间。
实现机制:
- 当偏向锁被撤销,或两个线程同时竞争一个无锁对象时,系统进入轻量级锁模式。
- 尝试获取锁的线程会在自己的线程栈帧中创建一个名为锁记录(Lock Record)的空间,用于拷贝和存储对象 Mark Word 的当前值(Displaced Mark Word)。
- 然后,该线程使用 CAS 操作,尝试将对象的 Mark Word 更新为指向这个 Lock Record 的指针。同时,Mark Word 的锁标志位变为 `00`。
- 如果 CAS 成功,该线程就获得了锁,可以执行同步代码。
- 如果 CAS 失败,说明锁已被其他线程持有。
自旋与膨胀:
当 CAS 失败后,线程并不会立刻阻塞。它会进入一个自旋(Spinning)循环,在循环中不断重试 CAS 操作。JVM 认为,既然竞争是短暂的,那么持有锁的线程可能很快就会释放锁,自旋等待比陷入内核态阻塞要划算得多。
HotSpot VM 采用了自适应自旋(Adaptive Spinning)。一个线程的自旋次数不是固定的,而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定。如果对于某个锁,自旋等待刚刚成功地获得过,那么 JVM 就会认为这次自旋也很有可能再次成功,进而它将允许自旋等待持续相对更长的时间。反之,如果自旋很少成功,那么后续的自旋请求将被跳过,直接膨胀为重量级锁,以避免浪费 CPU。
当自旋超过一定次数(或自适应判断后),锁就会膨胀(Inflate)为重量级锁。
// 伪代码:轻量级锁获取过程
void lightweightLock(Object obj) {
// 1. 在当前线程栈帧创建 Lock Record
LockRecord lr = new LockRecord();
lr.setDisplacedHeader(obj.getMarkWord());
// 2. CAS 尝试将 Mark Word 指向 Lock Record
if (CAS(obj.markWord, null, address_of(lr))) {
// 成功获取锁
return;
} else {
// CAS 失败,进入自旋或膨胀
handleContention(obj, lr);
}
}
终点站:重量级锁(Heavyweight Locking)
场景假设:锁竞争非常激烈,多个线程同时等待同一个锁,或者线程持有锁的时间非常长,自旋已经不划算。
实现机制:
- 当轻量级锁膨胀时,Mark Word 的内容会被修改。锁标志位变为 `10`,并且指针指向一个重量级的监视器对象——ObjectMonitor。
ObjectMonitor是一个 C++ 对象,它内部封装了操作系统的互斥量(如 Linux 下的 `pthread_mutex`)。它还维护了几个关键的线程集合:_owner: 指向当前持有锁的线程。_EntryList: 存储所有等待获取锁的、处于阻塞状态的线程。_WaitSet: 存储调用了object.wait()方法后,等待被唤醒的线程。
- 此时,所有后续尝试获取该锁的线程,如果发现锁已被占用,将不再自旋,而是直接将自己加入到
_EntryList中,并调用操作系统的同步原语(如 `park`)将自己挂起,进入阻塞状态,等待被唤醒。 - 当持有锁的线程释放锁时(退出
synchronized块),它会唤醒_EntryList中的一个或多个等待线程,被唤醒的线程将参与新一轮的锁竞争。
一旦锁升级为重量级锁,它通常不会再降级。即使之后没有竞争,它也会保持重量级锁的状态。这种设计的考虑是,既然已经出现了激烈竞争,那么未来再次出现激烈竞争的概率也很大,频繁地升级和降级本身也是一种开销。
对抗层:性能权衡与架构决策
理解了锁升级的机制后,我们作为架构师和开发者,能得到什么启示?
- 锁的粒度要尽可能小:
synchronized包裹的代码块越小,线程持有锁的时间就越短,锁竞争的概率就越低,锁停留在轻量级状态的可能性就越大,从而获得更好的性能。这是微观层面的“康威定律”——代码结构影响运行时性能。 - 识别你的并发模型:你的系统是典型的“读多写少”还是“写多读多”?线程之间的交互是短暂的还是需要长时间持有锁?例如,在一个高性能的交易撮合引擎中,订单簿的锁可能竞争极其激烈,那么可能一开始就使用重量级锁,或者采用更高级的无锁数据结构,会比让 JVM 反复进行锁升级的尝试更高效。而在一个普通的 Web 应用中,大多数共享对象可能只会被单个请求线程处理,偏向锁和轻量级锁的优化就显得尤为重要。
- JVM 参数调优的依据:当你考虑是否要调整
-XX:+UseBiasedLocking,-XX:BiasedLockingStartupDelay, 或自旋相关的参数时,你不再是盲目尝试,而是基于对应用并发模式的理解和锁升级原理的洞察。例如,如果应用启动初期就有大量并发,可以考虑将偏向锁的启动延迟设置为 0。 - 选择 `synchronized` 还是 `ReentrantLock`:在 JDK 1.6 之后,二者性能已经相差无几。
synchronized的优势在于语法简洁,且由 JVM 自动管理锁的释放,不易出错。ReentrantLock则提供了更丰富的功能,如可中断的等待、公平锁、尝试获取锁(tryLock)以及可以绑定多个 Condition。你的选择应该基于功能需求,而非微观的性能臆测。对于复杂的多线程协作场景,ReentrantLock依然是首选。
架构演进与落地路径
在一个系统的演进过程中,对并发控制的策略也应是分阶段的。
- 初期阶段(简单与正确优先):在项目初期,业务逻辑的快速实现和正确性是第一位的。此时,大胆地使用
synchronized关键字。JVM 的锁升级机制已经为你处理了 80% 的性能问题。相信 JVM,不要过早优化。 - 性能瓶颈分析阶段:当系统上线后,通过监控和性能剖析(Profiling),你会发现真正的瓶颈所在。如果 JProfiler 或 Arthas 显示大量的线程在某个锁上 BLOCKED,这说明该锁已经升级为重量级锁,并且竞争非常激烈。
- 针对性优化阶段:
- 降低锁粒度:分析该锁保护的代码块,是否可以将其拆分?能否将一个大对象锁拆分为多个小对象的锁?(例如,使用
ConcurrentHashMap代替 `Collections.synchronizedMap(new HashMap<>())`)。 - 减少锁持有时间:是否可以将一些耗时操作(如 I/O)移出同步代码块?
- 改变锁的实现:如果
synchronized确实成为了瓶颈,并且你需要更高级的功能,这时可以考虑用ReentrantLock,ReadWriteLock甚至是无锁的原子类(Atomics)和 VarHandle 来重构这部分代码。 - 架构重构:在极端情况下,如果单一锁点的竞争无法避免(如秒杀系统的库存扣减),可能需要从架构层面进行重构,比如采用分片(Sharding)将热点数据分散,或者引入消息队列进行异步削峰,将同步竞争转化为异步处理。
- 降低锁粒度:分析该锁保护的代码块,是否可以将其拆分?能否将一个大对象锁拆分为多个小对象的锁?(例如,使用
总而言之,Java 的 synchronized 锁升级机制是一个伟大的工程杰作。它体现了计算机科学中一个普适的设计哲学:根据实际情况做出自适应的优化(Profile-Guided Optimization)。它从一个乐观的假设(无竞争)开始,随着竞争的加剧,逐步增加同步的“重量”,最终退守到最保守但最可靠的操作系统互斥量。作为开发者,深刻理解这一过程,能帮助我们写出更高效、更健壮的并发程序,并在面对性能挑战时,做出直击要害的架构决策。
延伸阅读与相关资源
-
想系统性规划股票、期货、外汇或数字币等多资产的交易系统建设,可以参考我们的
交易系统整体解决方案。 -
如果你正在评估撮合引擎、风控系统、清结算、账户体系等模块的落地方式,可以浏览
产品与服务
中关于交易系统搭建与定制开发的介绍。 -
需要针对现有架构做评估、重构或从零规划,可以通过
联系我们
和架构顾问沟通细节,获取定制化的技术方案建议。