Skip to content

偏向锁:获取、撤销与批量重偏向

假设这样一个场景:

你的系统里有一个单例的缓存对象,整个应用只用一个线程访问它。

用 synchronized 保护它没问题,但每次进入同步块都要 CAS,未免有点浪费——明明只有一个线程在用。

偏向锁就是为了解决这个问题的:让这个线程「独占」这把锁,以后再也不用检查了。


偏向锁的核心思想

第一次获取锁时,用 CAS 记录线程ID;之后每次进入,只需比较线程ID。

┌─────────────────────────────────────────────────────────────────┐
│                      偏向锁工作流程                               │
├─────────────────────────────────────────────────────────────────┤
│                                                                 │
│  线程A 第一次进入 ────▶ CAS 记录线程ID ────▶ 偏向锁获取成功         │
│                                              │                   │
│                                              ▼                   │
│                                    后续进入只需比较线程ID           │
│                                    (无需任何同步操作)              │
│                                                                 │
└─────────────────────────────────────────────────────────────────┘

偏向锁的获取过程

第一步:检查是否可以偏向

JVM 启动后,默认会延迟一段时间再开启偏向锁(BiasedLockingStartupDelay=4000ms)。

为什么延迟?因为 JVM 启动早期会有大量线程创建和销毁,开启偏向锁反而会增加开销。

java
// 伪代码演示偏向锁获取
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   │          │
└─────────────┴──────────┴──────────┴───────┴─────────┴──────────┘

第三步:后续进入同步块

java
synchronized (obj) {  // 线程A 再次进入
    // ...
}

这次进入只需要检查 Mark Word 中的线程 ID 是否是自己的:

java
if (obj.markWord.threadId == currentThread.id) {
    // 偏向命中,跳过同步代码
    enterSyncBlock();
} else {
    // 偏向失效,需要重新获取锁
    revokeAndRebiased();
}

偏向锁的撤销

什么时候会撤销偏向锁?

触发条件原因
其他线程竞争线程 B 来访问同一个同步块
调用 hashCode()对象需要存储 hashCode,偏向空间不够
调用 wait/notify只有重量级锁支持 wait
偏向超时JDK 15+ 偏向锁默认关闭,不存在此问题

场景一:线程竞争导致撤销

时间线:
T1: 线程A 获取偏向锁,进入同步块
T2: 线程B 尝试进入同一个同步块

线程 B 发现对象处于偏向锁状态,但偏向的是线程 A。JVM 会尝试:

  1. 安全点撤销:在安全点(Safe Point)撤销偏向锁
  2. 升级为轻量级锁:让线程 B 自旋等待
java
// 偏向锁撤销的简化逻辑
public void revokeBias() {
    // 线程A 正在同步块中?
    if (isBIASABLE && isBiased()) {
        // 偏向的线程是否正在执行?
        if (currentThread == biasedThread) {
            // 安全点:升级为轻量级锁
            upgradeToLightweightLock();
        } else {
            // 撤销偏向,尝试偏向新的线程
            biasTo(currentThread);
        }
    }
}

场景二:调用 hashCode() 导致撤销

java
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 的偏向撤销次数:

条件触发阈值(默认)动作
偏向撤销次数达到 20JVM 认为这些对象应该偏向另一个线程,批量重偏向
偏向撤销次数达到 40JVM 认为这个 Class 不适合偏向,禁用偏向锁

批量重偏向的过程

Class User 的对象撤销偏向次数达到 20


┌─────────────────────────────────────────┐
│  JVM 在安全点遍历所有 User 对象           │
│  批量修改它们的 epoch 值                  │
└─────────────────────────────────────────┘


┌─────────────────────────────────────────┐
│  epoch = biasedLockingStartupDelay + 1  │
│  相当于告诉这些对象:"你们的偏向过期了"    │
└─────────────────────────────────────────┘


    线程B 访问时,直接偏向线程B,无需撤销

代码演示

java
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,不匹配就重新偏向

面试追问方向

  1. 批量重偏向和批量撤销的阈值分别是多少?可以调整吗? 默认 20 和 40,可以通过 -XX:BiasedLockingBulkRebiasThreshold-XX:BiasedLockingBulkRevokeThreshold 调整。

  2. 为什么 JDK 15 要废弃偏向锁? 在高并发场景下,偏向锁的撤销开销可能大于收益,而且增加了 JVM 的复杂性。

  3. epoch 为什么能减少批量重偏向的开销? 不需要遍历和修改每个对象,只需修改 Class 对象的一个值。

  4. 如果一个对象正在被一个线程使用时,另一个线程能否撤销它的偏向锁? 可以在安全点撤销;如果线程正在同步块中,会升级为轻量级锁。


留给你的思考题

假设这样一个场景:

系统启动后:
T1: 主线程创建了 1000 个 Order 对象,并处理它们(单线程)
T2: 主线程处理完后,启动 10 个工作线程并发处理这 1000 个对象

问题

  1. 这 1000 个 Order 对象的偏向锁是什么状态?(假设系统启动延迟已过)
  2. 工作线程并发访问时,会发生什么?偏向锁会被大量撤销吗?
  3. 如果要优化这个场景,有什么建议?

提示:考虑使用 -XX:BiasedLockingBulkRebiasThreshold=1000 或直接禁用偏向锁。

基于 VitePress 构建