实习踩坑记:终于把 synchronized 锁升级搞明白了
开篇:被面试官问懵的那一刻
面试官问我:“synchronized 有几种状态?什么时候会升级?”
我心想这题我背过啊,张口就来:“偏向锁、轻量级锁、重量级锁。”
面试官面无表情:“说说升级过程。”
我开始背:对象头、Mark Word、CAS…
面试官打断:“为什么要升级?直接用重量级锁不行吗?”
我卡壳了。
回来怒学一周,终于把这里面的门道摸透了。这篇博客,写给曾经和我一样只会背答案的你。
一、先搞懂对象头:锁的"身份证"
在说锁之前,必须先搞懂 Java 对象的内存布局。
一个 Java 对象在内存里分三块:
- 对象头:包含 Mark Word + Klass Pointer
- 实例数据:字段内容
- 对齐填充:补齐到 8 字节
锁的秘密全在 Mark Word 里。
Mark Word 是个 64 位(或者 32 位)的"万能槽",不同状态下存的东西不一样:
| 状态 | Mark Word 存储内容 |
|---|---|
| 无锁 | 对象 hashCode + 分代年龄 + 偏向标志(0) + 锁标志(01) |
| 偏向锁 | 偏向线程 ID + epoch + 分代年龄 + 偏向标志(1) + 锁标志(01) |
| 轻量级锁 | 指向栈中锁记录的指针 |
| 重量级锁 | 指向互斥量(管程)的指针 |
一句话记住:Mark Word 就是锁的"身份证",记录了对象当前被谁持有、处于什么状态。
二、偏向锁:第一个吃螃蟹的人独享
2.1 什么是偏向锁
偏向锁是 JDK 1.6 引入的优化。核心思想就一句话:“这个锁老子拿了,谁也别来抢。”
当一个线程第一次访问同步代码块时,JVM 会把 Mark Word 的偏向标志设为 1,记录下当前线程 ID。之后这个线程再来,直接对比 ID,秒过。
// 偏向锁场景:一个线程反复进入同一个锁
synchronized (lock) {
// 第一次:CAS 设置偏向
// 第二次:直接比对线程 ID,零成本
}
省掉了每次加锁的 CAS 开销。
2.2 什么时候撤销偏向锁
偏向锁虽好,但不是永久的。遇到其他线程来抢,就凉了:
// 线程 A 正在偏向中
synchronized (lock) {
// 线程 B 来了
}
// 此时 JVM 发现有竞争,撤销偏向锁
// 升级为轻量级锁
撤销偏向锁的场景:
- 其他线程竞争:线程 B 来获取锁,偏向锁被撤销
- 调用 wait():wait() 是重量级锁专属,偏向锁直接凉凉
- hashCode():对象调用 hashCode() 会导致偏向锁撤销
三、轻量级锁:CAS 自旋的战场
3.1 轻量级锁的加锁过程
当偏向锁被撤销,或者一开始就是多线程竞争,锁就升级为轻量级锁。
我第一次看这个流程时绕了好久,用大白话讲:
- 线程抢锁:线程在栈里创建一个"锁记录"(Lock Record),复制对象头的 Mark Word
- CAS 抢椅子的游戏:用 CAS 把对象头的 Mark Word 改成指向自己的锁记录
- 抢到就是赚到:CAS 成功,加锁成功
- 没抢到就自旋:CAS 失败,自旋重试,不放弃
// 简化模拟
public void lock() {
// 第一步:在栈里创建锁记录,复制 Mark Word
// 第二步:CAS 修改对象头
if (cas(markWord, copiedMarkWord, threadLockRecord)) {
// 成功!拿到了轻量级锁
} else {
// 没抢到,自旋重试
while (true) {
if (cas(...)) break;
}
}
}
3.2 轻量级锁的解锁过程
解锁也是 CAS:
// 线程执行完同步块,CAS 把栈里的锁记录写回对象头
if (cas(对象头, 当前锁记录, 之前的MarkWord)) {
// 解锁成功
} else {
// 解锁失败,说明有竞争,进入重量级锁流程
}
3.3 什么情况下轻量级锁会"膨胀"
这是面试常问的:“轻量级锁什么时候变成重量级锁?”
两种情况:
第一种:自旋次数超限。
// JDK 1.6 默认自旋 10 次
// 超过 10 次还没拿到锁,直接升级
for (int i = 0; i < 10; i++) {
if (cas(...)) return; // 拿到锁
}
// 升级为重量级锁
第二种:自旋过程中有第三个线程来抢。
// 线程 A 和 B 在轻量级锁层面自旋
// 线程 C 也来抢这把锁
// JVM 判断:竞争激烈,别自旋了,升级!
记住一个公式:
轻量级锁 ≈ “我赌你很快就能释放锁”
如果赌输了(自旋太久 / 竞争加剧),就升级为重量级锁。
四、重量级锁:最传统也最稳定
4.1 重量级锁的原理
重量级锁依赖操作系统的 Mutex(互斥量)实现。
synchronized (lock) {
// 调用操作系统底层
// 线程没拿到锁,进入等待队列
// 操作系统把线程挂起
}
特点:
- 线程进入等待状态,不占 CPU
- 需要用户态和内核态切换,开销大(大约是轻量级锁的 100 倍)
- 不自旋,不浪费 CPU
4.2 我踩过的坑:锁竞争导致的线上故障
实习期间遇到过一次 OOM 故障,根因就是 synchronized + 重量级锁。
// 伪代码
public synchronized void processOrder(Order order) {
// 这个方法被高频调用
// 每次进来都要竞争重量级锁
// 大量线程阻塞在 wait queue
// 导致内存占用飙升
}
教训:
- 高并发场景下,别用 synchronized 锁太久的代码块
- 优先考虑分段锁、读写锁、ConcurrentHashMap
五、锁升级流程图(面试必备)
无锁状态
│
│ 第一个线程进入
▼
偏向锁(偏向线程 ID = A)
│
│ 线程 B 来竞争
▼
偏向锁撤销
│
│ CAS 抢锁
▼
轻量级锁(A 在栈中持有锁记录)
│
│ 自旋超过 10 次 / 竞争加剧
▼
重量级锁(OS Mutex,线程阻塞)
面试能说出这个流程,加分。
六、面试高频 Q&A
Q1:为什么要有锁升级?不能直接用重量级锁吗?
答: 因为性能。重量级锁需要操作系统介入,用户态切换内核态,开销巨大。偏向锁和轻量级锁都是在"大多数情况只有一个线程"的假设下做的优化,避免无谓的系统调用。
一句话: 能用轻量级解决就别用重量级,底层优化就是省那 100 倍的开销。
Q2:synchronized 和 ReentrantLock 区别?
| 对比项 | synchronized | ReentrantLock |
|---|---|---|
| 底层 | Monitor (OS) | AQS |
| 锁升级 | 自动升级 | 可重入,不可升级 |
| 公平锁 | 不支持 | 支持 |
| 等待可中断 | 不支持 | 支持 |
| 条件变量 | 无 | Condition |
我的理解: synchronized 是"傻瓜相机",自动优化;ReentrantLock 是"单反相机",手动调参。
Q3:什么是锁粗化?为什么会有?
答: JIT 编译优化。如果一段代码反复加锁解锁,JIT 会把锁范围扩大,减少加锁次数。
// JIT 优化前
for (int i = 0; i < 1000; i++) {
synchronized (lock) {
list.add(i);
}
}
// JIT 优化后(锁粗化)
synchronized (lock) {
for (int i = 0; i < 1000; i++) {
list.add(i);
}
}
Q4:什么是锁消除?和逃逸分析什么关系?
答: 锁消除是 JIT 编译器分析发现一个锁对象不可能被其他线程访问,直接把锁去掉。
// JIT 逃逸分析发现 sb 是局部变量,不会逃逸
// 自动消除 synchronized
StringBuffer sb = new StringBuffer();
synchronized (sb) {
sb.append("hello");
}
// 编译后:sb.append("hello"); // 锁没了
七、记忆口诀
无锁 → 偏向 → 轻量 → 重量
一个线程用偏向,多个线程轻量来
自旋失败或竞争激,重量锁住线程歇
逐句解释:
- 无锁 → 偏向:第一个线程独享
- 偏向 → 轻量:其他线程来竞争
- 轻量 → 重量:自旋超时或竞争激烈
总结
写这篇文章的收获:
- 锁的本质是对象头:Mark Word 的状态决定了锁的形态
- 升级是单向的:只能无锁→偏向→轻量→重量,不能降级(JDK 15 后偏向锁已废弃)
- 性能优化的哲学:假设"大多数情况只有一个线程",用轻量手段解决;假设失败再升级
面试被问到锁升级,别再只会背三个名字了。把 Mark Word、升级条件、性能差异说出来,面试官会眼前一亮。
更多推荐


所有评论(0)