Skip to content
Charles Shao
Go back

synchronized 与锁升级:对象头、Monitor 与偏向、轻量、重量级锁

–views

线上系统中,很多人对 synchronized 的印象或许还停留在「重量级、慢、能不用就不用」的阶段,一遇到并发问题就习惯性地掏出 ReentrantLock。但事实上,自 JDK 6 引入锁升级机制后,synchronized 在无竞争或低竞争场景下的开销已经被极大地压缩。它会优先尝试最廉价的偏向锁,只有当锁竞争真正加剧时,才会逐级攀升至重量级锁。正因如此,彻底理解这条单向升级路径,我们才能在压测调优时,精准判断何时 synchronized 已经完全够用,何时又必须切换为更细粒度的显式锁或无锁结构。

正如我们在上一篇 JMM 与可见性 中所探讨的,synchronized 不仅提供了基础的互斥能力,还借助监视器锁规则(Monitor Lock Rule)严格保证了内存可见性与有序性。本篇我们将进一步拆解它的底层实现,从对象头(Object Header)的内存布局到 Monitor 结构,再到锁状态的动态流转,最终讲透何时该坚持内置锁,何时该果断换用显式锁或 CAS 机制。

TL;DR

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 被设计成一个非固定的动态数据结构,它会根据对象当前的锁状态复用内部的存储空间。在 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 不再是无脑挂起线程的代名词。它会根据实际的锁竞争激烈程度逐级攀升,尽最大可能将同步开销压到最低:

synchronized 锁升级路径。从左到右四个状态:无锁 → 偏向锁(同线程反复进)→ 轻量级锁(CAS+自旋)→ 重量级锁(ObjectMonitor);箭头标注首线程进入、他线程竞争/撤销、自旋失败。旁注 JDK 15+ 默认关闭偏向锁,常从无锁直入轻量级。底部说明升级单向、无竞争留用户态、竞争加剧才入内核。

这条升级路径是单向不可逆的:在 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 的指针:

轻量级锁示意图。左侧「线程栈帧」中有 Lock Record(含 Displaced Mark Word 副本);右侧「堆对象」Mark Word 指向该 Lock Record(标志位 00),下方为 Klass Pointer;中间箭头标注 CAS 抢占。底部说明:CAS 成功持锁,失败则自适应自旋,仍失败升级重量级。

核心价值:对比前一种方案,轻量级锁的最大意义在于——在没有发生严重阻塞的场景下,全程在用户态利用 CAS 与自旋化解竞争,彻底避免了线程陷入内核态的昂贵上下文切换开销。

3.3 重量级锁(Heavyweight Lock):回归内核态的终极防御

适用场景:并发度极高、锁竞争异常激烈,或者临界区内包含复杂的慢 I/O 与长耗时计算。此时盲目自旋只会白白烧毁 CPU 资源,必须引入更强硬的调度机制。

流转机制:当锁膨胀为重量级时,对象的 Mark Word 会被修改为指向一个 ObjectMonitor 结构体的指针。这个底层组件直接映射到操作系统的互斥量(Mutex),其内部精心维护了多个核心队列与状态:

当线程抢锁失败时,会被底层 LockSupport.park(或类似机制)强制挂起,并被送入 _EntryList 排队。这一步涉及极其沉重的用户态到内核态切换,耗时通常在微秒级别,远超 CAS 操作的纳秒级开销。直到持锁线程执行完毕并调用 unlock 释放锁时,才会从 _EntryList 中唤醒后继节点重新参与竞争。

ObjectMonitor 结构示意图。四个芯片:_owner(持锁线程)、_recursions(重入计数)、_EntryList(阻塞等锁)、_WaitSet(wait 等待)。底部说明 park 进入 EntryList 的内核态成本,以及 wait/notify 必须在 synchronized 内调用。

回到刚才那条加锁路径,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 在绝大多数常规场景下已经完全够用,且具备自动释放锁、天然可重入等优势,极大地降低了死锁风险。但面对复杂的高并发架构,它依然存在无法逾越的硬性限制:

核心能力synchronizedReentrantLock(详见下一篇)
自动释放锁✅ 出临界区自动释放❌ 必须在 finally 块中手动 unlock
可重入性✅✅
可中断获取❌ 只能死等✅ 支持 lockInterruptibly()
非阻塞尝试 / 超时机制❌✅ 支持 tryLock() 与 tryLock(timeout)
公平锁机制❌ 仅支持非公平抢占✅ 可配置为公平锁
多条件队列❌ 仅绑定一个 WaitSet✅ 支持绑定多个 Condition 队列
读写分离❌✅ 可替换为 ReentrantReadWriteLock

切换至显式锁的强烈信号:当你的业务场景需要超时控制或可中断获取(以防止线程饥饿等待)、需要利用 tryLock 进行无阻塞的状态快照探测、需要严格的公平锁排队机制、或者需要多个条件队列与读写分离时。这些痛点,正是我们下一篇要剖析的 AQS 与 Lock 家族 诞生的核心驱动力。

切换至无锁架构的信号:如果临界区内的逻辑仅仅是对某个计数器或引用状态的更新,请果断采用 AtomicInteger 或 AtomicReference。利用底层的 CAS 比较并交换机制,其性能远胜于任何级别的锁;当并发冲突极高时,还可以进一步引入 LongAdder 等分段累加组件来打平竞争。

6. 常见陷阱与排障指南

高并发调优口诀:锁对象求稳定;临界区求短小;慢 I/O 必剥离;读改写防并发;多条件换 AQS。

需要超时、可中断、公平排队、多条件流转或读写分离时,就该果断从 synchronized 切换到基于 AQS 的显式锁体系,这也是我们下一篇 AQS 与 Lock 家族 要探讨的核心。

延伸阅读


–views
Share this post on:

Previous Post
AQS 与 Lock 家族:ReentrantLock、读写锁与 Condition
Next Post
JMM 与可见性:volatile、happens-before 规则与指令重排序