偏向锁曾是 HotSpot 虚拟机引以为傲的一项轻量级锁优化,其核心假设是:一把锁在大多数生命周期里只会被同一个线程持有。在这样的前提下,JVM 只需在对象头里记录线程 ID,后续该线程进入同步块时无需任何原子操作即可通过判断。然而这项优化有一个容易被忽视的代价,当偏向关系需要被打破时,撤销操作必须借助 Safepoint 完成,也就是说,一次小小的锁撤销可能要求虚拟机暂停所有正在运行的线程,让它们全部到达安全点之后再继续。理解这个机制,对排查高并发系统中的偶发性停顿问题非常关键。

偏向锁的底层结构与撤销的由来
在 HotSpot 的对象内存布局中,对象头(Mark Word)承担了记录锁状态的任务。在 64 位 JVM 上,处于偏向模式的对象头会包含偏向线程的 ID、一个 epoch 值以及锁标志位。当持有偏向的线程再次进入同步块时,只需比对对象头中的线程 ID 是否等于自己,匹配则直接通过,整个过程没有任何 CAS 操作,这就是偏向锁快的原因。
问题出现在出现竞争的时刻。假设线程 A 持有偏向,而线程 B 尝试获取同一把锁,JVM 发现对象头的偏向线程不是 B,就必须执行偏向撤销,把对象头的锁状态退回为无锁或轻量级锁。撤销并不是简单地改几个比特位,因为偏向线程 A 可能正在同步块内执行,JVM 需要遍历 A 的栈帧,找到关联该锁的锁记录,判断锁是否仍被持有,再决定升级为轻量级锁还是恢复为无锁状态。遍历线程栈这种操作可能改变栈上的数据结构,只有在所有线程都停在稳定状态时才是安全的,这正是撤销必须依赖 Safepoint 的根本原因。
// 触发偏向撤销的典型场景
public class BiasRevokeDemo {
static final Object lock = new Object();
public static void main(String[] args) throws Exception {
synchronized (lock) {
// 主线程第一次获取,lock 被偏向给主线程
System.out.println("biased to main thread");
}
Thread t = new Thread(() -> {
// 另一个线程尝试获取,触发偏向撤销
// 撤销需要 VM_Thread 执行,等待 Safepoint
synchronized (lock) {
System.out.println("acquired by worker");
}
});
t.start();
t.join();
}
}上述代码中,工作线程进入同步块的那一刻,JVM 会向全局提交一个撤销请求。这个请求由 VM Operation 机制处理,本质上是把一个任务塞进虚拟机的操作队列,然后等待所有线程到达安全点后统一执行。
Safepoint 的全局同步机制与停顿成本
Safepoint 是 JVM 中一个全局的同步点,处于该点的线程状态对虚拟机而言是完全已知且可安全操作的。触发 Safepoint 时,虚拟机会设置一个轮询标志,各个线程在解释执行的字节码边界、JIT 编译代码中的回边和返回点、以及执行本地方法返回时检查这个标志,一旦发现需要进入安全点,就会主动挂起自己。当最后一个线程也到达安全点后,VM Operation 才开始执行。
关键问题在于:Safepoint 的耗时取决于最慢到达的那个线程。如果某个线程正在执行一个没有安全点检查的长循环,或者正在执行一段耗时的本地代码,其他所有线程即使早已就绪也必须等待它。对于偏向锁撤销而言,撤销本身的逻辑很快,但从提交请求到所有线程就绪这段时间,整个应用是全局停顿的。在低延迟交易系统或高频调用路径上,这种停顿可能表现为微秒到毫秒级的尾延迟抖动,且常规的锁竞争监控手段难以直接观测到。
更麻烦的是撤销存在放大效应。JVM 定义了两个阈值来应对频繁撤销:当撤销次数超过 BiasedLockingBulkRebiasThreshold(默认 20)时触发批量重偏向,通过递增类信息中的 epoch 值让旧偏向批量失效;当撤销次数继续超过 BiasedLockingBulkRevokeThreshold(默认 40)时,整个类的新对象都不再允许偏向。即便如此,批量重偏向和批量撤销本身也是 VM Operation,同样要经过 Safepoint,所以在锁竞争激烈的场景下,偏向锁反而成了额外的负担来源。
如何观察与验证偏向撤销带来的停顿
要确认系统中是否存在偏向撤销引发的 Safepoint 停顿,可以打开相关 JVM 日志。以 JDK 8 为例,参数 -XX:+PrintSafepointStatistics 配合 -XX:+PrintGCApplicationStoppedTime 能输出每次 Safepoint 的类型和耗时,其中操作类型为 RevokeBias 或 BulkRevokeBias 的记录就是偏向撤销的直接证据。
# JDK 8 观察偏向撤销相关的 Safepoint 统计
java -XX:+UseBiasedLocking -XX:BiasedLockingStartupDelay=0 \
-XX:+PrintSafepointStatistics -XX:PrintSafepointStatisticsCount=1 \
-XX:+PrintGCApplicationStoppedTime \
-jar app.jar
# 输出示例中关注这些字段:
# RevokeBias - 普通偏向撤销
# BulkRevokeBias - 批量撤销
# total_time_ns 表示该次安全点操作的同步与执行耗时另外,偏向锁默认有 4 秒的启动延迟(BiasedLockingStartupDelay=4000),这是为了避免应用启动阶段大量锁尚未稳定时就发生撤销。若日志中 RevokeBias 频繁出现且 sync 时间偏高,基本可以判定偏向锁在当前负载下得不偿失。
实践建议:什么情况下应该关闭偏向锁
当应用呈现明确的多线程竞争模式,例如线程池中的任务共享缓存对象、消息消费者并发竞争同一把锁时,偏向锁几乎一定会经历撤销、重偏向再到轻量级锁升级的完整流程,每次撤销都附带 Safepoint 成本。这种场景下可以显式加上 -XX:-UseBiasedLocking 直接关闭偏向优化,让锁直接走轻量级到重量级的路径。
值得注意的是,JDK 15 之后偏向锁已被默认废弃,并在后续版本中彻底移除,官方给出的理由正是撤销操作需要 Safepoint、维护成本高且在现代无竞争同步已经足够快的背景下收益有限。如果你的系统仍运行在 JDK 8 或 11 上,又对尾延迟敏感,关闭偏向锁往往能消除一类隐蔽的停顿来源。如果锁确实长期单线程持有,例如某些懒加载初始化路径,保留偏向锁仍然有价值,需要结合实际压测数据和 Safepoint 日志综合权衡,而不是盲目跟风开关参数。