JAVA · Vol.II · DAY 12 · 并发编程
synchronized 锁升级:偏向锁 → 轻量级锁 → 重量级锁
从一次线上事故说起
先讲一个真实发生过的线上事故。某天晚上大促压测,一个订单服务的接口 P99 从 80ms 一路飙到 3 秒,线程池被打满,新请求全部排队。运维 jstack 一抓,发现几百个线程齐刷刷卡在同一个状态:
"http-nio-8080-exec-42" #42 prio=5 os_prio=31 ...
java.lang.Thread.State: BLOCKED (on object monitor)
at com.demo.order.OrderService.createOrder(OrderService.java:58)
- waiting to lock <0x00000007ff8f4d10> (a java.lang.Object)
at com.demo.order.OrderController.createOrder(...)
一个对象的锁被某个线程长期持有,后面几百个线程都在排队等它——这就是最典型的锁竞争热点。等你学完这一篇,再看到这种日志,你一眼就能说出:这些线程正堵在 EntryList 上等锁,而锁大概率已经膨胀成了重量级锁。
随后面试官微微一笑,抛出了那个经典问题:
90% 的候选人会脱口而出:"synchronized 是重量级锁,ReentrantLock 是轻量级锁"。面试官心里已经给你打了标签——停留在 JDK 5 的认知。
事实上,从 JDK 6 开始,JVM 团队对 synchronized 做了翻天覆地的优化,引入了偏向锁、轻量级锁、锁膨胀等机制,让 synchronized 在大多数场景下的性能已经不输甚至优于 ReentrantLock。HotSpot 虚拟机甚至为此重写了大半个 monitor 模块。这篇文章,我们就来拆解 synchronized 背后的锁升级机制——从一把"笨锁"进化成"智能锁"的全过程。先给你一个 30 秒版答案,后面每一站都是在给这句话"上细节":
synchronized 是 JVM 层面的内置锁,锁的信息存在对象头 Mark Word 里,会经历无锁 → 偏向锁 → 轻量级锁 → 重量级锁的升级,升级单向不可逆;JDK 6 之后经过大量优化,性能已不输 ReentrantLock,默认优先用它;只有当需要可中断、超时获取、公平锁、多条件队列这些能力时,才轮到 ReentrantLock 上场。
发车之前,先问自己三个问题:
- Mark Word 有多少位?偏向锁时里面存的是什么?
- 锁能升级,能降级吗?
- ObjectMonitor 的 EntryList 和 WaitSet 有什么区别?
带着这些问题,我们从最底层开始,一步步拆解。
三种用法:synchronized 到底锁的是谁?
很多人在面试第一关就翻车,不是不知道 synchronized,而是说不清"它到底锁在哪个对象上"。先背下这张表,它是整篇文章的地基:
| 用法 | 锁的对象 | 等价写法 |
|---|---|---|
| 修饰实例方法 | this(当前实例对象) | synchronized (this) { … } |
| 修饰静态方法 | 类名.class(Class 对象) | synchronized (X.class) { … } |
| 修饰代码块 | 任意指定的对象 | synchronized (obj) { … } |
public class SyncUsageDemo {
private int count = 0;
private final Object lock = new Object();
// 用法一:修饰实例方法 —— 锁的是 this(当前实例)
public synchronized void instanceMethod() {
count++;
}
// 用法二:修饰静态方法 —— 锁的是 SyncUsageDemo.class
public static synchronized void staticMethod() {
System.out.println("静态方法锁的是 Class 对象");
}
// 用法三:修饰代码块 —— 锁的是你指定的对象
public void blockMethod() {
synchronized (lock) {
count++;
}
}
public static void main(String[] args) {
SyncUsageDemo demo = new SyncUsageDemo();
demo.instanceMethod(); // 锁 this
SyncUsageDemo.staticMethod(); // 锁 Class 对象
demo.blockMethod(); // 锁自定义 lock 对象
}
}
背下用法只是第一步,真正考的是你知不知道它们之间互不互斥。这里有三个高频坑,每一个都值得单独记住:
- 坑一:两个实例的同步实例方法互不干扰。因为锁的
this不同——new 出 10 个对象,就有 10 把独立的锁。所以"给实例方法加 synchronized 就能全局互斥"是错的。 - 坑二:静态同步方法对"所有实例"都互斥。因为 Class 对象在 JVM 里全局唯一,一个类的所有实例共享这一把锁。
- 坑三:静态同步方法和实例同步方法之间不互斥。一个锁 Class、一个锁 this,两者井水不犯河水。这也是面试最爱的追问点。
不是。静态方法锁的是 X.class(Class 对象),实例方法锁的是 this。想在代码块里锁 Class 对象,可以写 synchronized (X.class) 或 synchronized (this.getClass())——同一运行时类,getClass() 返回的是同一个 Class 对象,等价于静态方法。另外记住:锁的对象必须是引用类型,基本类型(int、boolean)不能加锁,编译直接报错。
生产代码里,代码块 + 专用锁对象(第 14 站细讲)是主流写法,因为它能精确控制锁的粒度——只锁真正需要保护的几行,而不是整个方法。方法级 synchronized 适合"整个方法就是临界区"的简单场景。
对象头结构:锁信息藏在哪?
在 HotSpot 虚拟机中,每一个 Java 对象在内存中都由三部分组成:对象头(Object Header)、实例数据(Instance Data)和对齐填充(Padding)。锁的所有状态信息,都存储在对象头的 Mark Word 里——这就是"synchronized 是对象级锁"这句话的物理基础。
// 64 位 JVM 下的对象头结构
┌─────────────────────────────────────┐
│ Mark Word(8 字节 = 64 位) │ ← 锁状态、哈希码、GC 分代年龄
├─────────────────────────────────────┤
│ Klass Pointer(4/8 字节) │ ← 指向类元数据(开启指针压缩后 4 字节)
├─────────────────────────────────────┤
│ 数组长度(仅数组对象,4 字节) │ ← 如果是普通对象则没有这部分
└─────────────────────────────────────┘
重点在 Mark Word。它是一个 64 位的位图,不同锁状态下存储的内容完全不同——同一个位置,无锁时放 hashCode,偏向锁时放线程 ID,轻量级锁时放指向 Lock Record 的指针,重量级锁时放指向 ObjectMonitor 的指针:
把位段分配整理成一张速查表,面试画图就靠它:
| 锁状态 | Mark Word 位段(高位 → 低位) | 锁标志位 |
|---|---|---|
| 无锁 | unused 25 + hashCode 31 + cms_free 1 + age 4 + biased_lock 1 | 01 |
| 偏向锁 | thread 54 + epoch 2 + age 4 + biased_lock 1(另 1 位保留) | 01 |
| 轻量级锁 | 指向 Lock Record 的指针 62 | 00 |
| 重量级锁 | 指向 ObjectMonitor 的指针 62 | 10 |
| GC 标记 | 转发指针(forwarding pointer)62 | 11 |
几个容易被追问的细节,一次讲透:
- age(4 bit):GC 分代年龄,最大 15。对象每熬过一次 Minor GC 年龄 +1,达到阈值(默认 15)晋升老年代。这就是为什么 JVM 调优时晋升阈值最大只能设 15——位段只有 4 bit。
- hashCode(31 bit):存的是 identity hash code(
System.identityHashCode/ 未重写的hashCode()),而且是懒计算的——第一次调用才写入 Mark Word。 - biased_lock + lock(1 + 2 bit):偏向锁和无锁的标志位都是 01,靠第 3 低位区分——这个设计省了标志位空间。
无锁态的 Mark Word 里放的是 hashCode(31 bit),偏向锁态放的是线程 ID(54 bit)——一个对象既想存 hashCode 又想存线程 ID,64 位根本放不下。所以一旦某个对象调用过 identity hash code(hashCode() 被写入),它就不能进入偏向锁;反过来,偏向锁期间调用 hashCode(),必须先撤销偏向,把 Mark Word 恢复成能放 hashCode 的形态。JDK 15 废弃偏向锁(JDK 18 彻底移除)后,这个限制也随之消失。这个点答出来,面试官会眼前一亮。
顺带一提:Mark Word 的大小随 JVM 位数变化——32 位 JVM 是 32 bit(8 字节对象头),64 位 JVM 是 64 bit;而 Klass Pointer 在开启指针压缩(-XX:+UseCompressedOops,JDK 8 默认)时只占 4 字节。面试默认按 64 位、未压缩或压缩场景说清楚即可。
管程 Monitor:Owner / EntryList / WaitSet
先补一个操作系统课上的概念:管程(Monitor)。它是并发编程里最经典的一等公民——把「互斥」和「条件等待」打包在一起的管理机制。Java 里每个对象都天生带一个 monitor,synchronized 加锁,本质就是去抢这个对象的 monitor;而重量级锁的底层实现,就是 HotSpot 的 ObjectMonitor 对象。
用餐厅叫号的类比来理解 ObjectMonitor 的内部结构,三组"人"各司其职:
- Owner:当前正在"用餐"的线程——同一时刻只能有一个,也就是持锁者;
- EntryList:门口排队等位的线程——想吃饭(抢锁)没抢到,先排队等着叫号;
- WaitSet:已经坐下、但点的菜还没上、暂时"让出座位"的线程——调用了
wait(),等notify()上菜才能继续。
围绕这三组结构,synchronized 的每个动作都有了明确落点:
| 操作 | 行为 | 涉及队列 |
|---|---|---|
| 加锁(monitorenter) | 如果 _owner 为空则获取;否则 park 线程 | cxq → EntryList |
| 解锁(monitorexit) | 释放 _owner,从 EntryList 唤醒一个线程 | EntryList 出队 |
| wait() | 释放 _owner,当前线程进入 WaitSet | → WaitSet |
| notify() | 从 WaitSet 移一个线程到 EntryList | WaitSet → EntryList |
| notifyAll() | 将 WaitSet 所有线程移到 EntryList | WaitSet → EntryList |
顺便解释一下图里的 _cxq:新来的竞争线程会先进入这个无锁链表,避免大家同时往 EntryList 上加锁造成二次竞争;解锁时再批量转移到 EntryList。这是 HotSpot 的性能细节,面试时主动提一句是明显加分项。
一句话记牢三者的分工:Owner 是"正在持锁"、EntryList 是"排队等锁"、WaitSet 是"等条件"。后面第 11 站讲 wait/notify 时,你会反复用到这张图。
偏向锁 (Biased Locking):一个人的独角戏
HotSpot 团队在大量真实应用中发现了一个关键观察:大多数锁在整个生命周期中只被同一个线程访问。比如一个 StringBuffer 对象几乎总是在同一个线程里被 append,一个连接池的内部状态通常由单个管理线程操作。
基于这个观察,偏向锁的设计思路非常直接:既然只有一个线程来,那就把锁"偏向"给它,后续访问连 CAS 都不需要做。
想象一个小区物业,给每辆车安排了专属停车位:第一次停车时,物业在车位上写上你的车牌号(CAS 把线程 ID 写入 Mark Word);之后你每次来,直接停进去就行,连登记都不需要(零开销放行)。只有别的车来停,物业才需要处理——撤销你的专属权(偏向撤销),然后按普通车位规则来。
偏向锁的获取流程:
// 第一次进入 synchronized 块
if (markWord.bias_pattern == 1 && markWord.threadId == 0) {
// 对象可偏向,且还没有偏向任何线程
CAS(markWord.threadId, 0, currentThreadId); // CAS 写入当前线程 ID
return; // 加锁成功,几乎无开销
}
// 后续进入 synchronized 块
if (markWord.threadId == currentThreadId) {
return; // 直接放行!不需要任何原子操作
}
// 其他线程来竞争 → 偏向锁撤销 → 升级为轻量级锁
revokeBias();
关键优势在于 零 CAS 开销。传统加锁每次进入同步块都需要 CAS 操作,偏向锁只需要第一次 CAS,之后就是普通的内存读比较——这比 CAS 快一个数量级。
偏向锁撤销(revocation)有个重要细节:撤销必须等到安全点(SafePoint)。JVM 要停住所有线程,检查持有偏向的线程状态:
- 偏向线程已结束(或不再使用该对象)→ 恢复为无锁、可重新偏向;
- 偏向线程还活着且在临界区内 → 撤销偏向,升级为轻量级锁。
正因为撤销要走安全点、可能引发短暂的 Stop-The-World 暂停,偏向锁在高竞争场景下反而是负优化——这也是它最终被废弃的核心原因之一。
偏向锁的设计初衷是好的,但它带来了巨大的代码复杂度。HotSpot 中与偏向锁相关的代码超过 2 万行,涉及 GC、类加载、锁撤销等多个子系统的协调。
JEP 374(JDK 15)正式废弃偏向锁并默认关闭(-XX:-UseBiasedLocking);JDK 18 起彻底移除,连开关都不再支持。原因:
- 现代应用更多是多线程并发,"只有一个线程访问"的场景变少;
- 偏向锁撤销涉及 SafePoint,可能导致 STW 暂停;
- 维护成本高,收益递减——轻量级锁本身已经很快了。
面试时要说:偏向锁在 JDK 15+ 默认关闭并废弃,JDK 18 彻底移除;现代 JVM 直接从轻量级锁起步。千万别再说"synchronized 永远很慢"这种过时结论。
轻量级锁 (Lightweight Lock):短暂竞争的最优解
当偏向锁遇到竞争(另一个线程也来加锁),或者 JDK 15+ 直接跳过偏向锁阶段,锁就会进入轻量级锁状态。
轻量级锁的设计假设是:锁的竞争是短暂的——线程 A 马上就会释放,线程 B 只需要稍微"等"一下就拿到了。这种情况下,不值得动用操作系统级别的线程挂起(那涉及用户态到内核态的切换,代价高昂)。
竞争很短暂,就像等电梯:A 马上从 5 楼下来,你在门口等一两秒就行,没必要呼叫物业(OS 内核)派人来处理。等电梯就是自旋(spin)——用户态空转等待;叫保安处理就是挂起(park)——线程进入内核态阻塞,等系统调度唤醒。
核心机制是 Lock Record(锁记录),它位于线程栈帧中:
// 每个线程栈帧中都有若干 Lock Record
// 用于在轻量级锁阶段保存 Mark Word 的备份
struct BasicLock {
volatile markOop displaced_header; // 保存对象原始的 Mark Word
};
// 加锁流程:
// 1. 在当前线程栈帧中创建一个 Lock Record
// 2. 把对象的 Mark Word 复制到 Lock Record 的 displaced_header
// 3. CAS 尝试将 Mark Word 替换为指向 Lock Record 的指针
// 4. CAS 成功 → 拿到锁(锁标志位变为 00)
// 5. CAS 失败 → 存在竞争 → 可能膨胀为重量级锁
轻量级锁的完整获取与释放序列:
这里要澄清一个常见的模糊表述:轻量级锁加锁用的是 CAS,竞争失败后 JVM 并不会立刻挂起线程,而是先短暂自旋(spin)等待持锁者释放,这就是自适应自旋(Adaptive Spinning)——JVM 根据历史上该锁的竞争情况动态调整自旋次数:上次自旋成功,这次就多转几圈;上次失败,这次就少转。自旋还是拿不到锁,才走锁膨胀。记住这条分界线:
重量级锁 = 线程挂起/唤醒(内核态阻塞,park/unpark)
竞争不激烈时,轻量级锁通常比重量级锁快一个数量级
(省掉了用户态 ↔ 内核态的切换)
锁膨胀 (Lock Inflation):从轻到重的临界点
当轻量级锁的 CAS 操作失败、自旋也等不到锁——意味着有另一个线程也在持续竞争同一把锁,竞争已经从"短暂"变成了"持续"。此时 JVM 会执行锁膨胀,将轻量级锁升级为重量级锁。
膨胀过程不可逆(在绝大多数 JDK 版本中),这就是"锁升级"中"升级"的含义——一旦变重,就回不去了。
膨胀的核心步骤:
ObjectMonitor* inflate(Thread* self, oop obj) {
// 1. 为对象创建一个 ObjectMonitor(如果不存在)
ObjectMonitor* monitor = new ObjectMonitor();
// 2. 将对象的 Mark Word 从"指向 Lock Record"改为"指向 ObjectMonitor"
// 锁标志位从 00 变为 10
obj->set_mark(markOop::encode(monitor));
// 3. 将之前持有轻量级锁的线程放入 ObjectMonitor 的 Owner 字段
monitor->set_owner(lockRecordOwner);
// 4. 将当前竞争失败的线程放入 EntryList(阻塞队列)
monitor->enter(self); // 线程被 park(),进入内核态阻塞
return monitor;
}
理论上,当竞争消失后,重量级锁可以"降级"回轻量级锁以恢复性能。但实际上,HotSpot 的实现中锁降级非常有限。原因:
- 降级逻辑复杂——需要确认 EntryList 为空、没有线程在 wait() 等;
- 降级本身有开销——CAS 修改 Mark Word、清理 ObjectMonitor;
- 经验数据表明:竞争激烈过的锁,未来大概率还会竞争。
JDK 8u20 之后引入了有限的锁降级,但只在安全点(SafePoint)批量进行,不是实时降级。面试答"锁升级单向不可逆,仅有安全点批量降级"就到位了。
重量级锁 (Heavyweight Lock):为什么它最慢
重量级锁的底层是 ObjectMonitor(第 4 站的结构图),它通过操作系统的 Mutex 和 Condition Variable 实现线程的阻塞与唤醒。synchronized 编译后,每个同步块对应两条字节码指令 monitorenter 和 monitorexit:
// Java 源码
synchronized (obj) {
count++;
}
// 编译后的字节码(javap -c 输出)
0: aload_1 // 加载 obj 到操作数栈
1: dup // 复制一份引用(用于 finally 中的 monitorexit)
2: monitorenter // 尝试获取 monitor 锁
3: aload_0
4: dup
5: getfield #2 // 读取 count
8: iconst_1
9: iadd
10: putfield #2 // 写回 count++
13: aload_1
14: monitorexit // 正常退出,释放 monitor 锁
15: goto 23
18: astore_2 // 异常处理入口
19: aload_1
20: monitorexit // 异常退出,也要释放锁!
21: athrow // 重新抛出异常
23: return
注意字节码里有两个 monitorexit:一个是正常路径(第 14 行),一个是异常路径(第 20 行,由编译器自动生成的 finally 块)。这就是为什么 synchronized 能保证即使抛出异常,锁也一定会被释放——语法层面的保证,不需要你写 finally。
这也是 synchronized 与 ReentrantLock 最本质的差异:一个在 JVM 层(字节码指令),一个在 JDK 层(java.util.concurrent 的类)。前者由 JVM 解释执行加解锁语义,后者需要你手动 lock()/unlock() 并自己保证 finally 释放。
慢不在"锁"本身,而在线程的挂起与唤醒:
- 用户态 ↔ 内核态切换:
park/unpark要陷入内核,一次切换代价在微秒级,频繁切换直接吃掉吞吐; - 线程上下文切换:被唤醒的线程要重新进入运行队列、竞争 CPU,涉及寄存器保存恢复、缓存失效;
- 调度延迟:从"锁释放"到"等待线程真正拿到 CPU 继续执行",中间隔了好几步系统调度,延迟远高于自旋。
所以重量级锁只有在临界区长、竞争激烈(持锁时间远大于切换成本)时才是合理选择;临界区极短时,宁可让竞争者自旋。
整条链路的性能逻辑一句话:能用内存操作(CAS)解决就不用自旋,能用自旋解决就不用挂起线程——每一级优化都是在"少进一次内核"。第 1 站事故里几百个线程 BLOCKED,说明锁已经膨胀到重量级、线程全在挂起等锁,这就是性能雪崩的典型现场。
可重入性 (Reentrancy):同一线程能反复进
什么叫可重入(Reentrant)?就是同一个线程可以重复进入自己已经持有的锁。写业务代码时这几乎是必然发生的:一个 synchronized 方法调另一个 synchronized 方法,或者递归调用,都会"重复进入"。如果锁不可重入,那么方法一调用方法二,自己就把自己锁死了——这叫自死锁(self-deadlock)。
public class ReentrantDemo {
// 场景一:synchronized 方法嵌套调用
public synchronized void a() {
System.out.println("进入 a(),持有锁");
b(); // 同一个线程再次进入 synchronized —— 可重入,不会死锁
}
public synchronized void b() {
System.out.println("进入 b(),锁可重入");
}
// 场景二:递归调用
public synchronized int factorial(int n) {
return n <= 1 ? 1 : n * factorial(n - 1);
}
public static void main(String[] args) {
ReentrantDemo demo = new ReentrantDemo();
demo.a(); // 嵌套调用,正常运行不卡死
System.out.println("10! = " + demo.factorial(10)); // 递归,正常运行
}
}
原理一句话:JVM 在 monitor 里记录了持有者线程(_owner)和重入计数(_recursions)。同一线程再次进入,只做一次判断——_owner == 当前线程,成立就计数 +1 直接放行;每退出一层计数 -1,减到 0 才真正释放锁。所以在轻量级锁阶段,重入时 Mark Word 指向的 Lock Record 不需要变——这就是为什么同一个线程可以在同一个对象上加锁多次而不乱套。
都可重入。synchronized 靠的是 JVM 内部的 _owner + _recursions;ReentrantLock 靠的是 AQS 的 state 字段——同一线程再次 lock() 时 state +1,释放时逐步减,减到 0 才算真正释放(详见 AQS 那一篇)。共同点:都通过"持有者身份 + 计数"实现可重入。反过来,不可重入的锁(如早期的自旋锁实现)遇到递归或嵌套调用就会自死锁——这也是面试官爱出的陷阱题。
锁升级全景图:状态机总览
现在把前几站的内容串联起来,看完整的锁升级状态机:
无锁 → 偏向锁:第一次加锁,CAS 写入线程 ID
偏向锁 → 轻量级锁:另一个线程尝试加锁(撤销偏向)
轻量级锁 → 重量级锁:CAS 替换 Mark Word 失败(检测到真实竞争)
JDK 15+ 之后:无锁 → 轻量级锁 → 重量级锁(偏向锁已移除)
标准答案是:锁升级单向不可逆,重量级锁不会自动降级回轻量级锁。严格补充一句:JDK 8u20 之后存在有限的安全点批量降级,把已无竞争的重量级锁恢复为轻量级锁,但它不是实时的、也不面向普通业务代码,所以面试时先把"单向不可逆"答对,再提降级细节当加分项。
wait / notify:与 Monitor 的 WaitSet 打交道
前面第 4 站的图里,WaitSet 一直没细讲。现在来补上——wait()、notify()、notifyAll() 是 synchronized 体系里"线程间协作"的三板斧,它们全部围绕 WaitSet 工作。
先记住第一条铁律:这三个方法必须持有锁才能调用(即在 synchronized 块/方法内),否则抛 IllegalMonitorStateException。原因很直白:wait() 要"释放锁并把线程放进 WaitSet",notify() 要"操作 WaitSet 把线程移出去"——这俩动作都动 monitor 的内部结构,没有锁就没有操作资格。
完整流程走一遍:
- 线程持有锁(Owner)时调用
wait()→ 释放锁,线程进入 WaitSet,状态变为 WAITING; - 另一个线程持锁后调用
notify()→ 从 WaitSet 挑一个线程移到 EntryList(状态变 BLOCKED,重新排队等锁); notifyAll()→ 把 WaitSet 里所有线程都移到 EntryList;- 被唤醒的线程在 EntryList 抢到锁后,才回到 Owner 继续执行
wait()之后的代码。
一个可以直接复制运行的经典生产者-消费者示例:
public class WaitNotifyDemo {
private static final Object LOCK = new Object();
private static int count = 0;
private static final int CAP = 5; // 容量上限
public static void main(String[] args) {
// 生产者:满了就等,不满就生产并唤醒消费者
new Thread(() -> {
while (true) {
synchronized (LOCK) {
while (count == CAP) { // 注意是 while,不是 if
try { LOCK.wait(); } catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
System.out.println("生产,当前数量: " + (++count));
LOCK.notifyAll(); // 唤醒消费者
}
}
}, "生产者").start();
// 消费者:空了就等,不空就消费并唤醒生产者
new Thread(() -> {
while (true) {
synchronized (LOCK) {
while (count == 0) { // 注意是 while,不是 if
try { LOCK.wait(); } catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
System.out.println("消费,剩余数量: " + (--count));
LOCK.notifyAll(); // 唤醒生产者
}
}
}, "消费者").start();
}
}
因为存在虚假唤醒(spurious wakeup):线程可能在没有被 notify 的情况下被唤醒(JVM/OS 允许这样做)。如果只用 if 判断一次条件,唤醒后直接往下走,很可能条件已经不成立(比如队列又满了/又空了),导致数据错乱。用 while 循环的话,被唤醒后会重新检查条件,不成立就继续 wait——这是所有 wait/notify 代码的硬性规范。
还有两个高频对比,面试必考:
| 维度 | wait() | sleep() |
|---|---|---|
| 谁的静态方法 | Object 的实例方法 | Thread 的静态方法 |
| 是否释放锁 | 释放(进 WaitSet) | 不释放(抱着锁睡) |
| 是否需要 synchronized | 必须(否则 IllegalMonitorStateException) | 不需要 |
| 如何唤醒 | notify/notifyAll(或虚假唤醒) | 时间到自动醒(可被 interrupt) |
| 典型用途 | 线程间协作(等条件) | 定时/暂停(不涉及协作) |
notify() 只唤醒 WaitSet 里一个线程——如果唤醒的不是恰好等到了自己条件的那个,被唤醒的线程会重新 wait,甚至可能饿死;notifyAll() 全部唤醒,让它们重新竞争、各自重新检查条件,最安全。生产实践:条件简单、只有一个消费者时用 notify() 可以;条件复杂或不确定时,一律用 notifyAll(),牺牲一点"惊群"开销换正确性。
JIT 锁优化:锁消除与锁粗化
除了加锁时的锁升级,JIT 编译器还会在运行时做两类"拆锁"优化——这是面试官验证你是否真的懂 synchronized 性能的试金石。
锁消除(Lock Elision)
JIT 通过逃逸分析(Escape Analysis)判断一个对象会不会"逃逸"出当前线程/方法。如果对象是方法内的局部变量、没有被其他线程看到(不逃逸),那么对它的 synchronized 加锁就完全可以安全地抹掉:
public String joinNames(List<String> names) {
StringBuffer sb = new StringBuffer(); // sb 是局部变量,不会逃逸出方法
for (String name : names) {
sb.append(name).append(","); // append 是 synchronized 的
}
return sb.toString(); // 但 sb 没被别的线程看到 → 锁可被消除
}
这段代码里每次 append 都带锁,但因为 sb 根本不逃逸,JIT 会把这些 synchronized 全部消除——所以"StringBuffer 慢"在局部变量场景下是个伪命题。这也是为什么规范常说"局部拼串用 StringBuilder 就行"——它省的是语法层面的锁,而不是运行时优化兜底。
锁粗化(Lock Coarsening)
反过来,如果 JIT 发现同一个线程对同一把锁短时间内反复加解锁,它会把这些相邻的加解锁合并成一把大锁,减少加解锁的次数:
// 优化前:每次循环都加锁/解锁
for (int i = 0; i < 1000; i++) {
synchronized (lock) {
sum += i;
}
}
// 优化后(JIT 自动粗化):合并成一把大锁
synchronized (lock) {
for (int i = 0; i < 1000; i++) {
sum += i;
}
}
注意粗化是有代价的:持锁时间变长了,其他线程等待更久。所以 JIT 会权衡——临界区太长就不粗化。它是个"兜底优化",不该成为你随意写锁的借口。
- 锁消除、锁粗化依赖逃逸分析,JDK 8 服务端模式默认开启(
-XX:+DoEscapeAnalysis、-XX:+EliminateLocks); - 它们是运行时兜底,别依赖它救性能——该用 StringBuilder 就用 StringBuilder,该缩小临界区就缩小;
- 也别因为"JIT 会优化"就乱加锁——优化只在特定场景生效,正确性永远靠自己。
synchronized vs ReentrantLock:怎么选
这是整篇最常被面试官追着问的对比,把表格记牢:
| 特性 | synchronized | ReentrantLock |
|---|---|---|
| 实现层面 | JVM 内置(字节码指令 monitorenter/monitorexit) | JDK 层面(AQS + CAS) |
| 锁释放 | 自动释放(字节码 finally 保证) | 必须手动 unlock()(通常放 finally) |
| 可重入 | 支持(_owner + _recursions) | 支持(AQS state 计数) |
| 可中断 | 不可中断 | lockInterruptibly() 支持 |
| 超时获取 | 不支持 | tryLock(timeout, unit) 支持 |
| 公平锁 | 不支持(非公平) | new ReentrantLock(true) 支持公平 |
| 条件变量 | 一个(Object.wait/notify) | 多个(newCondition(),可建多个条件队列) |
| 锁升级 | 有(无锁→偏向→轻量→重量) | 无(state 直接计数) |
| jstack 可读性 | 能看到 synchronized 方法名 | 能看到 lock 的线程及"parking to wait for" |
| 性能(JDK 6+) | 锁升级后接近 ReentrantLock | 基于 AQS + CAS,高竞争下稳定 |
| 可读性 | 简洁,关键字语法 | 稍复杂,需要 try-finally |
什么场景必须用 ReentrantLock?四个刚需,缺一个都用不上它:
- 需要可中断地等锁:比如线程池
shutdownNow()要打断正在等锁的线程,synchronized做不到(等锁期间不可中断); - 需要超时获取:
tryLock(3, TimeUnit.SECONDS)——等 3 秒拿不到就放弃,避免无限阻塞; - 需要公平锁:防止线程饥饿,代价是吞吐下降;
- 需要多个条件队列:一个锁配多个
Condition(如"队列不满"和"队列不空"两个条件),wait/notify只有一套。
import java.util.concurrent.locks.ReentrantLock;
import java.util.concurrent.TimeUnit;
public class LockDemo {
private final ReentrantLock lock = new ReentrantLock();
public void doJob() {
try {
// 等锁过程中可以被 interrupt,也可以超时放弃
if (lock.tryLock(3, TimeUnit.SECONDS)) {
try {
// 临界区
} finally {
lock.unlock(); // 手动释放,必须 finally
}
} else {
System.out.println("3 秒没拿到锁,放弃");
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt(); // 恢复中断标志位
}
}
}
默认用 synchronized:简洁、自动释放、JVM 持续优化(锁升级),出错概率低;只有明确需要可中断 / 超时 / 公平 / 多条件队列之一时,才换 ReentrantLock。这句话说出口,面试官就知道你不是背的。
生产实践:锁对象怎么选
写 synchronized 最容易翻车的不是语法,而是选错了锁对象。三个真实生产坑,每一个都酿过线上事故。
public class LockObjectDemo {
// ❌ 错误示范 1:锁 String 字面量 —— 常量池陷阱
private static final String LOCK = "pay";
// "pay" 在字符串常量池里全局只有一份!另一个模块也写 synchronized ("pay"),
// 两把"不同的锁"其实是同一把锁 → 毫无关系的代码互相阻塞,甚至死锁。
// ❌ 错误示范 2:锁 Integer / Long 包装类型 —— 缓存陷阱
private static final Integer LOCK2 = 1;
// Integer.valueOf(1) 命中 -128~127 缓存:所有锁"1"的代码共享同一个 Integer 对象
// 你的锁可能和 JDK 内部、第三方库的锁撞在一起。
// ❌ 错误示范 3:锁的引用被重新赋值 —— 锁了个寂寞
private static Integer counter = 0;
public static void badIncr() {
synchronized (counter) { // 锁的是当前 counter 指向的对象
counter++; // 拆箱 + 加 1 + 装箱 → counter 指向了另一个对象!
} // 下一次加锁锁的是新对象 → 并发完全失效
}
// ✅ 正确示范:private final 专用锁对象
private final Object lock = new Object(); // 每个实例一把独立锁,谁也碰不到
public void pay() {
synchronized (lock) {
// 临界区
}
}
}
- String 字面量:字符串字面量会 intern 进常量池,同一个
"pay"在整个 JVM 里只有一个实例。所以"锁字面量"等于"全 JVM 共用一把锁",作用范围完全失控; - 包装类型缓存:
Integer.valueOf()对-128 ~ 127返回缓存对象,Long同理。锁一个"值"可能撞上别的代码锁同一个"值";超出缓存范围时,自动装箱又可能产生新对象,导致加锁对象不一致、锁形同虚设; - 引用被重新赋值:
synchronized (counter)锁的是"引用当前指向的对象",一旦counter++让引用指向新对象,下一次加锁就锁到了别的对象上——两个线程各锁各的,并发安全瞬间归零。这是包装类型锁里最阴的坑。
- 首选:
private final Object lock = new Object()——专用、独立、final 防重新赋值; - 别锁:String 字面量、包装类型(Integer/Long/Boolean)、this(范围太大)、Class 对象(全局唯一,等于全局锁,除非你就要全局互斥);
- 锁粒度:能锁代码块就别锁整个方法,能用细粒度锁(多个小锁对象/分段)就别用一把大锁;但多个锁同时出现时,务必保证全局一致的加锁顺序,否则就是死锁温床。
锁竞争排查实战:jstack 怎么看
回到第 1 站的事故。学了整篇,现在教你怎么用 5 分钟定位锁竞争热点。
jstack 里,线程在锁上的等待只有两种状态,一眼分清:
// 情况一:BLOCKED —— 在 EntryList 排队等锁(等"锁")
"http-nio-8080-exec-42" #42 ...
java.lang.Thread.State: BLOCKED (on object monitor)
at com.demo.order.OrderService.createOrder(OrderService.java:58)
- waiting to lock <0x00000007ff8f4d10> (a java.lang.Object)
// 情况二:WAITING —— 在 WaitSet 等条件(等"通知")
"pool-1-thread-3" #13 ...
java.lang.Thread.State: WAITING (parking)
at jdk.internal.misc.Unsafe.park(Native Method)
- parking to wait for <0x00000007ff8f4d10> (a java.lang.Object)
at java.util.concurrent.locks.LockSupport.park(...)
BLOCKED 说明它在 EntryList 等锁(第 4 站的图);WAITING (in Object.wait()) 说明它在 WaitSet 等条件(第 11 站的图)。大量 BLOCKED = 锁竞争热点,大量 WAITING 往往只是正常的等待。
排查三步走:
# 1. 抓三把线程快照(间隔 5 秒),避免误判瞬时现象
jstack <pid> > stack1.txt
sleep 5 && jstack <pid> > stack2.txt
# 2. 统计哪个锁对象被最多线程等待
grep "waiting to lock" stack1.txt | sort | uniq -c | sort -rn | head
# 3. 用锁地址反查持锁线程卡在哪
grep "locked <0x00000007ff8f4d10>" stack1.txt stack2.txt stack3.txt
找到持锁线程后,看它卡在临界区里干什么。第 1 站的订单事故,根因就是 createOrder() 的 synchronized 块里做了一次 300ms 的远程扣库存调用——锁被远程 IO 拽住,后面几百个线程全部陪葬。修复方案:
| 手段 | 做法 | 适用场景 |
|---|---|---|
| 缩小临界区 | 把远程调用、IO、耗计算移出 synchronized | 大部分锁竞争的第一步 |
| 降低锁粒度 | 分段锁、读写锁、ConcurrentHashMap | 读多写少、热点分散 |
| 无锁化 | CAS、原子类、CopyOnWrite 容器 | 单变量更新、读多写极少 |
| 红线 | 锁内不要 sleep、不要远程调用、不要等待 | 永远不要 |
把 createOrder() 里的扣库存远程调用移出临界区(先本地锁保护状态,再异步扣减),P99 从 3s 回到 80ms,几百个 BLOCKED 线程瞬间消失。记住这句话:锁竞争优化的第一原则,永远是把临界区缩到最小,而不是换更快的锁——锁再快,也扛不住你把远程 IO 锁在里面。
线上常盯三个数:BLOCKED 线程占比(锁竞争强度)、持锁线程的临界区耗时、线程池的活跃线程数。配合 APM 的"锁等待时间"埋点,锁热点基本无处遁形。
这一篇你掌握了什么
核心知识点回顾
- 三种用法:实例方法锁
this、静态方法锁Class对象、代码块锁指定对象;两个实例的实例锁互不干扰,静态锁和实例锁不互斥; - Mark Word:64 位位图,按锁状态复用存储——无锁放 hashCode(31bit)+ age(4bit),偏向锁放线程 ID(54bit),轻量级锁放 Lock Record 指针,重量级锁放 ObjectMonitor 指针;最低 2 位锁标志位 01/00/10/11;调用过 hashCode() 的对象进不了偏向锁;
- 管程 Monitor:ObjectMonitor 三组结构——Owner 持锁、EntryList 排队等锁、WaitSet 等条件;新竞争线程先入 cxq 再批量转移;
- 锁升级链路:无锁 → 偏向锁 → 轻量级锁(CAS + 自旋)→ 重量级锁(挂起线程),单向不可逆,仅安全点有有限批量降级;
- 准确版本事实:偏向锁 JDK 15(JEP 374)默认关闭并废弃、JDK 18 彻底移除,现代 JVM 从轻量级锁起步;
- 可重入:_owner + _recursions 计数,同一线程重复进入,减到 0 才释放;不可重入会自死锁;
- wait/notify:必须持有锁(否则 IllegalMonitorStateException);wait 释放锁进 WaitSet,notify 移到 EntryList;条件判断必须用 while 防虚假唤醒;notifyAll 更安全;
- JIT 锁优化:锁消除(逃逸分析,局部对象)与锁粗化(合并相邻加解锁);
- 选型:默认 synchronized;需要可中断/超时/公平/多条件队列时才用 ReentrantLock;
- 锁对象:首选
private final Object;别锁 String 字面量(常量池)和包装类型(-128~127 缓存、引用重赋值陷阱)。
一句话总结
synchronized = JVM 内置锁:对象头 Mark Word 存锁状态,无锁 → 偏向锁 → 轻量级锁 → 重量级锁单向升级。它的性能演进史就是一句话:能用 CAS 就不用自旋,能用自旋就不用挂起线程。JDK 6 之后它早已不是"重量级锁",默认优先用它;遇到锁竞争,第一原则永远是缩小临界区。
面试 30 秒总结
30 秒版答案:「synchronized 是 JVM 层面的互斥锁,锁状态存在对象头的 Mark Word 里,会经历无锁 → 偏向锁 → 轻量级锁 → 重量级锁的升级,升级单向不可逆;偏向锁 JDK 15 默认关闭、JDK 18 移除。轻量级锁靠 CAS + 自旋,重量级锁才挂起线程。JDK 6 之后它性能已接近 ReentrantLock,默认优先用 synchronized;只有需要可中断、超时、公平、多条件队列时才换 ReentrantLock。」
追问预案:① 锁的到底是谁?实例方法锁 this、静态方法锁 Class、代码块锁指定对象;② 可重入吗?可,_owner + _recursions 计数;③ EntryList 和 WaitSet 区别?一个等锁一个等条件;④ 为什么重量级锁慢?线程挂起涉及用户态↔内核态切换;⑤ 锁对象怎么选?private final Object,别锁 String 和包装类型。
Comments · 评论