jvm 锁升级机制
Java 虚拟机(JVM)中的锁升级机制(也称为锁膨胀)是 HotSpot 虚拟机为了优化 synchronized
关键字的性能而引入的一项重要技术。它的核心思想是:根据实际遇到的竞争激烈程度,动态地将锁从开销最小的状态逐步升级到开销更大的状态,从而在无竞争或低竞争时减少锁操作的开销,而在高竞争时保证必要的互斥性和线程调度能力。
锁的状态主要有四种,升级路径如下:
无锁 -> 偏向锁 -> 轻量级锁 -> 重量级锁
锁只能升级(膨胀),不能降级(虽然理论上重量级锁在竞争消失后可以降级,但HotSpot 实现中为了简化,很少进行降级,尤其是从重量级锁降级)。
1. 无锁状态
- 初始状态: 当一个对象刚被创建出来,且没有任何线程尝试获取它的锁时,它就处于无锁状态。
- 特点: 没有锁的开销。
- 适用场景: 对象从未被同步访问或只被单个线程访问(无需同步)。
2. 偏向锁
- 设计目标: 优化同一个线程重复进入同步块的场景(无实际竞争)。消除在无竞争情况下的同步原语开销(如 CAS)。
- 工作原理:
- 当第一个线程(T1)访问同步块时,JVM 会检查对象头中的 Mark Word。
- 如果当前是无锁状态,JVM 使用 CAS 操作尝试将 Mark Word 中的线程 ID 设置为 T1 的 ID,并将锁标志位设置为偏向模式。
- 如果 CAS 成功,T1 就持有了该对象的偏向锁。后续只要 T1 进入这个同步块,无需再进行任何同步操作(如 CAS 或锁申请),只需简单检查对象头中的线程 ID 是否还是自己。
- 升级触发:
- 竞争出现: 当另一个线程(T2)尝试获取这个已经被偏向于 T1 的锁时,偏向锁就会失效。
- JVM 会撤销偏向锁。撤销过程需要等待持有偏向锁的线程(T1)到达全局安全点(Safepoint),暂停 T1。
- 检查 T1 的状态:
- 如果 T1 已经退出同步块(不再持有锁),则将对象头设置为无锁状态(或者根据情况尝试重新偏向给 T2)。
- 如果 T1 仍在同步块中,则将锁升级为轻量级锁。JVM 会在 T1 的栈帧中创建一个锁记录(Lock Record),并将对象头的 Mark Word 复制到该锁记录中(称为 Displaced Mark Word),然后用 CAS 操作将对象头指向 T1 栈帧中的锁记录地址(轻量级锁状态)。
- 特点: 适用于只有一个线程反复访问同步块的场景。加锁解锁几乎无额外开销。撤销偏向锁有代价(需要暂停线程)。
- 关闭偏向锁: 由于偏向锁在存在竞争时撤销有开销,且现代应用中共享数据竞争往往更常见,从 JDK 15 开始,偏向锁默认被禁用(可通过
-XX:+UseBiasedLocking
开启,但已不推荐)。
3. 轻量级锁
- 设计目标: 优化多个线程交替执行同步块,但未发生真正并发竞争的场景(低竞争)。避免直接使用重量级锁带来的操作系统内核态切换的开销。
- 工作原理:
- 当线程尝试获取轻量级锁时(可能是从无锁升级而来,也可能是从偏向锁撤销升级而来),JVM 会在当前线程的栈帧中创建一个锁记录空间。
- 将对象头的 Mark Word 复制到该锁记录中(称为 Displaced Mark Word)。
- 然后线程尝试使用 CAS 操作将对象头中的 Mark Word 替换为指向该锁记录的指针。
- 如果 CAS 成功,当前线程获得轻量级锁。锁标志位变为
00
。 - 如果 CAS 失败(说明对象头已被其他线程修改,即发生了竞争),当前线程会自旋(循环尝试 CAS)一小段时间(自适应自旋)。
- 如果在自旋期间成功获取到锁,则继续执行。
- 如果自旋结束仍未成功,或者自旋过程中竞争加剧(如又有新线程加入竞争),则锁升级为重量级锁。
- 如果 CAS 成功,当前线程获得轻量级锁。锁标志位变为
- 解锁过程: 使用 CAS 操作将 Displaced Mark Word 替换回对象头。
- 如果成功,解锁完成。
- 如果失败(说明锁已经膨胀为重量级锁),则在释放锁的同时唤醒等待线程。
- 特点: 使用 CAS 和自旋代替互斥量,避免了用户态到内核态的切换,适用于线程阻塞时间非常短的场景(“忙等”)。自旋会消耗 CPU。如果锁持有时间较长或竞争激烈,自旋会浪费 CPU,性能反而下降。
- 升级触发: CAS 失败且自旋获取锁失败(或自适应策略判断竞争激烈)、调用
wait()
方法(因为wait()
需要重量级锁的监视器模型支持)。
4. 重量级锁
- 设计目标: 处理高并发、激烈竞争的场景。保证在任意时刻只有一个线程能进入同步块。
- 工作原理:
- 当锁升级到重量级锁时,对象头中的 Mark Word 会指向一个与对象关联的监视器锁(Monitor,也称为管程或互斥锁),这个结构通常存在于堆中。
- 该 Monitor 内部维护了一个入口队列(Entry Set) 和一个等待队列(Wait Set)。
- 当一个线程尝试获取重量级锁时:
- 如果锁可用(未被持有),则获取成功,成为锁的持有者。
- 如果锁已被其他线程持有,则当前线程会被阻塞(Park),并被操作系统挂起,放入入口队列等待唤醒。这涉及到用户态到内核态的切换(线程上下文切换),开销最大。
- 当持有锁的线程释放锁时,它会唤醒入口队列中的某个或所有等待线程(具体策略取决于实现,如公平/非公平),被唤醒的线程会重新尝试获取锁。
- 特点: 真正的互斥锁。阻塞线程,不消耗 CPU 空转。适用于竞争激烈或临界区执行时间较长的场景。线程阻塞、唤醒、上下文切换开销很大。
- 升级触发: 轻量级锁自旋失败、调用
Object.wait()
方法(强制升级)。
总结锁升级机制
锁状态 | 目标场景 | 核心机制 | 优点 | 缺点 | 升级触发条件 |
---|---|---|---|---|---|
无锁 | 无同步访问 | - | 无开销 | 无法提供线程安全 | 首次线程访问 |
偏向锁 | 单线程重复访问 | CAS 设置 Thread ID | 同一线程后续进入无开销 | 竞争时撤销开销大(需暂停线程) | 第二个线程尝试获取锁 |
轻量级锁 | 低竞争(交替执行) | CAS + 自旋 (栈锁记录) | 避免内核切换,开销小 | 自旋消耗 CPU,长时间竞争性能下降 | CAS失败且自旋失败 / 调用 wait() |
重量级锁 | 高竞争 | 操作系统 Monitor | 阻塞线程,不消耗 CPU 空转 | 阻塞/唤醒开销大(内核切换) | 轻量级锁升级失败 / 显式调用 wait() |
关键点
- 自适应自旋: 轻量级锁中的自旋次数不是固定的,JVM 会根据之前在该锁上的自旋成功情况以及持有者的状态,动态调整自旋时间(适应性自旋)。
- 锁消除: JIT 编译器在运行时,通过逃逸分析如果发现某个锁对象不可能被其他线程访问到(即不会发生竞争),它会将这个锁操作完全消除掉。
- 锁粗化: 如果 JVM 检测到有一连串连续的操作都对同一个对象反复加锁和解锁(即使是在循环中),它可能会将加锁的范围扩大(粗化)到整个操作序列的外部,从而减少不必要的锁申请/释放次数。
- 偏向锁的争议与默认关闭: 在现代多核、高并发环境下,共享数据的竞争是常态,偏向锁在首次获得和撤销时的开销,以及它对应用程序启动性能的影响(大量类初始化时偏向锁操作)变得不可忽视。自 JDK 15 起,偏向锁默认被禁用,轻量级锁成为更常见的起点。
-XX:-UseBiasedLocking
可以显式关闭它(在 JDK 15+ 已经是默认行为)。 hashCode()
的影响: 当调用一个未被覆盖的Object.hashCode()
或System.identityHashCode()
时,如果对象处于偏向锁或轻量级锁状态,会导致锁撤销或升级,因为无锁状态下的 Mark Word 需要存储哈希码。
理解锁升级的意义
锁升级机制体现了 JVM 在性能与正确性之间所做的精妙平衡:
- 无竞争/低竞争: 最大程度减少开销(偏向锁、轻量级锁)。
- 高竞争: 保证正确性和线程调度的公平性/效率,接受较大的开销(重量级锁)。
了解锁升级机制对于编写高效、正确的并发程序至关重要。它解释了为什么简单的 synchronized
在低竞争场景下性能可以非常好,而在高竞争场景下性能会显著下降。在需要极高并发性能的场景下,开发者可能会选择更灵活、可优化的显式锁(如 ReentrantLock
),但 synchronized
结合锁升级在大多数场景下已经是非常高效且简洁的选择。