线上系统中,很多人对 synchronized 的印象或许还停留在「重量级、慢、能不用就不用」的阶段,一遇到并发问题就习惯性地掏出 ReentrantLock。但事实上,自 JDK 6 引入锁升级机制后,synchronized 在无竞争或低竞争场景下的开销已经被极大地压缩。它会优先尝试最廉价的偏向锁,只有当锁竞争真正加剧时,才会逐级攀升至重量级锁。正因如此,彻底理解这条单向升级路径,我们才能在压测调优时,精准判断何时 synchronized 已经完全够用,何时又必须切换为更细粒度的显式锁或无锁结构。
正如我们在上一篇 JMM 与可见性 中所探讨的,synchronized 不仅提供了基础的互斥能力,还借助监视器锁规则(Monitor Lock Rule)严格保证了内存可见性与有序性。本篇我们将进一步拆解它的底层实现,从对象头(Object Header)的内存布局到 Monitor 结构,再到锁状态的动态流转,最终讲透何时该坚持内置锁,何时该果断换用显式锁或 CAS 机制。
TL;DR
- 对象头与 Mark Word:
synchronized的底层锚点在于对象头中的 Mark Word,锁状态(偏向、轻量、重量)均编码在这几十个 bit 的标记位中,锁升级的本质即是 Mark Word 的状态迁移。 - 单向不可逆的升级路径:遵循「无锁 → 偏向锁 → 轻量级锁 → 重量级锁」的流转方向,一旦升级至重量级锁便不再降级(HotSpot JVM 运行时不做常规锁降级)。
- 偏向锁(Biased Locking):基于「同一个线程反复获取同一把锁」的假设,旨在省去重复的 CAS 操作;但由于撤销成本极高,在竞争型业务中常沦为负优化,JDK 15(JEP 374)起已默认关闭并标记废弃。
- 轻量级锁(Lightweight Lock):利用 CAS 比较并交换与自适应自旋,专门应对「多线程交替执行、临界区极短」的场景,有效避免线程陷入内核态的上下文切换开销。
- 重量级锁(Heavyweight Lock):依赖底层 ObjectMonitor 与操作系统的互斥量(Mutex),专治竞争激烈或临界区较长的场景;虽然线程挂起(park)会带来微秒级的内核态切换成本,但能避免 CPU 空耗。
- JIT 编译器优化:通过逃逸分析实现锁消除(证明无需同步),以及通过锁粗化合并相邻的加锁操作;因此,切忌为了所谓的「性能」而盲目将同步块拆得过细。
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 对象
}
经过 javac 编译后,同步代码块会在字节码层面对应一对 monitorenter 与 monitorexit 指令;而同步方法则会在方法的访问标志(Access Flags)上打上 ACC_SYNCHRONIZED 标记,交由 JVM 在方法进入与退出时隐式执行加解锁逻辑。需要强调的是,这两种机制殊途同归,最终都归结为获取对象关联的监视器(Monitor)。
那么,一个普通的 Java 对象究竟是如何与它的锁状态产生关联的?这就必须深入探究内存布局中的对象头。
2. 对象头与 Mark Word:锁状态的载体
在 HotSpot 虚拟机中,一个普通对象在堆内存里的布局严格划分为三块:对象头(Object Header)、实例数据(Instance Data) 以及 对齐填充(Padding)。其中,承载锁机制的核心正是对象头,它又被细分为两部分:
- Mark Word:用于存储对象自身的运行时数据,例如 hashCode、GC 分代年龄、锁标志位以及偏向线程 ID 等。
- Klass Pointer:类型指针,指向方法区中的类元数据,JVM 借此标识该对象属于哪个类。
为了在极小的空间内塞入如此多的信息,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 JVM 的常规运行过程中,锁一旦升级便不会降级(仅在 STW 的全局安全点存在极少数的批量降级场景)。接下来,我们沿着这条主线逐级剖析。
3.1 偏向锁(Biased Locking):消除无竞争下的 CAS 开销
核心假设:在绝大多数业务场景中,一把锁在其生命周期内往往只被同一个工作线程反复获取,真正的锁竞争极少发生。既然如此,何必每次进入临界区都要耗费性能去做 CAS 操作?
流转机制:当工作线程第一次获取锁时,JVM 会利用 CAS 操作将当前线程的 ID 写入对象头的 Mark Word 中,并将偏向位置为 1。此后,只要该线程再次进入同步块,只需简单比对 Mark Word 中的线程 ID 是否与自身一致。一旦匹配,线程便可长驱直入,全程零 CAS 开销。
撤销代价(Revoke):偏向锁的软肋在于撤销。一旦有第二个线程尝试抢占这把偏向锁,原有的偏向状态就必须被打破。这个撤销过程极为昂贵:JVM 必须等待全局安全点(Safepoint)来挂起持有偏向锁的线程,仔细检查其是否仍在临界区内执行,随后才能将锁状态平滑过渡到无锁或轻量级锁。
批量重偏向与批量撤销:为了缓解频繁撤销带来的性能抖动,JVM 引入了基于类维度的统计机制。当某个类的对象撤销次数超过特定阈值时,JVM 会触发批量重偏向(更新偏向 epoch),甚至直接对整个类禁用偏向锁。
工程现状:在高并发交易或秒杀等竞争型工作负载下,偏向锁的撤销成本往往远超其带来的收益,常沦为系统的负优化。正因如此,JDK 15(JEP 374)起已默认关闭偏向锁并将其标记为废弃(deprecate for removal)。在现代 JVM 的实际压测中,锁的流转路径往往是直接从「无锁」跃升至「轻量级锁」。
3.2 轻量级锁(Lightweight Lock):用户态的自旋博弈
适用场景:多个工作线程交替进入临界区,彼此之间很少发生真正的物理碰撞,且临界区内的代码执行极快。
流转机制:当线程准备进入同步块时,JVM 会在当前线程的栈帧中开辟一块名为 Lock Record(锁记录) 的空间,并将对象当前的 Mark Word 拷贝至此(即 Displaced Mark Word)。随后,线程尝试通过 CAS 操作将对象的 Mark Word 替换为指向该 Lock Record 的指针:
- CAS 成功:线程顺利拿到轻量级锁,进入临界区执行业务逻辑。
- CAS 失败:说明此时存在锁竞争。线程并不会立刻将自己挂起,而是选择自旋(Spin) 重试若干次。现代 JVM 普遍采用自适应自旋策略,会根据该锁历史上的自旋成功率动态调整重试次数。
- 自旋破防:如果自旋耗尽仍未抢到同步状态,轻量级锁将彻底膨胀为重量级锁。
核心价值:对比前一种方案,轻量级锁的最大意义在于——在没有发生严重阻塞的场景下,全程在用户态利用 CAS 与自旋化解竞争,彻底避免了线程陷入内核态的昂贵上下文切换开销。
3.3 重量级锁(Heavyweight Lock):回归内核态的终极防御
适用场景:并发度极高、锁竞争异常激烈,或者临界区内包含复杂的慢 I/O 与长耗时计算。此时盲目自旋只会白白烧毁 CPU 资源,必须引入更强硬的调度机制。
流转机制:当锁膨胀为重量级时,对象的 Mark Word 会被修改为指向一个 ObjectMonitor 结构体的指针。这个底层组件直接映射到操作系统的互斥量(Mutex),其内部精心维护了多个核心队列与状态:
_owner:指向当前成功持有锁的工作线程。_EntryList:同步队列,存放所有竞争锁失败、被迫阻塞等待的线程。_WaitSet:等待队列,存放因调用了wait()方法而主动让出锁的线程。_recursions:重入计数器,支撑了synchronized的可重入特性。
当线程抢锁失败时,会被底层 LockSupport.park(或类似机制)强制挂起,并被送入 _EntryList 排队。这一步涉及极其沉重的用户态到内核态切换,耗时通常在微秒级别,远超 CAS 操作的纳秒级开销。直到持锁线程执行完毕并调用 unlock 释放锁时,才会从 _EntryList 中唤醒后继节点重新参与竞争。
回到刚才那条加锁路径,wait()、notify() 与 notifyAll() 的本质其实就是在操作 _WaitSet:调用 wait 会让线程释放当前持有的 Monitor 并进入 _WaitSet 挂起;而 notify 则是将线程从 _WaitSet 捞出,重新扔回 _EntryList 参与锁竞争。这也解释了为什么这三个方法必须在 synchronized 块内调用,否则底层无法获取 Monitor,必然抛出 IllegalMonitorStateException。
4. 编译器优化:锁消除与锁粗化
除了 JVM 运行时的锁升级,JIT(即时编译器)在编译热点代码时,也会在幕后替我们省去大量不必要的同步开销。
4.1 锁消除(Lock Elision)
JIT 会利用逃逸分析(Escape Analysis) 技术对代码进行扫描。一旦证明某个锁对象只在当前线程的栈内流转,绝对不可能被多个线程共享(即未发生逸出),JIT 就会在编译期直接将这把锁剥离。典型案例如下:
public String concat(String a, String b) {
StringBuffer sb = new StringBuffer(); // 局部变量,生命周期被限制在方法栈内,未逸出
sb.append(a).append(b); // StringBuffer.append 本身是 synchronized 方法,但此处会被 JIT 触发锁消除
return sb.toString();
}
4.2 锁粗化(Lock Coarsening)
如果代码中存在一连串相邻的操作,它们都在反复对同一个对象进行加锁与解锁,JIT 会敏锐地察觉到这种无谓的消耗,并将其合并为一次范围更大的加锁动作,从而有效避免频繁进出临界区带来的性能损耗:
for (int i = 0; i < n; i++) {
synchronized (lock) { list.add(i); } // 每次循环都在重复加解锁,开销极大
}
// 经过 JIT 锁粗化优化后,逻辑近似于:
synchronized (lock) {
for (int i = 0; i < n; i++) { list.add(i); }
}
实战推论:在日常编码中,千万不要为了追求所谓的「极致性能」而刻意将同步块拆解得过于细碎。JIT 的锁粗化机制往往比你手写的逻辑更聪明,过细的同步块反而会徒增加解锁的频率,拖垮系统吞吐量。
5. synchronized vs 显式锁:什么时候该换
经过多年的深度优化,synchronized 在绝大多数常规场景下已经完全够用,且具备自动释放锁、天然可重入等优势,极大地降低了死锁风险。但面对复杂的高并发架构,它依然存在无法逾越的硬性限制:
| 核心能力 | synchronized | ReentrantLock(详见下一篇) |
|---|---|---|
| 自动释放锁 | ✅ 出临界区自动释放 | ❌ 必须在 finally 块中手动 unlock |
| 可重入性 | ✅ | ✅ |
| 可中断获取 | ❌ 只能死等 | ✅ 支持 lockInterruptibly() |
| 非阻塞尝试 / 超时机制 | ❌ | ✅ 支持 tryLock() 与 tryLock(timeout) |
| 公平锁机制 | ❌ 仅支持非公平抢占 | ✅ 可配置为公平锁 |
| 多条件队列 | ❌ 仅绑定一个 WaitSet | ✅ 支持绑定多个 Condition 队列 |
| 读写分离 | ❌ | ✅ 可替换为 ReentrantReadWriteLock |
切换至显式锁的强烈信号:当你的业务场景需要超时控制或可中断获取(以防止线程饥饿等待)、需要利用 tryLock 进行无阻塞的状态快照探测、需要严格的公平锁排队机制、或者需要多个条件队列与读写分离时。这些痛点,正是我们下一篇要剖析的 AQS 与 Lock 家族 诞生的核心驱动力。
切换至无锁架构的信号:如果临界区内的逻辑仅仅是对某个计数器或引用状态的更新,请果断采用 AtomicInteger 或 AtomicReference。利用底层的 CAS 比较并交换机制,其性能远胜于任何级别的锁;当并发冲突极高时,还可以进一步引入 LongAdder 等分段累加组件来打平竞争。
6. 常见陷阱与排障指南
- 锁错对象引用:写出
synchronized (new Object())或者锁定一个随时会被重新赋值的字段引用,等同于在裸奔。锁对象必须是final修饰的、在内存中绝对稳定的实例。 - 误锁 String 常量或 Integer 缓存:使用
synchronized ("lock")极易与其他毫无关联的第三方代码撞上同一个常量池对象,从而引发隐蔽的饥饿等待甚至死锁。 - 在同步块中执行慢 I/O:临界区耗时越长,锁状态越容易迅速膨胀为重量级锁,进而成倍放大系统的阻塞范围。务必将数据库查询、RPC 调用等慢 I/O 动作剥离出临界区。
- 对 synchronized 抱有性能偏见:在低冲突场景下,得益于轻量级锁与自旋机制,它的开销极低。切忌为了「避免重量级」而过早引入复杂的显式锁轮子,一切以真实压测数据为准。
- 混淆 synchronized 与 volatile 的边界:只要业务逻辑涉及「读-改-写」的复合操作,就绝对不能指望
volatile来保证原子性,必须老老实实使用synchronized或原子类(详见上一篇第 6 节的分析)。
高并发调优口诀:锁对象求稳定;临界区求短小;慢 I/O 必剥离;读改写防并发;多条件换 AQS。
需要超时、可中断、公平排队、多条件流转或读写分离时,就该果断从 synchronized 切换到基于 AQS 的显式锁体系,这也是我们下一篇 AQS 与 Lock 家族 要探讨的核心。
延伸阅读
- 站内. AQS 与 Lock 家族:
ReentrantLock、读写锁与Condition。 - 站内. JMM 与可见性:监视器锁规则与 happens-before。
- Oracle. HotSpot Runtime Overview: Synchronization:官方关于锁机制的底层架构文档。
- OpenJDK. JEP 374: Deprecate and Disable Biased Locking:关于废弃偏向锁的官方提案。
- Brian Goetz et al. Java Concurrency in Practice:Ch. 13–14,显式锁与自定义同步工具。
- OpenJDK. JOL(Java Object Layout)工具文档:用于观测对象头内存布局的官方工具。