JDK 6 之后锁升级机制:偏向锁 → 轻量级锁 → 重量级锁
你知道吗?JDK 6 之前,synchronized 被称为「性能杀手」。
那时候,一个简单的 synchronized 代码块,就可能导致线程阻塞——而线程阻塞需要操作系统介入,用户态和内核态的切换,少说也要几微秒。
但 JDK 6 之后,JVM 团队花了三年时间重新设计 synchronized,引入了一套精妙的锁升级机制。
从此,synchronized 的性能可以和 Lock 抗衡了。
锁升级路线图
┌─────────┐ 线程第一次访问 ┌─────────┐ 出现竞争 ┌─────────┐ 竞争激烈 ┌─────────┐
│ 无锁 │ ───────────────────▶ │ 偏向锁 │ ─────────────▶ │ 轻量级锁 │ ──────────────▶ │ 重量级锁 │
└─────────┘ └─────────┘ └─────────┘ └─────────┘
│ │ │ │
│ │ │ │
正常状态 单线程访问同步块 线程交替执行 多线程同时竞争
(无竞争) (无实际竞争) (需要OS介入)重要特性:这把锁只能升级,不能降级——这就是所谓的「锁膨胀」。
阶段一:无锁 → 偏向锁
什么时候进入偏向锁?
当一个线程第一次访问同步块时,JVM 会尝试用偏向锁。
偏向锁的获取过程
线程A 首次进入同步块
│
▼
┌─────────────────────────────────────────┐
│ 检查 Mark Word:biased_lock = 0? │
└─────────────────────────────────────────┘
│ 是
▼
┌─────────────────────────────────────────┐
│ CAS(0, thread_id) │
│ 将 Mark Word 的 biased_lock 设为 1 │
│ 并写入当前线程 ID │
└─────────────────────────────────────────┘
│ 成功
▼
偏向锁获取成功
线程A 进入同步块,无需任何同步操作为什么偏向锁这么快?
因为根本不需要 CAS!再次进入同步块时,只需检查 Mark Word 中的线程 ID 是否是自己的。
// 偏向锁状态下的同步块执行
if (markWord.threadId == currentThreadId) {
// 跳转到同步代码块
}阶段二:偏向锁 → 轻量级锁
什么情况会撤销偏向锁?
- 另一个线程尝试竞争:线程 B 来访问同一个同步块
- 调用 hashCode():计算过 hashCode 的对象无法保持偏向锁
- 偏向锁等待超时:
BiasedLockingStartupDelay默认 4 秒
撤销过程
线程B 尝试进入同步块
│
▼
┌─────────────────────────────────────────┐
│ 发现对象处于偏向锁状态 │
│ 且偏向的线程A 正在执行 │
└─────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────┐
│ 偏向锁撤销(升级为轻量级锁) │
└─────────────────────────────────────────┘
│
▼
线程B 进入轻量级锁获取流程阶段三:轻量级锁 → 重量级锁
什么情况会膨胀为重量级锁?
- CAS 竞争失败:线程 B 自旋一定次数后仍未获取锁
- 自旋超过阈值:JVM 自适应决定
膨胀过程
线程B CAS 获取轻量级锁失败
│
▼
┌─────────────────────────────────────────┐
│ 在线程B 栈帧中创建锁记录(Lock Record) │
│ 并将 Mark Word 复制到锁记录 │
└─────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────┐
│ CAS(对象头, 锁记录指针, 线程A的MarkWord) │
└─────────────────────────────────────────┘
│
├────────── 成功 ───────────▶ 线程B 获得轻量级锁
│
└───── 失败(线程A还没释放)──▶ 膨胀为重量级锁
线程B 进入阻塞队列三种锁状态对比
| 特性 | 偏向锁 | 轻量级锁 | 重量级锁 |
|---|---|---|---|
| 适用场景 | 单线程访问 | 多线程交替访问 | 多线程竞争 |
| 性能开销 | 最低(仅首次CAS) | 较低(CAS自旋) | 较高(OS阻塞) |
| 实现原理 | Mark Word 存线程ID | Mark Word 指向栈锁记录 | Mark Word 指向Monitor |
| CPU 消耗 | 无 | 自旋消耗CPU | 线程阻塞不消耗CPU |
| 响应速度 | 立即 | 稍慢(自旋) | 慢(线程切换) |
为什么锁升级不可逆?
这是面试常问的问题。核心原因:Mark Word 的空间不够用。
无锁状态:25bit hashCode + age + lock
偏向锁:23bit threadID + epoch + age + lock
轻量级锁:30bit 指向锁记录的指针 + lock
重量级锁:30bit 指向Monitor的指针 + lock偏向锁需要存线程 ID,轻量级锁需要存指针——这两个信息不能同时存在。
一旦升级到轻量级锁,原来的偏向信息就丢失了,无法退回偏向锁。
实际性能对比
用一个小测试来验证:
public class LockUpgradeTest {
private static final int ITERATIONS = 10_000_000;
// 无竞争场景
public static void singleThread() throws Exception {
Object lock = new Object();
long start = System.nanoTime();
for (int i = 0; i < ITERATIONS; i++) {
synchronized (lock) {
// 空操作
}
}
System.out.println("单线程耗时: " + (System.nanoTime() - start) / 1_000_000 + "ms");
}
public static void main(String[] args) throws Exception {
singleThread();
}
}理论预期结果:
- 偏向锁:~50ms(几乎无开销)
- 轻量级锁:~100ms(有自旋开销)
- 重量级锁:~500ms+(线程阻塞开销)
JDK 对锁升级的影响
JDK 15+ 的变化
JDK 1.6 - 1.14: 偏向锁默认开启
JDK 15: 偏向锁默认关闭(--XX:+UseBiasedLocking)
JDK 18: 偏向锁彻底移除原因:偏向锁在多线程竞争激烈的场景下,反而会带来额外的撤销开销。
关闭偏向锁
# 关闭偏向锁(高并发场景推荐)
-XX:-UseBiasedLocking
# 设置偏向锁延迟(等待JVM启动完成后再开启)
-XX:BiasedLockingStartupDelay=0实战建议
1. 高并发场景
# 关闭偏向锁,减少撤销开销
-XX:-UseBiasedLocking2. 低并发场景
保持默认配置,偏向锁能带来最好的性能。
3. 锁竞争激烈但需要低延迟
考虑使用 ReentrantLock,可以避免 synchronized 的不公平性:
ReentrantLock lock = new ReentrantLock(true); // 公平锁面试追问方向
synchronized 和 ReentrantLock 在锁升级方面有什么区别? synchronized 会自动升级,ReentrantLock 不会(它直接使用 AQS,没有偏向锁和轻量级锁的概念)。
为什么 JDK 15 要废弃偏向锁? 偏向锁在竞争激烈时需要频繁撤销,开销反而大于无锁状态。
锁升级过程中,Mark Word 变化了几次? 最多三次:无锁→偏向锁→轻量级锁→重量级锁。
如果一个对象的偏向锁被撤销后,还能再次偏向吗? 可以,但需要等到批量重偏向阈值。
留给你的思考题
假设这样一个场景:
时间线:
T1: 线程A 进入同步块,获取偏向锁
T2: 线程A 退出同步块,但对象仍被线程A 使用
T3: 线程B 来竞争这个对象的锁
T4: 偏向锁被撤销,轻量级锁登场
T5: 线程B 自旋失败,膨胀为重量级锁问题:
- T2 时刻,偏向锁会释放吗?为什么?
- 如果 T3 发生在 T2 之前(即线程A 还在同步块中),偏向锁的撤销过程有什么不同?
- 如果线程A 和线程B 交替进入同一个同步块,锁会在哪些状态之间切换?
