轻量级锁: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。
轻量级锁的膨胀
什么情况会膨胀?
- 自旋超过阈值:自适应自旋决定放弃
- 同一个对象有多个线程同时竞争: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);
}
}这个场景下:
- 偏向锁会被频繁撤销
- 轻量级锁是更合适的选择
- 线程交替访问,自旋可以成功
面试追问方向
为什么轻量级锁需要备份原来的 Mark Word? 释放锁时需要恢复原来的状态,否则对象头的锁信息就丢失了。
轻量级锁和自旋锁是什么关系? 轻量级锁使用 CAS,自旋是获取轻量级锁失败后的优化手段。两者有关联,但不是同一概念。
为什么轻量级锁在释放时也需要 CAS? 因为可能有其他线程在等待(已膨胀为重量级锁),需要正确唤醒。
自适应自旋是如何统计成功率的? JVM 记录每次自旋的结果(成功/失败),根据统计信息动态调整。
留给你的思考题
假设这样一个场景:
系统参数:
- XX:PreBlockSpin=10(固定自旋10次)
执行过程:
T1: 线程A 获取轻量级锁,进入同步块
T2: 线程B 竞争锁,自旋 10 次失败
T3: 线程B 阻塞,线程A 释放锁
T4: 线程C 竞争锁...问题:
- 线程B 在 T2 时刻自旋时,线程A 在做什么?
- 如果线程A 在同步块中执行了 100ms,线程B 的自旋还有意义吗?
- 如何判断一个场景适合使用轻量级锁还是应该直接用重量级锁?
提示:考虑锁的持有时间和上下文切换的开销。
