跳至正文
来两杯美式
返回

一文拿下 JVM(锁篇):synchronized 锁的升级与三种锁对比

By 来两杯美式
发布于更新于

「一文拿下 JVM」正传五篇讲完之后,回头补一个老朋友——synchronized。它在终章里以「对象头是 synchronized 的关键」一句话登场,这篇把它彻底讲透:从对象头里那块叫 Mark Word 的内存开始,看一把锁是怎么一步步从「无锁」膨胀到「重量级锁」的。当年(2017)这是面试高频题,到 2026 年依然没过时——变的只是默认值。

一、Java 对象的内存布局

在 Java 中,任何一个对象都能作为锁。那线程是怎么知道一个对象被加锁了的?答案藏在对象的内存里。

一个 Java 对象在堆中由三部分组成:

部分作用
对象头(Object Header)保存运行时数据:哈希码、GC 分代年龄、锁信息
实例数据(Instance Data)字段实际值
对齐填充(Padding)保证对象大小是 8 字节的倍数

详见系列终章「对象创建的 7 个步骤」。这里聚焦对象头。

对象头里最关键的一块叫 Mark Word——锁的故事,全在这里上演。

1.1 Mark Word 的结构

64 位 JVM 下,Mark Word 占 8 字节,根据锁状态复用存储不同信息:

长度内容说明
32/64 bitMark WordhashCode、GC 分代年龄、锁信息(锁类型、锁标识位)
32/64 bitClass Metadata Address指向对象类型数据的指针
32/64 bitArray Length数组的长度(当对象为数组时才有)

举个例子:

Object lockObject = new Object();
synchronized (lockObject) {
    // ...
}

lockObject 一被创建,对象头里的「偏向锁标志位」就已经存在了——也就是说,所有对象天生都是「可偏向」的,只是创建那一刻标志位还是 0,偏向锁并未真正生效。

二、锁的升级路径总览

jdk1.6 之前,synchronized 直接就是重量级锁——一进一出都得找操作系统帮忙,性能被诟病多年。jdk1.6 引入了「锁升级」机制,让锁按需膨胀

无锁  →  偏向锁  →  轻量级锁  →  重量级锁
                                 (自旋 / 自适应自旋)

设计哲学:绝大部分锁在整个生命周期内都不会出现真实的竞争。能用最便宜的锁就别上贵的。

下面逐级展开。

三、偏向锁(Biased Locking)

当线程第一次进入同步代码块时,JVM 用 CAS 把自己的线程 ID 写入 Mark Word,同时修改「是否偏向锁」标志位:

bit fields是否偏向锁锁标志位
线程 ID + epoch101

此时一眼就能看出:哪个线程获得了这把偏向锁

3.1 偏向锁是什么

偏向锁是 jdk1.6 引入的一项锁优化,它的意思是:这把锁会偏向于第一个获得它的线程——在接下来的执行过程中,假如没有其他线程来竞争,持有偏向锁的线程将永远不需要进行同步操作。

再次进入或退出同一段同步代码块时,不再是加锁/解锁,而是 Load-and-Test

  1. 简单判断当前线程 ID 与 Mark Word 中的线程 ID 是否一致
  2. 一致 → 直接进入代码块(几乎零开销
  3. 不一致 → 检查「是否偏向锁」标志位
    • 未偏向 → CAS 竞争锁(第一次获取)
    • 已偏向他人 → 存在竞争,升级为轻量级锁(大部分情况)

设计依据:经验表明,绝大部分情况下都是同一个线程进入同一块同步代码块。为这种场景专门做优化,能省掉巨量的 CAS 与内存屏障开销。jdk1.6 中偏向锁默认开启。

3.2 锁撤销(Bias Revocation)

一旦出现竞争,偏向锁就失效了,需要撤销。撤销的开销其实挺大:

  1. 安全点(Safepoint)停止拥有锁的线程
  2. 遍历线程栈,如果存在锁记录,修复锁记录和 Mark Word,使其回到无锁状态
  3. 唤醒当前线程,升级为轻量级锁

所以,如果某段同步代码大多数情况下都有两个及以上线程竞争,偏向锁反而是累赘。这种情况可以一开始就把它关掉:-XX:-UseBiasedLocking

现代注解(2026 视角):JEP 提案里关于「禁用偏向锁」的讨论在 2017 年就开始了——理由是撤销成本高,且现代应用中 HashMap / StringBuilder 之类本就不该加锁的场景大量使用 synchronized 反而拖累。JDK 15 起,HotSpot 默认禁用偏向锁-XX:-UseBiasedLocking),并计划最终移除。当年写笔记时还是默认开启,写在这里留个时间锚点。

四、轻量级锁(Lightweight Lock)

锁撤销后会升级为轻量级锁。升级过程大致是这样:

  1. 线程在自己的栈帧中创建锁记录(Lock Record)
  2. 把锁对象 Mark Word 的内容复制到锁记录里
  3. 把锁记录的 Owner 指针指向锁对象
  4. 把锁对象的 Mark Word 替换为指向锁记录的指针

之后 Mark Word 长这样:

bit fields锁标志位
指向 Lock Record 的指针00

锁标志位 00 = 轻量级锁

轻量级锁有两种实现:自旋锁自适应自旋锁

4.1 自旋锁(jdk1.4.2 引入)

所谓自旋,是指当另一个线程来竞争锁时,这个线程在原地循环等待,而不是把自己阻塞——直到持有锁的线程释放锁,等待方就能立刻拿到。

// 伪代码
while (!tryAcquire()) {
    // 空转,消耗 CPU
}

注意:自旋时会消耗 CPU,相当于在跑一个什么都没有的 for 循环。

适用场景:同步代码块执行非常快的场景——线程原地等很短时间就能拿到锁。

不适用场景:同步代码块执行很慢,或竞争线程特别多——大量线程空循环浪费 CPU,甚至一直拿不到锁。基于这个问题,必须给自旋设一个次数上限,超过就再次膨胀,升级为重量级锁。默认 10 次,可用 -XX:PreBlockSpin 调整。

4.2 自适应自旋锁(Adaptive Spinning)

自适应自旋的「自旋次数」不再是固定的,而是根据实际情况动态变化

一句话:最近自旋成功过的,给它多一点机会;最近老是失败的,干脆别自旋了

五、重量级锁(Heavyweight Lock)

重量级锁依赖对象内部的 Monitor,Monitor 又依赖操作系统的 MutexLock(互斥锁)实现。轻量级锁膨胀为重量级锁后,Mark Word 变为:

bit fields锁标志位
指向 Monitor(Mutex)的指针10

5.1 关于 Monitor

每个对象创建后,对象头里都有一个指针,指向 JVM 中 C++ 实现的 ObjectMonitor。它内部维护了两个集合:

集合装的是谁
Wait Set(等待池)持有 Monitor 的线程调用 wait() 让出 CPU 后的去处
Entry Set(锁池)来竞争这把 Monitor 但失败的线程的去处

外加一个 owner 字段,记录当前持有锁的线程。

                 ┌─────────────── ObjectMonitor ───────────────┐
                 │                                              │
   owner ─────►  │  当前持有线程                                 │
                 │                                              │
                 │  ┌─── Entry Set ───┐  ┌─── Wait Set ────┐   │
                 │  │ 阻塞中、等锁的   │  │ wait() 后被唤醒  │   │
                 │  │ 竞争失败的线程   │  │ 重新进入竞争的线程│   │
                 │  └─────────────────┘  └─────────────────┘   │
                 └──────────────────────────────────────────────┘

5.2 为什么重量级锁开销大

当系统检查到锁是重量级锁之后,会把等待锁的线程阻塞——被阻塞的线程不消耗 CPU。但阻塞或唤醒一个线程都需要操作系统帮忙,这就需要从用户态切换到内核态,而状态切换的时间开销非常大——有可能比用户代码本身的执行时间还长

这就是为什么 jdk1.6 之前 synchronized 被叫做「重量级锁」的根源——每一次进入和退出同步块,都要走一次内核态切换。

六、四种锁的对比速查

锁级别锁标志位触发条件适用场景开销
偏向锁1 + 01第一个线程进入单线程反复进入同步块几乎为零(Load-and-Test)
轻量级锁00出现竞争、撤销偏向同步块执行很快、竞争温和CAS + 自旋(消耗 CPU)
重量级锁10自旋失败、竞争激烈同步块执行慢、多线程抢内核态阻塞/唤醒

只升不降——一旦升级为重量级锁,就不会再回退到轻量级或偏向。这是为了简化 JVM 实现的取舍。

七、三种锁的横向对比

前文讲的都是 synchronized 内部的事。但 JDK 里还有 ReentrantLock / ReentrantReadWriteLock,怎么选?当年笔记里这么记的:

7.1 synchronized

7.2 ReentrantLock

使用建议:只有当有明确证据表明 synchronized 已成为性能瓶颈,或者明确需要 ReentrantLock 独有的特性时才用它,否则推荐 synchronized。现代 JVM 的锁优化几乎已经抹平了它在性能上的劣势。

7.3 ReentrantReadWriteLock

典型适用场景:读多写少、读远远多于写的缓存、配置类共享数据。

八、三种锁速查表

维度synchronizedReentrantLockReentrantReadWriteLock
实现层次JVM 关键字JDK 类(AQS)JDK 类(AQS)
可重入
公平 / 非公平仅非公平二者可选二者可选
响应中断
超时获取
多条件变量❌(单 wait/notify✅(Condition✅(Condition
读写分离
自动释放✅(出代码块即释放)❌(必须 finally 手动 unlock同 ReentrantLock
适用场景大多数通用场景需要 ReentrantLock 独有特性读多写少

九、小结

synchronized 的设计哲学是 「按需付费」

把判断交给 JVM,写代码时大多数情况下用 synchronized 就够了。只在需要高级语义(公平、可中断、超时、多条件)时,才动用 ReentrantLock

这是「一文拿下 JVM」系列的补遗。正传五篇已经把 JVM 内存区域、参数命令、逃逸分析、OOM 排查、对象创建讲完——但锁这个话题一直没单独写。补在这里,让整条线收得更完整。

现代视角补一句(2026):到 JDK 21,synchronized 的性能已经追平甚至超过 ReentrantLock,再加上 Virtual Thread(虚拟线程)对 synchronized 的支持(JDK 24 起的 JEP 491),synchronized 在大多数场景下的优势进一步扩大。当年笔记里「synchronized 性能差」的认知,可以正式翻篇了。


分享这篇文章:
通过邮件分享这篇文章✓ 链接已复制
所属专题
JVM
第 6 / 6 篇
查看系列全部文章
  1. 01.一文拿下 JVM(上):内存区域全景
  2. 02.一文拿下 JVM(中):参数配置与常用命令
  3. 03.一文拿下 JVM(下):逃逸分析与代码优化
  4. 04.一文拿下 JVM(番外):OOM 排查与 MAT / JVisualVM 实战
  5. 05.一文拿下 JVM(终章):对象创建的 7 个步骤
  6. 06.一文拿下 JVM(锁篇):synchronized 锁的升级与三种锁对比

上一篇
GC 调优入门:参数、工具与参考值
下一篇
一文拿下 JVM(终章):对象创建的 7 个步骤