「一文拿下 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 bit | Mark Word | hashCode、GC 分代年龄、锁信息(锁类型、锁标识位) |
| 32/64 bit | Class Metadata Address | 指向对象类型数据的指针 |
| 32/64 bit | Array Length | 数组的长度(当对象为数组时才有) |
举个例子:
Object lockObject = new Object();
synchronized (lockObject) {
// ...
}
lockObject 一被创建,对象头里的「偏向锁标志位」就已经存在了——也就是说,所有对象天生都是「可偏向」的,只是创建那一刻标志位还是 0,偏向锁并未真正生效。
二、锁的升级路径总览
jdk1.6 之前,synchronized 直接就是重量级锁——一进一出都得找操作系统帮忙,性能被诟病多年。jdk1.6 引入了「锁升级」机制,让锁按需膨胀:
无锁 → 偏向锁 → 轻量级锁 → 重量级锁
(自旋 / 自适应自旋)
设计哲学:绝大部分锁在整个生命周期内都不会出现真实的竞争。能用最便宜的锁就别上贵的。
下面逐级展开。
三、偏向锁(Biased Locking)
当线程第一次进入同步代码块时,JVM 用 CAS 把自己的线程 ID 写入 Mark Word,同时修改「是否偏向锁」标志位:
| bit fields | 是否偏向锁 | 锁标志位 |
|---|---|---|
| 线程 ID + epoch | 1 | 01 |
此时一眼就能看出:哪个线程获得了这把偏向锁。
3.1 偏向锁是什么
偏向锁是 jdk1.6 引入的一项锁优化,它的意思是:这把锁会偏向于第一个获得它的线程——在接下来的执行过程中,假如没有其他线程来竞争,持有偏向锁的线程将永远不需要进行同步操作。
再次进入或退出同一段同步代码块时,不再是加锁/解锁,而是 Load-and-Test:
- 简单判断当前线程 ID 与 Mark Word 中的线程 ID 是否一致
- 一致 → 直接进入代码块(几乎零开销)
- 不一致 → 检查「是否偏向锁」标志位
- 未偏向 → CAS 竞争锁(第一次获取)
- 已偏向他人 → 存在竞争,升级为轻量级锁(大部分情况)
设计依据:经验表明,绝大部分情况下都是同一个线程进入同一块同步代码块。为这种场景专门做优化,能省掉巨量的 CAS 与内存屏障开销。jdk1.6 中偏向锁默认开启。
3.2 锁撤销(Bias Revocation)
一旦出现竞争,偏向锁就失效了,需要撤销。撤销的开销其实挺大:
- 在安全点(Safepoint)停止拥有锁的线程
- 遍历线程栈,如果存在锁记录,修复锁记录和 Mark Word,使其回到无锁状态
- 唤醒当前线程,升级为轻量级锁
所以,如果某段同步代码大多数情况下都有两个及以上线程竞争,偏向锁反而是累赘。这种情况可以一开始就把它关掉:
-XX:-UseBiasedLocking。
现代注解(2026 视角):JEP 提案里关于「禁用偏向锁」的讨论在 2017 年就开始了——理由是撤销成本高,且现代应用中
HashMap/StringBuilder之类本就不该加锁的场景大量使用synchronized反而拖累。JDK 15 起,HotSpot 默认禁用偏向锁(-XX:-UseBiasedLocking),并计划最终移除。当年写笔记时还是默认开启,写在这里留个时间锚点。
四、轻量级锁(Lightweight Lock)
锁撤销后会升级为轻量级锁。升级过程大致是这样:
- 线程在自己的栈帧中创建锁记录(Lock Record)
- 把锁对象 Mark Word 的内容复制到锁记录里
- 把锁记录的 Owner 指针指向锁对象
- 把锁对象的 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)
自适应自旋的「自旋次数」不再是固定的,而是根据实际情况动态变化:
- 假如线程 1 刚成功获得过这把锁、释放后线程 2 接手,此时线程 1 又来抢——JVM 认为线程 1 这次自旋也很可能成功,延长它的自旋次数
- 反之,如果某把锁上一个线程自旋后很少成功,那以后这个线程再来抢,可能直接跳过自旋,立刻膨胀为重量级锁,避免空转浪费
一句话:最近自旋成功过的,给它多一点机会;最近老是失败的,干脆别自旋了。
五、重量级锁(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
- 可重入锁
- 基于 JVM 实现(关键字,由
monitorenter/monitorexit字节码 + 锁升级机制保障) - 优势:写法简单、易于理解;由 JVM 保证锁释放,避免忘记释放造成的死锁;
jstack线程快照能直接显示锁定信息
7.2 ReentrantLock
- 可重入锁
- 基于 JDK 编码实现(
java.util.concurrent.locks) - 优势:可指定公平锁或非公平锁;提供
Condition分组唤醒等待线程;提供可中断的锁获取机制(lockInterruptibly);支持超时尝试(tryLock)
使用建议:只有当有明确证据表明
synchronized已成为性能瓶颈,或者明确需要ReentrantLock独有的特性时才用它,否则推荐synchronized。现代 JVM 的锁优化几乎已经抹平了它在性能上的劣势。
7.3 ReentrantReadWriteLock
- 在没有任何读写锁的时候,才能获取到写入锁
- 一般用于实现悲观读——一个线程写入时,暂停其他线程的读取
- 缺点:在读取特别多的情况下,可能造成写入线程饥饿
典型适用场景:读多写少、读远远多于写的缓存、配置类共享数据。
八、三种锁速查表
| 维度 | synchronized | ReentrantLock | ReentrantReadWriteLock |
|---|---|---|---|
| 实现层次 | 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性能差」的认知,可以正式翻篇了。