Skip to content

轻量级锁:CAS + 自旋的高效锁机制

上一节讲了偏向锁——只有一个线程独享锁的时候,偏向锁是最快的。

但现实世界哪有那么多「独享」?多线程交替访问才是常态。

比如一个任务队列:

  • 线程 A 往队列放数据
  • 线程 B 从队列取数据
  • 线程 C 又往队列放数据
  • ...

三个线程交替访问,没有真正的「同时竞争」,但偏向锁就不好使了——因为每个线程都要撤销偏向。

轻量级锁就是为这种场景设计的。


轻量级锁的核心思想

用 CAS 在用户态完成加锁,不阻塞线程,让线程空转一会儿。

┌─────────────────────────────────────────────────────────────────┐
│                      轻量级锁核心流程                            │
├─────────────────────────────────────────────────────────────────┤
│                                                                 │
│  线程进入同步块                                                  │
│        │                                                        │
│        ▼                                                        │
│  ┌─────────────────┐                                            │
│  │ 在线程栈创建锁记录 │                                           │
│  │ LockRecord      │                                           │
│  └─────────────────┘                                            │
│        │                                                        │
│        ▼                                                        │
│  ┌─────────────────┐                                            │
│  │ CAS 交换 Mark   │                                           │
│  │ Word            │                                           │
│  │ 尝试将对象头指向 │                                           │
│  │ 锁记录           │                                           │
│  └─────────────────┘                                            │
│        │                                                        │
│        ├───── 成功 ─────▶ 进入同步块,执行临界区代码              │
│        │                                                        │
│        └───── 失败 ─────▶ 自旋重试(不阻塞!)                    │
│                              │                                  │
│                              ├───── 成功 ──▶ 进入同步块           │
│                              │                                  │
│                              └───── 超时 ──▶ 膨胀为重量级锁       │
│                                                                 │
└─────────────────────────────────────────────────────────────────┘

轻量级锁的获取过程

第一步:在线程栈创建锁记录

java
// 每个线程有自己的线程栈,线程进入同步块时
// 会在栈帧中创建一个 LockRecord
class LockRecord {
    Object reference;    // 指向对象头
    long   originalMarkWord;  // 备份原来的 Mark Word
}

线程栈结构:

┌─────────────────────────────────────────┐
│           线程栈(Thread Stack)         │
├─────────────────────────────────────────┤
│  ┌─────────────────────────────────┐    │
│  │         栈帧 1                   │    │
│  │  ┌─────────────────────────┐    │    │
│  │  │     LockRecord         │    │    │
│  │  │  - reference: obj      │    │    │
│  │  │  - originalMarkWord    │    │    │
│  │  └─────────────────────────┘    │    │
│  │         局部变量表...            │    │
│  │         操作数栈...              │    │
│  └─────────────────────────────────┘    │
│                                          │
│  ┌─────────────────────────────────┐    │
│  │         栈帧 2                   │    │
│  │  ┌─────────────────────────┐    │    │
│  │  │     LockRecord         │    │    │
│  │  └─────────────────────────┘    │    │
│  └─────────────────────────────────┘    │
└─────────────────────────────────────────┘

第二步:CAS 交换 Mark Word

对象头 Mark Word(当前是无锁状态):
┌──────────┬─────────────────────────┬───────┬─────────┬──────────┐
│ 25 bit   │                         │ 4 bit │ 1 bit   │ 2 bit    │
│  对象哈希码  │        未使用            │  age  │ 0       │   01     │
└──────────┴─────────────────────────┴───────┴─────────┴──────────┘

线程栈中 LockRecord 的 originalMarkWord:
┌──────────┬─────────────────────────┬───────┬─────────┬──────────┐
│ 25 bit   │                         │ 4 bit │ 1 bit   │ 2 bit    │
│  对象哈希码  │        未使用            │  age  │ 0       │   01     │
└──────────┴─────────────────────────┴───────┴─────────┴──────────┘

CAS 操作:

java
// 伪代码
do {
    // 备份原来的 Mark Word 到 LockRecord
    lockRecord.originalMarkWord = obj.markWord;
    
    // CAS: 将对象头的 Mark Word 替换为指向锁记录的指针
    expected = obj.markWord;
    newValue = lockRecordPointer | lockState;
    
} while (CAS(obj.markWord, expected, newValue) != success);

成功后,Mark Word 变为:

┌─────────────────────────────────────────────────────────────────┬──────────┐
│                        30 bit                                    │ 2 bit    │
│              指向栈中锁记录的指针                                  │   00     │
└─────────────────────────────────────────────────────────────────┴──────────┘

第三步:进入同步块执行

CAS 成功后,线程持有轻量级锁,可以进入同步块执行临界区代码。

注意:此时对象头的 Mark Word 已经变了,线程需要通过 reference 指针找到自己的 LockRecord 来释放锁。


自旋优化:不让线程真的阻塞

什么是自旋?

当 CAS 失败时,线程不阻塞,而是「空转」重试:

java
public void lock() {
    while (true) {
        if (CAS(obj.markWord, expected, newValue)) {
            return;  // 获取锁成功
        }
        // 空转,继续重试
    }
}

JDK 6 之前:固定次数自旋

java
// JDK 6 之前
private static final int MAX_SPINS = 10;  // 默认自旋10次

for (int i = 0; i < MAX_SPINS; i++) {
    if (CAS(...)) {
        return;  // 成功
    }
}
// 自旋失败,膨胀为重量级锁
inflateToHeavyweightLock();

问题:自旋次数固定,太少可能浪费 CPU,太多可能更浪费。

JDK 6 之后:自适应自旋

JVM 会根据上一次自旋的成功率动态调整:

上一次自旋成功率:80%  ───▶  增加自旋次数
上一次自旋成功率:20%  ───▶  减少自旋次数

自旋次数范围:1 ~ MAX_SPINS(默认10次)
java
// 自适应调整逻辑(简化)
int currentSpins = getThreadLocalSpinCount();
if (previousSpinSuccessRate > 0.5) {
    currentSpins = Math.min(currentSpins + 1, MAX_SPINS);
} else {
    currentSpins = Math.max(currentSpins - 1, 1);
}

自旋的代价

自旋会占用 CPU 时间片:

场景:自旋 10 次,每次自旋耗时约 100 纳秒
总计:约 1 微秒

vs 线程阻塞:
- 阻塞:0 微秒(不占CPU)
- 唤醒:约 100-1000 微秒(需要线程切换)

结论:自旋适合锁持有时间很短的场景。如果锁持有时间长,自旋就浪费 CPU。


轻量级锁的膨胀

什么情况会膨胀?

  1. 自旋超过阈值:自适应自旋决定放弃
  2. 同一个对象有多个线程同时竞争:CAS 竞争激烈

膨胀过程

线程B 自旋失败


┌─────────────────────────────────────────┐
│  在对象头中设置「膨胀」标记               │
│  创建重量级锁 Monitor                    │
└─────────────────────────────────────────┘


┌─────────────────────────────────────────┐
│  将 Mark Word 修改为指向 Monitor 的指针 │
│  线程B 进入 Monitor 的 EntryList 等待    │
└─────────────────────────────────────────┘

轻量级锁 vs 重量级锁

特性轻量级锁重量级锁
线程状态运行(自旋中)阻塞
CPU 消耗
响应速度快(微秒级)慢(毫秒级)
适用场景锁持有时间短锁持有时间长
实现机制CAS + 用户态Monitor + 系统调用

轻量级锁的释放

java
// 线程退出同步块,释放轻量级锁
public void unlock() {
    // 获取对象头中的指向锁记录的指针
    LockRecord* lockRecord = obj.markWord & ~LOCK_MASK;
    
    // CAS:将 LockRecord 中的 originalMarkWord 恢复到对象头
    if (CAS(obj.markWord, currentMarkWord, lockRecord.originalMarkWord)) {
        // 释放成功
    } else {
        // 说明有竞争,需要唤醒等待的线程
        // 膨胀为重量级锁的处理...
    }
}

关键点:释放时需要回滚 Mark Word 到原来的状态。如果有其他线程在等待,它们会被唤醒。


实际代码演示

java
public class LightweightLockDemo {
    private final Object lock = new Object();
    private int counter = 0;
    
    // 模拟交替访问场景
    public void increment() {
        synchronized (lock) {
            // 临界区
            counter++;
        }
    }
    
    public static void main(String[] args) throws InterruptedException {
        LightweightLockDemo demo = new LightweightLockDemo();
        
        // 模拟三个线程交替访问
        Thread t1 = new Thread(() -> {
            for (int i = 0; i < 100000; i++) {
                demo.increment();
            }
        }, "Thread-A");
        
        Thread t2 = new Thread(() -> {
            for (int i = 0; i < 100000; i++) {
                demo.increment();
            }
        }, "Thread-B");
        
        Thread t3 = new Thread(() -> {
            for (int i = 0; i < 100000; i++) {
                demo.increment();
            }
        }, "Thread-C");
        
        t1.start();
        t2.start();
        t3.start();
        
        t1.join();
        t2.join();
        t3.join();
        
        System.out.println("Final counter: " + demo.counter);
    }
}

这个场景下:

  • 偏向锁会被频繁撤销
  • 轻量级锁是更合适的选择
  • 线程交替访问,自旋可以成功

面试追问方向

  1. 为什么轻量级锁需要备份原来的 Mark Word? 释放锁时需要恢复原来的状态,否则对象头的锁信息就丢失了。

  2. 轻量级锁和自旋锁是什么关系? 轻量级锁使用 CAS,自旋是获取轻量级锁失败后的优化手段。两者有关联,但不是同一概念。

  3. 为什么轻量级锁在释放时也需要 CAS? 因为可能有其他线程在等待(已膨胀为重量级锁),需要正确唤醒。

  4. 自适应自旋是如何统计成功率的? JVM 记录每次自旋的结果(成功/失败),根据统计信息动态调整。


留给你的思考题

假设这样一个场景:

系统参数:
- XX:PreBlockSpin=10(固定自旋10次)

执行过程:
T1: 线程A 获取轻量级锁,进入同步块
T2: 线程B 竞争锁,自旋 10 次失败
T3: 线程B 阻塞,线程A 释放锁
T4: 线程C 竞争锁...

问题

  1. 线程B 在 T2 时刻自旋时,线程A 在做什么?
  2. 如果线程A 在同步块中执行了 100ms,线程B 的自旋还有意义吗?
  3. 如何判断一个场景适合使用轻量级锁还是应该直接用重量级锁?

提示:考虑锁的持有时间和上下文切换的开销。

基于 VitePress 构建