很多人对 synchronized 的印象还停留在”重量级、慢、能不用就不用”。但从 JDK 6 引入锁升级机制后,synchronized 在无竞争或低竞争下的开销已经非常小——它会先尝试最廉价的偏向锁,竞争加剧才逐级升级到重量级锁。理解这条升级路径,才能判断什么时候 synchronized 完全够用、什么时候该换成 ReentrantLock 或无锁结构。
从 JMM 与可见性 可知:synchronized 不仅提供互斥,还借监视器锁规则保证可见性与有序性。这一篇拆开它的底层——对象头、Monitor、锁升级——以及何时该换成显式锁或 CAS。
TL;DR
- synchronized 的锚点是对象头里的 Mark Word:锁状态(偏向/轻量/重量)就编码在这几十个 bit 里,锁升级本质是 Mark Word 的状态迁移。
- 升级路径单向不可逆:无锁 →(偏向锁)→ 轻量级锁 → 重量级锁。一旦升到重量级就不再降级(JVM 不做锁降级)。
- 偏向锁赌「同一个线程反复进同一把锁」:省掉重复 CAS。撤销成本高,竞争一多常是负优化——JDK 15(JEP 374)起默认关闭并标记废弃,后续版本以移除为目标。
- 轻量级锁用 CAS + 自旋应对”交替执行、短临界区”:线程在栈里放 Lock Record,CAS 抢 Mark Word,抢不到就自旋,避免陷入内核态。
- 重量级锁基于 ObjectMonitor 与操作系统互斥量:竞争激烈、临界区长时才用,线程 park 进内核,切换成本高但正确且不空耗 CPU。
- 编译器还会做锁消除(逃逸分析证明无需同步)与锁粗化(合并相邻加锁),所以别为了”性能”手动拆细每一个同步块。
Table of contents
Open Table of contents
1. synchronized 的三种用法与本质
synchronized 有三种写法,锁的都是某个对象:
public synchronized void m() { } // 锁 this(实例方法)
public static synchronized void s() { } // 锁 Class 对象(静态方法)
public void b() {
synchronized (lock) { /* 临界区 */ } // 锁显式指定的 lock 对象
}
编译后,同步块对应一对 monitorenter / monitorexit 字节码;同步方法则在方法的访问标志上打 ACC_SYNCHRONIZED,由 JVM 在进入/退出时隐式加解锁。两者最终都归结为获取对象关联的监视器(Monitor)。
而对象与它的锁状态怎么关联?答案在对象头。
2. 对象头与 Mark Word
HotSpot 中一个普通对象在堆里的内存布局分三块:对象头(Object Header)、实例数据、对齐填充。对象头又含两部分:
- Mark Word:存运行时数据——hashCode、GC 分代年龄、锁标志位、偏向线程 ID 等。
- Klass Pointer:指向类元数据,标识对象类型。
Mark Word 是复用的:它在不同锁状态下存不同内容。64 位 JVM 下的布局(简化):
锁状态 | Mark Word (64-bit) 主要内容 | 标志位
-------------|---------------------------------------------------|------
无锁 | unused:25 | hashCode:31 | unused:1 | age:4 | 0 | 01
偏向锁 | threadID:54 | epoch:2 | unused:1 | age:4 | 1 | 01
轻量级锁 | 指向栈中 Lock Record 的指针:62 | 00
重量级锁 | 指向 ObjectMonitor 的指针:62 | 10
GC 标记 | (空) | 11
关键点:锁标志位 + 是否偏向位,共同决定当前处于哪种锁状态。加锁/升级的过程,就是通过 CAS 修改这几个 bit 与对应字段。(想看对象头实际布局,可用 OpenJDK 的 JOL 工具 ClassLayout.parseInstance(obj).toPrintable()。)
3. 锁升级:一条单向的路
JDK 6 之后,synchronized 按竞争程度逐级升级,尽量把开销压到最低:
升级单向不可逆:HotSpot 不做运行时锁降级(仅在 STW 的安全点有极有限的批量降级场景)。下面逐级看。
3.1 偏向锁(Biased Locking)
假设:绝大多数锁在其生命周期里只被同一个线程反复获取,从无竞争。既然如此,何必每次都做 CAS?
机制:第一次加锁时,用 CAS 把当前线程 ID 写进 Mark Word。之后该线程再进入,只需检查 Mark Word 里的线程 ID 是不是自己——是就直接进,连 CAS 都省了。
撤销(Revoke):一旦有第二个线程尝试获取这把偏向锁,偏向就得撤销。撤销必须在全局安全点(safepoint) 暂停持有偏向的线程,检查它是否还在临界区,再把锁转成无锁或轻量级。撤销开销很大。
批量重偏向 / 批量撤销:JVM 以类为单位统计撤销次数,超过阈值就批量重偏向(换偏向 epoch)甚至对整个类禁用偏向,避免逐个撤销的抖动。
现状:偏向锁在竞争型工作负载下经常是负优化,且维护成本高。JDK 15(JEP 374)起默认关闭偏向锁并标记为废弃(deprecate for removal);新版本 JVM 上实际路径往往是「无锁 → 轻量级 → 重量级」。
3.2 轻量级锁(Lightweight Lock)
适用:多个线程交替进入临界区,实际很少真正碰撞,且临界区很短。
机制:线程进入同步块时,在自己的栈帧里创建 Lock Record,把对象当前 Mark Word 拷贝进去(Displaced Mark Word),再用 CAS 把对象 Mark Word 改成指向这个 Lock Record:
- CAS 成功 → 拿到轻量级锁,进入临界区。
- CAS 失败 → 有竞争。线程不立刻挂起,而是自旋(spin) 重试若干次(自适应自旋:JVM 根据历史成功率动态决定次数)。
- 自旋仍拿不到 → 升级为重量级锁。
轻量级锁的价值:在没有真正阻塞的场景下,全程在用户态用 CAS 解决,不陷入内核。
3.3 重量级锁(Heavyweight Lock)
适用:竞争激烈、或临界区较长,自旋只会白白烧 CPU。
机制:Mark Word 指向一个 ObjectMonitor 结构,它内部维护:
_owner:当前持有锁的线程。_EntryList:竞争锁失败、被阻塞的线程队列。_WaitSet:调用了wait()而等待的线程队列。_recursions:重入计数(synchronized 可重入)。
抢不到锁的线程通过 park() 挂起,进入 _EntryList——涉及用户态到内核态切换,成本在微秒级,远高于 CAS。持锁线程 unlock 时从 _EntryList 唤醒后继者。
wait() / notify() / notifyAll() 操作 _WaitSet:wait 释放锁并进 _WaitSet,notify 把线程移回 _EntryList 重新竞争锁。这三个方法必须在 synchronized 块内调用,否则抛 IllegalMonitorStateException。
4. 编译器优化:锁消除与锁粗化
JIT 还会替你省掉不必要的同步。
4.1 锁消除(Lock Elision)
通过逃逸分析证明某个锁对象不可能被多个线程共享,就直接把锁去掉。典型例子:StringBuffer 的 append 是同步方法,但如果它是方法内的局部变量、没有逸出,JIT 会消除这些锁:
public String concat(String a, String b) {
StringBuffer sb = new StringBuffer(); // 局部变量,不逸出
sb.append(a).append(b); // append 是 synchronized,但会被锁消除
return sb.toString();
}
4.2 锁粗化(Lock Coarsening)
如果一连串相邻操作反复对同一个对象加解锁,JIT 会把它们合并成一次更大的加锁,避免频繁进出临界区:
for (int i = 0; i < n; i++) {
synchronized (lock) { list.add(i); } // 每次循环都加解锁
}
// 锁粗化后近似于:
synchronized (lock) {
for (int i = 0; i < n; i++) { list.add(i); }
}
推论:不必为了”性能”手动把同步块拆到极细——JIT 的锁粗化可能比你手写的更好,而过细的同步块反而增加加解锁次数。
5. synchronized vs 显式锁:什么时候该换
synchronized 在多数场景已经够用且不易写错(自动释放、可重入、JVM 深度优化)。但它也有硬限制:
| 能力 | synchronized | ReentrantLock(见下一篇) |
|---|---|---|
| 自动释放(出块即解锁) | ✅ | ❌ 必须 finally 手动 unlock |
| 可重入 | ✅ | ✅ |
| 可中断地获取锁 | ❌ | ✅ lockInterruptibly() |
| 尝试获取 / 带超时 | ❌ | ✅ tryLock() / tryLock(timeout) |
| 公平锁 | ❌(非公平) | ✅ 可选公平 |
| 多个条件变量 | ❌ 只有一个 wait set | ✅ 多个 Condition |
| 读写分离 | ❌ | ✅ ReentrantReadWriteLock |
换成显式锁的信号:需要超时/可中断获取(避免死等)、需要 tryLock 做无阻塞探测、需要公平性、需要多个条件队列、需要读写分离。这些正是 AQS 与 Lock 家族要解决的问题。
换成无锁的信号:临界区只是一个计数或一个引用的更新——直接用 AtomicInteger/AtomicReference(CAS),比任何锁都轻。竞争极高时再考虑 LongAdder 之类的分段累加。
6. 常见陷阱
- 锁错对象:
synchronized (new Object())或锁一个会被替换的字段引用,等于没锁。锁对象应是final的、稳定的。 - 锁 String 常量 / Integer 缓存:
synchronized ("lock")可能与其他无关代码撞上同一个常量池对象,造成隐性耦合。 - 在 synchronized 里做慢 I/O:临界区越长,越容易升级到重量级锁并放大阻塞。把 I/O 挪出临界区。
- 误以为 synchronized 只有重量级:低竞争下它很轻。别为了”避免重量级”过早换轮子,先压测。
- synchronized 与 volatile 的取舍:只要”读改写”就别指望 volatile,
synchronized或原子类才对(见上一篇第 6 节)。
需要超时、可中断、公平、多条件或读写分离时,就该从 synchronized 换到基于 AQS 的显式锁。
延伸阅读
- AQS 与 Lock 家族:
ReentrantLock、读写锁与Condition - JMM 与可见性:监视器锁规则与 happens-before
- Oracle. HotSpot Runtime Overview: Synchronization
- JEP 374: Deprecate and Disable Biased Locking
- Brian Goetz et al. Java Concurrency in Practice, Ch. 13–14
- OpenJDK JOL(Java Object Layout)工具文档