Synchronized 锁升级机制与底层原理深度解析:从偏向锁到重量级锁
在 Java 并发编程(JUC)中,Synchronized 关键字的底层原理是核心知识点。从 JDK 1.6 开始,JVM 团队对 Synchronized 进行了大量优化,引入了 锁升级 机制,使其在不同竞争场景下具有更好的性能。
本文将深入 JVM 源码层面,结合 JOL (Java Object Layout) 工具,通过代码实战验证 Mark Word 的状态变化,详细解析 偏向锁、轻量级锁、重量级锁 的升级过程及底层 Monitor 原理。
一、 Java 对象头 (Mark Word) 结构解析
要理解锁升级,首先需要理解 Java 对象的内存布局。在虚拟机中,对象在堆内存中的存储布局可以分为三个部分:
- 对象头:包含 Mark Word 和 Klass Pointer。
- 实例数据:对象真正的属性数据。
- 对齐填充:为了满足 8 字节对齐的要求。
其中,Mark Word 是锁升级机制的核心。它是一个非固定的数据结构,用于存储对象自身的运行时数据,如哈希码(HashCode)、GC 分代年龄、锁状态标志、线程持有的锁、偏向线程 ID、偏向时间戳等。
1. 64 位 JVM 下的 Mark Word 布局
在 64 位虚拟机中,Mark Word 占用 8 个字节(64 bit)。为了节省空间,它会根据对象的状态复用这些 bit 位。
我们可以将 Mark Word 的状态总结为以下表格(重点关注最后 3 位):
| 锁状态 | 25 bit | 31 bit | 1 bit | 4 bit | 1 bit | 2 bit |
|---|---|---|---|---|---|---|
| 含义 | unused | hashCode | unused | 分代年龄 | 偏向锁标识 | 锁标识位 |
| 无锁 (Normal) | 0 | identity_hashcode | 0 | age | 0 | 01 |
| 偏向锁 (Biased) | ThreadID (54bit) | Epoch (2bit) | 0 | age | 1 | 01 |
| 轻量级锁 (Lightweight) | 指向栈中 Lock Record 的指针 (62bit) | 00 | ||||
| 重量级锁 (Heavyweight) | 指向互斥量 (Monitor) 的指针 (62bit) | 10 | ||||
| GC 标记 | 空 (0) | 11 |
如何判断:
- 看到最后两位是 01:可能是无锁,也可能是偏向锁。要看倒数第 3 位(偏向标识),
0是无锁,1是偏向锁。 - 看到最后两位是 00:轻量级锁。
- 看到最后两位是 10:重量级锁。
二、 JOL 工具的使用与内存布局查看
为了眼见为实,我们需要使用 JOL (Java Object Layout) 工具来打印对象的内存布局。
1. 添加 Maven 依赖
<dependency>
<groupId>org.openjdk.jol</groupId>
<artifactId>jol-core</artifactId>
<version>0.16</version>
</dependency>
2. 编写基础测试代码
我们先写一个最简单的类,看看无锁状态下的对象头长什么样。
import org.openjdk.jol.info.ClassLayout;
public class JolTest {
public static void main(String[] args) {
Object o = new Object();
// 打印对象布局
System.out.println(ClassLayout.parseInstance(o).toPrintable());
}
}
运行结果(示例,注意涉及大小端存储):
java.lang.Object object internals:
OFF SZ TYPE DESCRIPTION VALUE
0 8 (object header: mark) 0x0000000000000001 (non-biasable; age: 0)
8 4 (object header: class) 0xf80001e5
12 4 (object alignment gap)
Instance size: 16 bytes
注意 VALUE 字段的第一行(Mark Word)。由于我们的机器通常是小端存储 (Little Endian),即低位字节存储在低地址,所以在阅读十六进制时需要倒着读。
例如,如果打印出的十六进制最后两位是 01(在输出的最左边),那它实际上是 Mark Word 的最低位字节。
接下来,我们将正式进入锁升级。
三、 偏向锁 (Biased Locking) 原理与实战
“偏向锁” 的核心思想是:假设锁一直由同一个线程持有,不存在多线程竞争。
在 JDK 1.6 之前,只要加上 synchronized,JVM 就会直接向操作系统申请互斥量(Mutex),这需要从用户态切换到内核态,开销巨大。然而,HotSpot 团队经过研究发现,在大多数情况下,锁不仅不存在多线程竞争,而且总是由同一线程多次获得。
为了优化这一场景,引入了偏向锁。
1. 偏向锁原理
当一个线程第一次访问同步块并获取锁时,JVM 会在对象头的 Mark Word 中记录下该线程的 Thread ID。
- 后续进入:该线程再次进入同步块时,不需要进行 CAS 操作,只需要简单地测试一下 Mark Word 里是否存储着当前线程的 ID。
- 如果成功:直接进入。
- 如果失败:说明有其他线程尝试获取锁,偏向锁可能会撤销或升级。
2. 必须知道的 “启动延迟”
这是一个非常重要的细节!默认情况下,JVM 启动时并不会立即开启偏向锁,而是会有 4 秒钟 的延迟。这是因为 JVM 启动时会有大量内部线程竞争资源,如果一上来就开启偏向锁,会导致频繁的偏向锁撤销,反而降低性能。
- JVM 参数:
-XX:BiasedLockingStartupDelay=0(关闭延迟,立即开启)
3. 实战验证:捕捉偏向锁
为了演示偏向锁,我们需要让程序先“睡”一会儿,等 JVM 的偏向锁机制启动。
import org.openjdk.jol.info.ClassLayout;
import java.util.concurrent.TimeUnit;
public class BiasedLockTest {
public static void main(String[] args) throws InterruptedException {
// 1. 睡眠 5 秒,确保偏向锁功能已激活
TimeUnit.SECONDS.sleep(5);
Object o = new Object();
System.out.println("=== 1. 新对象创建 (匿名偏向) ===");
System.out.println(ClassLayout.parseInstance(o).toPrintable());
synchronized (o) {
System.out.println("=== 2. 加锁中 (偏向锁 - 偏向主线程) ===");
System.out.println(ClassLayout.parseInstance(o).toPrintable());
}
System.out.println("=== 3. 释放锁后 (依然是偏向锁) ===");
System.out.println(ClassLayout.parseInstance(o).toPrintable());
}
}
运行结果分析:
我们重点观察输出中 VALUE 的第一行最后几位(注意小端序,看最左边的十六进制数)。
-
新建对象时:
- 输出:
0x...05(二进制...0000 0101) - 解析:最后 3 位是
101(偏向模式),但 Thread ID 为 0。这叫做 “匿名偏向” (Anonymous Biased) 状态。JVM 预判这个对象未来会是偏向锁,但还没偏向谁。
- 输出:
-
加锁中:
- 输出:
0x...05(依然是101),但前面的位变了(填入了 Main 线程 ID)。 - 解析:此时对象正式偏向于 Main 线程。
- 输出:
-
释放锁后:
- 输出:
0x...05 - 解析:注意!偏向锁释放后,对象头并不会自动恢复成无锁状态,它依然记录着偏向线程 ID。这是为了该线程下次再来时,能直接识别,无需再次修改对象头。
- 输出:
小结:偏向锁一旦偏向,除非有其他线程来竞争,否则它会一直保持偏向状态。
四、 轻量级锁 (Lightweight Locking) 原理与实战
“轻量级锁” 的核心思想是:多线程交替执行,但不存在真正的时刻冲突。
当偏向锁被另一个线程尝试获取时,偏向模式宣告结束。JVM 会判断:是否存在实际的并发竞争?
如果仅仅是两个线程交替执行(例如线程 A 执行完,线程 B 再来),那么锁会升级为轻量级锁,而不是直接升级为重量级锁。
1. 轻量级锁原理

- Lock Record:JVM 会在当前线程的栈帧中创建一个名为 Lock Record 的空间。
- Displaced Mark Word:将对象头中的 Mark Word 复制到 Lock Record 中(为了将来解锁时还原)。
- CAS 替换:JVM 尝试使用 CAS 将对象头的 Mark Word 替换为指向 Lock Record 的指针。
-
成功:对象头最后两位变为
00,表示处于轻量级锁状态。 -
失败:说明有竞争,或者当前线程已经持有锁(重入)。
-

2. HashCode 的破坏力
有一个非常有趣的现象:一旦对象调用了 hashCode() 方法,它就无法进入偏向锁状态了。
为什么?
回头看 Mark Word 的结构:偏向锁状态下,存储 HashCode 的 31 位空间被 Thread ID 占用了!JVM 无法同时存储 HashCode 和 Thread ID。
- 如果对象计算过 HashCode,它无法偏向。
- 如果对象正处于偏向状态,此时调用 HashCode,偏向锁会被撤销,并升级为重量级锁(或强制退化)。
3. 实战验证:轻量级锁 (00)
我们用一个简单的方法强行避开偏向锁:直接计算 HashCode。
import org.openjdk.jol.info.ClassLayout;
public class LightWeightLockTest {
public static void main(String[] args) throws InterruptedException {
Object o = new Object();
// 1. 计算 HashCode,破坏偏向锁条件
o.hashCode();
System.out.println("=== 1. 计算 HashCode 后 (无锁 Normal) ===");
System.out.println(ClassLayout.parseInstance(o).toPrintable());
synchronized (o) {
System.out.println("=== 2. 加锁中 (轻量级锁) ===");
System.out.println(ClassLayout.parseInstance(o).toPrintable());
}
System.out.println("=== 3. 释放锁后 (无锁 Normal) ===");
System.out.println(ClassLayout.parseInstance(o).toPrintable());
}
}
运行结果分析:
-
计算 HashCode 后:
- 输出:
0x...01(无锁)。 - 注意:此时 Mark Word 中记录了具体的 HashCode 值。
- 输出:
-
加锁中:
- 输出:
0x...f8(二进制...1111 1000) -> 最后两位是 00。 - 解析:这就是 轻量级锁。此时 Mark Word 的内容是一个指针,指向栈中的 Lock Record。
- 输出:
-
释放锁后:
- 输出:
0x...01(恢复无锁)。 - 解析:轻量级锁释放时,会将栈里的 Displaced Mark Word 写回对象头,对象恢复原样。
- 输出:
五、 重量级锁 (Heavyweight Locking) 原理与实战
“重量级锁” 的核心思想是:战场升级,必须动用操作系统层面的互斥量(Mutex)来强制管控。
当轻量级锁的 CAS 尝试(自旋)失败达到一定次数,或者竞争过于激烈时,JVM 认为简单的 CAS 已经无法解决问题,必须让线程挂起等待。于是,锁膨胀为重量级锁。
1. 重量级锁原理
此时,Mark Word 存储的不再是简单的指针或 ID,而是一个指向 ObjectMonitor(C++ 对象)的指针。
所有竞争锁的线程,都会被操作系统挂起(Blocked),进入内核态等待唤醒。这也是为什么它叫"重量级"的原因——上下文切换开销大。
2. 实战验证:重量级锁 (10)
我们让两个线程发生真正的“碰撞”。
import org.openjdk.jol.info.ClassLayout;
public class HeavyWeightLockTest {
public static void main(String[] args) throws InterruptedException {
Object o = new Object();
// 线程 1:先占住锁不放
new Thread(() -> {
synchronized (o) {
try {
Thread.sleep(2000); // 占着茅坑不拉屎
} catch (InterruptedException e) { e.printStackTrace(); }
}
}).start();
Thread.sleep(100); // 确保线程 1 已经拿锁
// 线程 2:尝试获取锁,发生阻塞
new Thread(() -> {
synchronized (o) {
System.out.println("线程2拿到锁了");
}
}).start();
Thread.sleep(100); // 确保线程 2 已经阻塞在 synchronized 上
// 此时打印主线程看到的 o 的状态
System.out.println("=== 发生竞争 (重量级锁) ===");
System.out.println(ClassLayout.parseInstance(o).toPrintable());
}
}
运行结果分析:
- 输出:
0x...0a(二进制...0000 1010) -> 最后两位是 10。 - 解析:这就是 重量级锁。Mark Word 指向了堆外的 ObjectMonitor 对象。
六、 Monitor (管程) 原理与操作系统互斥量
当锁升级为重量级锁时,Java 对象头里的指针指向了一个 C++ 内部对象:ObjectMonitor。它才是真正管理线程排队的“管家”。
1. ObjectMonitor 核心结构
在 HotSpot 虚拟机中,Monitor 底层由 C++ 的 ObjectMonitor 实现。其核心数据结构如下:
Monitor 内部主要有三个核心区域:
-
_owner:
- 指向当前持有锁的线程。
- 当
_owner为 null 时,表示当前 Monitor 未被任何线程持有。 - 同一时刻只能有一个线程成功将
_owner设置为自己。
-
_EntryList:
- 所有争抢锁失败的线程,都会被封装成 ObjectWaiter 对象加入到此队列中。
- 这些线程处于
BLOCKED状态,被操作系统挂起。
-
_WaitSet:
- 当持有锁的线程调用了
wait()方法时,它会释放锁,进入_WaitSet队列。 - 这些线程处于
WAITING状态,直到被其他线程通过notify()或notifyAll()唤醒,才会重新进入_EntryList竞争锁。
- 当持有锁的线程调用了
2. 线程流转过程详解
-
抢锁 (monitorenter):
- 线程尝试通过 CAS 将 Monitor 的
_owner字段设置为自己。 - 成功:将
_owner指向当前线程,执行同步代码块。 - 失败:如果当前线程已经是
_owner(重入),则计数器_recursions+1;否则,封装为 ObjectWaiter 对象,进入_EntryList队列阻塞等待。
- 线程尝试通过 CAS 将 Monitor 的
-
释放锁 (monitorexit):
- 持有锁的线程执行完任务,将
_recursions-1。 - 当
_recursions减为 0 时,清空_owner,并从_EntryList或_WaitSet中唤醒一个线程(具体策略取决于 JVM 实现,如Knob_QMode参数)去争抢锁。
- 持有锁的线程执行完任务,将
-
等待 (wait):
- 持有锁的线程发现条件不满足,调用
wait()。 - 线程释放锁(清空
_owner),进入_WaitSet队列,线程状态变为WAITING。
- 持有锁的线程发现条件不满足,调用
-
唤醒 (notify):
- 持有锁的线程调用
notify()。 - 将
_WaitSet中的某个线程移动到_EntryList(或者直接唤醒),让它有机会再次参与锁竞争。
- 持有锁的线程调用
七、 锁升级过程总结与对比
1. 锁升级完整流程图
最后,我们将三种锁的特性汇总成一张表格,方便大家记忆。
| 锁类型 | 触发场景 | 竞争程度 | 优点 | 缺点 | Mark Word 标志位 |
|---|---|---|---|---|---|
| 偏向锁 | 只有一个线程访问 | 无竞争 | 加解锁无需 CAS,只有第一次需要设置 ThreadID,性能极高 | 锁撤销需要等待全局安全点 (Safepoint),有一定开销 | 101 |
| 轻量级锁 | 多线程交替执行 | 轻微竞争 (无阻塞) | 竞争的线程不会阻塞,使用自旋等待,响应速度快 | 若长时间自旋会消耗 CPU | 00 |
| 重量级锁 | 多线程同时竞争 | 激烈竞争 (阻塞) | 线程被挂起,不消耗 CPU | 涉及用户态与内核态切换,上下文切换开销大 | 10 |
写在最后
synchronized 关键字在 JDK 1.6 之后的性能已经得到了巨大的提升。它不再是单纯的“重量级锁”,而是一个会根据情况自我进化的智能锁。
- 如果是单线程,它就是个贴心的 偏向锁。
- 如果是两人协作,它就是个高效的 轻量级锁。
- 只有在真正打得不可开交时,它才会请出 重量级锁 来维持秩序。
如果这篇文章对你有帮助,请 点赞、收藏、关注 一波!你的支持是我持续输出高质量技术干货的最大动力!!!
更多推荐




所有评论(0)