偏向锁:获取、撤销与批量重偏向
假设这样一个场景:
你的系统里有一个单例的缓存对象,整个应用只用一个线程访问它。
用 synchronized 保护它没问题,但每次进入同步块都要 CAS,未免有点浪费——明明只有一个线程在用。
偏向锁就是为了解决这个问题的:让这个线程「独占」这把锁,以后再也不用检查了。
偏向锁的核心思想
第一次获取锁时,用 CAS 记录线程ID;之后每次进入,只需比较线程ID。
┌─────────────────────────────────────────────────────────────────┐
│ 偏向锁工作流程 │
├─────────────────────────────────────────────────────────────────┤
│ │
│ 线程A 第一次进入 ────▶ CAS 记录线程ID ────▶ 偏向锁获取成功 │
│ │ │
│ ▼ │
│ 后续进入只需比较线程ID │
│ (无需任何同步操作) │
│ │
└─────────────────────────────────────────────────────────────────┘偏向锁的获取过程
第一步:检查是否可以偏向
JVM 启动后,默认会延迟一段时间再开启偏向锁(BiasedLockingStartupDelay=4000ms)。
为什么延迟?因为 JVM 启动早期会有大量线程创建和销毁,开启偏向锁反而会增加开销。
// 伪代码演示偏向锁获取
public class BiasedLocking {
public void enterSynchronizedBlock(Object obj) {
// 检查偏向锁是否开启
if (UseBiasedLocking) {
// 第一次进入:尝试偏向
if (obj.markWord.biasedLock == 0) {
// CAS 将线程ID写入Mark Word
if (CAS(obj.markWord,
originalMarkWord,
markWordWithThreadId)) {
// 偏向成功,线程ID已记录
return;
}
}
}
}
}第二步:Mark Word 的变化
获取偏向锁前(无锁状态):
┌──────────┬─────────────────────────┬───────┬─────────┬──────────┐
│ 25 bit │ │ 4 bit │ 1 bit │ 2 bit │
│ 对象哈希码 │ 未使用 │ age │ 0 │ 01 │
└──────────┴─────────────────────────┴───────┴─────────┴──────────┘
获取偏向锁后:
┌─────────────┬──────────┬──────────┬───────┬─────────┬──────────┐
│ 23 bit │ 2 bit │ 4 bit │ 1 bit │ 1 bit │ 2 bit │
│ threadID │ epoch │ age │ 1 │ biased │ 01 │
│ 线程A的ID │ │ │ │ lock │ │
└─────────────┴──────────┴──────────┴───────┴─────────┴──────────┘第三步:后续进入同步块
synchronized (obj) { // 线程A 再次进入
// ...
}这次进入只需要检查 Mark Word 中的线程 ID 是否是自己的:
if (obj.markWord.threadId == currentThread.id) {
// 偏向命中,跳过同步代码
enterSyncBlock();
} else {
// 偏向失效,需要重新获取锁
revokeAndRebiased();
}偏向锁的撤销
什么时候会撤销偏向锁?
| 触发条件 | 原因 |
|---|---|
| 其他线程竞争 | 线程 B 来访问同一个同步块 |
| 调用 hashCode() | 对象需要存储 hashCode,偏向空间不够 |
| 调用 wait/notify | 只有重量级锁支持 wait |
| 偏向超时 | JDK 15+ 偏向锁默认关闭,不存在此问题 |
场景一:线程竞争导致撤销
时间线:
T1: 线程A 获取偏向锁,进入同步块
T2: 线程B 尝试进入同一个同步块线程 B 发现对象处于偏向锁状态,但偏向的是线程 A。JVM 会尝试:
- 安全点撤销:在安全点(Safe Point)撤销偏向锁
- 升级为轻量级锁:让线程 B 自旋等待
// 偏向锁撤销的简化逻辑
public void revokeBias() {
// 线程A 正在同步块中?
if (isBIASABLE && isBiased()) {
// 偏向的线程是否正在执行?
if (currentThread == biasedThread) {
// 安全点:升级为轻量级锁
upgradeToLightweightLock();
} else {
// 撤销偏向,尝试偏向新的线程
biasTo(currentThread);
}
}
}场景二:调用 hashCode() 导致撤销
public class HashCodeTest {
public static void main(String[] args) {
Object obj = new Object();
// 如果先调用 hashCode,偏向锁就没机会了
obj.hashCode(); // 计算hashCode,Mark Word被占用
synchronized (obj) {
// 此时进入的是轻量级锁,不是偏向锁
// 因为 hashCode 已经占用了偏向锁需要的空间
}
}
}原因:无锁状态需要 25 位存 hashCode,偏向锁需要 23 位存线程 ID——两个信息都需要空间,但 Mark Word 只有那么多位。
批量重偏向(Bulk Rebiasing)
什么是批量重偏向?
当同一个 Class 的对象大量偏向线程 A,但线程 A 之后不再使用这些对象时:
现象:
- 500 个 User 对象都偏向线程A
- 线程A 突然不再使用这些 User 对象
- 线程B 开始使用这些 User 对象
- 结果:每个对象都要撤销偏向,开销巨大!批量重偏向的机制
JVM 会统计每个 Class 的偏向撤销次数:
| 条件 | 触发阈值(默认) | 动作 |
|---|---|---|
| 偏向撤销次数 | 达到 20 | JVM 认为这些对象应该偏向另一个线程,批量重偏向 |
| 偏向撤销次数 | 达到 40 | JVM 认为这个 Class 不适合偏向,禁用偏向锁 |
批量重偏向的过程
Class User 的对象撤销偏向次数达到 20
│
▼
┌─────────────────────────────────────────┐
│ JVM 在安全点遍历所有 User 对象 │
│ 批量修改它们的 epoch 值 │
└─────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────┐
│ epoch = biasedLockingStartupDelay + 1 │
│ 相当于告诉这些对象:"你们的偏向过期了" │
└─────────────────────────────────────────┘
│
▼
线程B 访问时,直接偏向线程B,无需撤销代码演示
public class BulkRebiasDemo {
// 使用反射创建大量对象,触发批量重偏向
public static void main(String[] args) throws Exception {
Class<User> clazz = User.class;
// 线程A 创建 100 个对象,都偏向线程A
Thread threadA = new Thread(() -> {
List<User> users = new ArrayList<>();
for (int i = 0; i < 100; i++) {
users.add(new User());
}
// users 对象都偏向 threadA
}, "ThreadA");
threadA.start();
threadA.join();
// 线程B 访问这些对象,触发批量重偏向
Thread threadB = new Thread(() -> {
// 第一个对象会触发撤销(计数20)
// 第21个对象开始会触发批量重偏向
}, "ThreadB");
threadB.start();
}
}批量撤销(Bulk Revocation)
什么时候发生?
当一个 Class 的对象偏向锁被撤销次数达到 40 次时:
┌─────────────────────────────────────────┐
│ Class User 的偏向撤销次数 = 40 │
│ │ │
│ ▼ │
│ 该 Class 的所有对象禁用偏向锁 │
│ 以后创建的新对象都是无锁状态 │
└─────────────────────────────────────────┘原因
JVM 认为这个 Class 存在严重的锁竞争,继续使用偏向锁会带来大量撤销开销,不如直接不用。
epoch 机制详解
epoch 的作用
epoch 是偏向锁的「版本号」,用于支持批量重偏向。
┌─────────────────────────────────────────────────────────────────┐
│ epoch 工作原理 │
├─────────────────────────────────────────────────────────────────┤
│ │
│ 每个 Class 对象有一个 classEpoch(类 epoch) │
│ 每个实例对象有一个 epoch(实例 epoch) │
│ │
│ 偏向锁有效条件:实例 epoch == classEpoch │
│ │
│ 批量重偏向时:classEpoch++ │
│ 旧 epoch 的偏向锁被认为「过期」 │
│ │
└─────────────────────────────────────────────────────────────────┘为什么需要 epoch?
想象没有 epoch 的情况:
批量重偏向要修改什么?
- 每个对象都要改?开销太大!
- 只改 class?不够精确!
有 epoch 后:
- 只改 class 的 classEpoch
- 访问时检查 epoch,不匹配就重新偏向面试追问方向
批量重偏向和批量撤销的阈值分别是多少?可以调整吗? 默认 20 和 40,可以通过
-XX:BiasedLockingBulkRebiasThreshold和-XX:BiasedLockingBulkRevokeThreshold调整。为什么 JDK 15 要废弃偏向锁? 在高并发场景下,偏向锁的撤销开销可能大于收益,而且增加了 JVM 的复杂性。
epoch 为什么能减少批量重偏向的开销? 不需要遍历和修改每个对象,只需修改 Class 对象的一个值。
如果一个对象正在被一个线程使用时,另一个线程能否撤销它的偏向锁? 可以在安全点撤销;如果线程正在同步块中,会升级为轻量级锁。
留给你的思考题
假设这样一个场景:
系统启动后:
T1: 主线程创建了 1000 个 Order 对象,并处理它们(单线程)
T2: 主线程处理完后,启动 10 个工作线程并发处理这 1000 个对象问题:
- 这 1000 个 Order 对象的偏向锁是什么状态?(假设系统启动延迟已过)
- 工作线程并发访问时,会发生什么?偏向锁会被大量撤销吗?
- 如果要优化这个场景,有什么建议?
提示:考虑使用 -XX:BiasedLockingBulkRebiasThreshold=1000 或直接禁用偏向锁。
