synchronized 锁升级原理:从 JDK 8 实现到 JDK 25 演进
1. 引言
1.1 什么是synchronized
synchronized 是 Java 提供的一种内置的同步机制,用于解决多线程并发访问共享资源时的线程安全问题。在 JDK 1.6 之前,synchronized 是一个"重量级"锁,其性能较差,因为它直接依赖操作系统的互斥量(Mutex)来实现,涉及用户态和内核态的切换。
1.2 为什么需要锁升级
为了提升 synchronized 的性能,JDK 1.6 引入了锁升级机制(也称为锁膨胀机制)。锁升级的核心思想是:根据竞争情况动态调整锁的实现策略,从轻量级到重量级逐步升级。
这种设计基于以下观察:
- 大多数情况下,锁不存在多线程竞争,总是由同一个线程多次获得
- 即使有竞争,竞争也往往是短暂的,线程自旋等待即可获得锁
通过锁升级机制,JVM 可以在不同的竞争场景下使用最合适的同步策略,从而显著提升性能。
2. Java对象内存布局
2.1 对象的内存结构
在 HotSpot 虚拟机中,每个 Java 对象在内存中的布局分为三个部分:
| 组成部分 | 说明 | 大小 |
|---|---|---|
| 对象头(Object Header) | 存储对象自身的运行时数据,包括 Mark Word 和类型指针 | 8/12/16 字节(取决于是否开启指针压缩) |
| 实例数据(Instance Data) | 对象真正存储的有效信息,即各个字段的内容 | 不固定 |
| 对齐填充(Padding) | 占位符,保证对象大小是 8 字节的整数倍 | 0-7 字节 |
2.2 对象头结构
对象头是实现 synchronized 锁的关键,它包含两部分:
- Mark Word(标记字):存储对象自身的运行时数据
- Klass Pointer(类型指针):指向对象的类元数据
对于数组对象,还会包含一个4字节的数组长度信息。
2.2.1 Mark Word 详解
Mark Word 是实现锁升级的核心数据结构。在 64 位 JVM 中,Mark Word 占 64 位(8 字节),在 32 位 JVM 中占 32 位(4 字节)。
32 位 JVM 的 Mark Word 结构:
| 锁状态 | 25 bit | 4 bit | 1 bit (是否偏向锁) | 2 bit (锁标志位) |
|---|---|---|---|---|
| 无锁 | 对象的 hashCode | 分代年龄 | 0 | 01 |
| 偏向锁 | 线程 ID (23 bit) + Epoch (2 bit) | 分代年龄 | 1 | 01 |
| 轻量级锁 | 指向栈中锁记录的指针 | 00 | ||
| 重量级锁 | 指向互斥量(重量级锁)的指针 | 10 | ||
| GC 标记 | 空 | 11 |
64 位 JVM 的 Mark Word 结构:
| 锁状态 | 56 bit | 1 bit | 4 bit | 1 bit (是否偏向锁) | 2 bit (锁标志位) |
|---|---|---|---|---|---|
| 无锁 | unused (25 bit) + hashCode (31 bit) | unused | 分代年龄 | 0 | 01 |
| 偏向锁 | 线程 ID (54 bit) + Epoch (2 bit) | unused | 分代年龄 | 1 | 01 |
| 轻量级锁 | 指向栈中锁记录的指针 (62 bit) | 00 | |||
| 重量级锁 | 指向 ObjectMonitor 的指针 (62 bit) | 10 | |||
| GC 标记 | 空 | 11 |
锁标志位说明:
| 锁标志位 | 偏向锁位 | 锁状态 |
|---|---|---|
| 01 | 0 | 无锁状态(Normal) |
| 01 | 1 | 偏向锁状态(Biased) |
| 00 | - | 轻量级锁状态(Lightweight Locked) |
| 10 | - | 重量级锁状态(Heavyweight Locked) |
| 11 | - | GC 标记状态 |
注意:指针压缩的影响
以上 Mark Word 布局假设未启用指针压缩。在 64 位 JVM 中,默认启用指针压缩(
-XX:+UseCompressedOops),此时对象头的 Klass Pointer 会从 8 字节压缩为 4 字节。这会影响对象整体大小,但 Mark Word 本身的布局保持不变。使用 JOL 工具验证时,需注意指针压缩对对象布局的实际影响。
3. 锁的四种状态
synchronized 锁一共有四种状态:无锁、偏向锁、轻量级锁、重量级锁。
3.1 锁状态对比表
| 锁状态 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| 无锁 | 无同步开销 | 无法保证线程安全 | 单线程环境 |
| 偏向锁 | 加锁和解锁不需要额外的消耗,和执行非同步方法相比仅存在纳秒级的差距 | 如果线程间存在锁竞争,会带来额外的锁撤销的消耗 | 适用于只有一个线程访问同步块的场景 |
| 轻量级锁 | 竞争的线程不会阻塞,提高了程序的响应速度 | 如果始终得不到锁竞争的线程,使用自旋会消耗 CPU | 追求响应时间,同步块执行速度非常快,线程交替执行同步块 |
| 重量级锁 | 线程竞争不使用自旋,不会消耗 CPU | 线程阻塞,响应时间缓慢 | 追求吞吐量,同步块执行时间较长 |
3.2 锁的演进方向
重要特性:锁可以升级但不能降级
无锁 → 偏向锁 → 轻量级锁 → 重量级锁
↑ ↑ ↑ ↑
(01,0) (01,1) (00) (10)
这种"只能升级不能降级"的策略是为了提高获得锁和释放锁的效率。
JDK 版本演进说明
自 JDK 15 起(JEP 374),偏向锁默认禁用,原因包括:维护成本高、现代应用多为高并发场景导致收益有限、与虚拟线程(JDK 21)存在兼容性问题。JDK 18+ 中相关 JVM 参数已标记为"obsolete"(过时),但截至 JDK 25,偏向锁代码仍保留在 JVM 中以兼容旧应用(尚未完全移除)。详见 Section 9.3。
4. 锁升级的完整流程
4.1 锁升级流程图
4.2 详细升级过程
4.2.1 无锁 → 偏向锁
- 检查对象头:线程访问同步块时,检查 Mark Word 的锁标志位和偏向锁标志
- 判断是否可偏向:
- 如果是可偏向状态(偏向锁标志为 1)且 ThreadID 为空
- 使用 CAS 操作尝试将当前线程 ID 写入 Mark Word
- 获取偏向锁成功:CAS 成功后,以后该线程进入同步块时只需简单测试 Mark Word 中是否存储着指向当前线程的偏向锁
4.2.2 偏向锁 → 轻量级锁
当另一个线程尝试获取已被偏向的锁时:
- 检测到竞争:发现 Mark Word 中的线程 ID 不是自己
- 暂停偏向线程:到达全局安全点(Safepoint),暂停持有偏向锁的线程
- 检查偏向线程状态:
- 如果线程已经退出同步块或不再存活:直接撤销偏向锁,变为无锁状态
- 如果线程仍在同步块中:将偏向锁升级为轻量级锁
- 恢复线程执行
4.2.3 轻量级锁 → 重量级锁
当自旋达到一定次数仍未获得锁时:
- 自旋失败:线程自旋等待锁的次数超过阈值(JDK 6+ 默认启用自适应自旋,由 JVM 动态调整)
- 膨胀为重量级锁:将轻量级锁膨胀为重量级锁
- 线程阻塞:未获得锁的线程进入阻塞状态,等待被唤醒
注意:早期版本可通过
-XX:PreBlockSpin参数设置固定自旋次数(默认 10 次),但该参数在现代 JDK 中已被自适应自旋取代,不再生效。
5. 偏向锁的实现原理
5.1 偏向锁的设计理念
偏向锁的核心思想:锁不仅不存在多线程竞争,而且总是由同一线程多次获得。其核心流程如下:
- 初始状态(可偏向):当一个对象被创建后,如果它的类被计算为可偏向(默认大部分都是),并且JVM的偏向锁延迟已过(默认4秒后),那么该对象的Mark Word会处于 “匿名偏向” 状态。此时,它的锁标志位是“101”(偏向模式),但线程ID部分为0,表示尚未偏向于任何特定线程。
- 首次加锁(CAS烙印):当第一个线程T1来尝试获取这个对象的锁时,它会执行一个CAS原子操作,试图将当前线程的ID(以及一些其他信息如epoch)写入到对象头的Mark Word中。如果这个CAS成功,那么锁就**“偏向”** 于线程T1了。这是整个过程中唯一一次可能的竞争点。
- 同一线程重入(偏向生效):在此之后,只要线程T1再次进入这个同步块,它只需要做两步极快的操作:
- 检查:查看对象头中的线程ID是否就是自己。
- 验证:如果相等,则直接认为持有锁,可以执行同步代码。 这个过程完全不涉及任何原子指令(如CAS)或操作系统调用,性能开销极低,可以理解为基本无消耗。这就是“偏向”带来的巨大收益。
- 其他线程竞争(撤销偏向):如果另一个线程T2也来尝试获取这个锁,它会发现对象头中的线程ID不是自己。这时,偏向锁就需要“撤销”。撤销过程是安全点中一个相对昂贵的操作,需要等待原持有者T1到达安全点,然后根据情况,要么升级为轻量级锁(T1已释放锁),要么升级为重量级锁(T1仍持有锁)。
5.2 偏向锁的获取流程
5.3 偏向锁的撤销
偏向锁的撤销是一个相对昂贵的操作,需要等待全局安全点(Safepoint)。
撤销流程:
- 到达安全点:暂停持有偏向锁的线程
- 检查线程状态:
- 线程未活动或已退出同步块:直接将 Mark Word 改为无锁状态(01,0)
- 线程仍在同步块中:将偏向锁升级为轻量级锁
- 恢复线程执行
批量重偏向和批量撤销:
为了优化偏向锁撤销的性能,JVM 引入了批量重偏向和批量撤销机制:
| 机制 | 触发条件 | 处理方式 |
|---|---|---|
| 批量重偏向 | 一个类的对象被多个线程访问,但不存在竞争 | 将该类的偏向锁指向新的线程 |
| 批量撤销 | 某个类的撤销次数达到阈值(默认 40) | 将该类的所有对象都改为不可偏向 |
5.4 相关 JVM 参数
| 参数 | 说明 | 默认值 | 状态 |
|---|---|---|---|
-XX:+UseBiasedLocking | 启用偏向锁 | JDK 6-14 默认启用,JDK 15+ 默认禁用 | JDK 18+ obsolete |
-XX:BiasedLockingStartupDelay | 偏向锁延迟启动时间(毫秒) | 4000ms(见下文说明) | JDK 18+ obsolete |
-XX:BiasedLockingBulkRebiasThreshold | 批量重偏向阈值 | 20 | JDK 18+ obsolete |
-XX:BiasedLockingBulkRevokeThreshold | 批量撤销阈值 | 40 | JDK 18+ obsolete |
JDK 18+ 参数状态说明
自 JDK 18 起,上述偏向锁相关参数已标记为 obsolete(过时),在命令行使用时会输出警告信息并被忽略。这是 JEP 374 废弃偏向锁计划的延续。
为什么偏向锁要延迟 4 秒启动?
JVM 在启动时会创建大量内部对象和线程(如 Finalizer 线程、Reference Handler 等),这些初始化操作会涉及很多同步操作。如果此时就启用偏向锁,反而会因为频繁的偏向锁撤销而降低性能。因此 JVM 默认延迟 4 秒后才启用偏向锁,此时应用程序已经完成初始化,可以正常享受偏向锁带来的性能提升。
5.5 使用 JOL 验证锁状态
可以使用 JOL(Java Object Layout)工具观察对象头的变化:
<!-- Maven 依赖 -->
<dependency>
<groupId>org.openjdk.jol</groupId>
<artifactId>jol-core</artifactId>
<version>0.16</version>
</dependency>
import org.openjdk.jol.info.ClassLayout;
public class LockStateDemo {
public static void main(String[] args) throws InterruptedException {
// 等待偏向锁延迟启动(或使用 -XX:BiasedLockingStartupDelay=0)
Thread.sleep(5000);
Object lock = new Object();
// 1. 查看初始状态(可偏向/匿名偏向)
System.out.println("===== 初始状态 =====");
System.out.println(ClassLayout.parseInstance(lock).toPrintable());
// 2. 加锁后查看(偏向锁)
synchronized (lock) {
System.out.println("===== 偏向锁状态 =====");
System.out.println(ClassLayout.parseInstance(lock).toPrintable());
}
// 3. 另一个线程竞争锁(升级为轻量级锁或重量级锁)
Thread t = new Thread(() -> {
synchronized (lock) {
System.out.println("===== 竞争后状态 =====");
System.out.println(ClassLayout.parseInstance(lock).toPrintable());
}
});
t.start();
t.join();
}
}
运行参数:
-XX:+UseBiasedLocking -XX:BiasedLockingStartupDelay=0:立即启用偏向锁-XX:-UseBiasedLocking:禁用偏向锁,直接使用轻量级锁
6. 轻量级锁的实现原理
6.1 轻量级锁的设计理念
轻量级锁的核心思想:对于绝大部分的锁,在整个同步周期内都是不存在竞争的。
轻量级锁通过 CAS(Compare-And-Swap) 操作和 自旋 来避免线程阻塞和唤醒的开销。
6.2 轻量级锁的加锁流程
详细步骤:
- 线程在自己的栈帧中分配一个锁记录(Lock Record)。
- 将对象当前的 Mark Word 复制到锁记录的
Displaced Mark Word中。 - 线程执行一个 CAS 原子操作:尝试将对象的 Mark Word 更新为指向该锁记录的指针,并将锁标志位设置为
00(轻量级锁)。 - 如果 CAS 成功:加锁成功,线程进入同步块执行。
- 如果 CAS 失败:意味着在第三步执行前,对象的 Mark Word 已经被其他线程修改了(可能是另一个线程也成功执行了CAS,或锁已升级)。此时,进入失败处理分支。
CAS 失败后的处理分支:
- 分支一:检查是否为“锁重入”:线程会检查对象的 Mark Word 是否指向自己栈帧中的另一个锁记录。如果是,说明是同一个线程的锁重入。此时,线程会将本次新分配的锁记录中的
Displaced Mark Word设置为null(而不是备份对象头),作为一个重入次数的标记,然后加锁成功。这是轻量级锁处理重入的方式。 - 分支二:存在竞争,锁升级:如果 CAS 失败,且不是锁重入,则说明存在真正的竞争(另一个线程持有了这把轻量级锁)。当前线程不会立即挂起,而是会进行一段时间的 “自旋” (空循环),尝试再次CAS获取锁。如果自旋期间成功获取,则继续执行。如果自旋失败,轻量级锁就会升级为重量级锁(
10)。JVM会将对象 Mark Word 更新为指向一个重量级锁监视器(Monitor)的指针,并将自己(竞争线程)挂起,等待被唤醒。
6.3 轻量级锁的解锁流程
详细步骤:
- CAS 恢复:使用 CAS 操作将 Displaced Mark Word 替换回对象头
- 成功:表示没有竞争发生,解锁成功
- 失败:表示存在竞争,锁已经膨胀为重量级锁,需要释放锁并唤醒等待的线程
6.4 自旋优化
为了避免线程频繁挂起和恢复,轻量级锁引入了 自旋(Spinning) 机制。
自旋策略:
| 策略类型 | 说明 | JVM 参数 | 状态 |
|---|---|---|---|
| 固定次数自旋 | 自旋固定次数(默认 10 次) | -XX:PreBlockSpin=10 | JDK 9+ 已移除 |
| 自适应自旋 | 根据前一次在同一个锁上的自旋时间和锁拥有者的状态动态调整 | JDK 6+ 默认启用,无需配置 | 现代 JDK 唯一策略 |
注意:
-XX:PreBlockSpin参数在 JDK 9 及更高版本中已被移除,现代 JVM 完全依赖自适应自旋,由 JIT 编译器根据运行时数据动态优化自旋行为。
自适应自旋的优化逻辑:
- 如果在同一个锁对象上,自旋等待刚刚成功获得过锁,并且持有锁的线程正在运行,那么 JVM 会认为这次自旋也很有可能再次成功,因此允许自旋等待更长的时间
- 如果对于某个锁,自旋很少成功获得过,那么以后获取这个锁时将可能省略掉自旋过程,以避免浪费 CPU 资源
具体实现:JVM 内部有一个“自旋计数器”或类似的启发式算法。它并不是一个简单的固定次数,而是基于上一次自旋的成功与否、锁的持有者是否正在运行(On-Processor) 等多个因素来动态决定本次要自旋多久。其目标是在“避免无意义CPU浪费”和“减少线程挂起/唤醒开销”之间找到最佳平衡点。
7. 重量级锁的实现原理
7.1 重量级锁的设计理念
当轻量级锁的自旋达到一定次数仍未获得锁,或者持有锁的线程调用Object.wait()方法,轻量级锁会膨胀为重量级锁。
重量级锁依赖操作系统的 互斥量(Mutex Lock) 实现,会导致线程在用户态和内核态之间切换,开销较大。
7.2 ObjectMonitor 对象
轻量锁膨胀完成后,对象 Mark Word(锁标志位变为10)中的指针,将不再指向线程栈中的锁记录,而是指向一个在堆中分配的、与对象关联的 ObjectMonitor 对象。在 HotSpot 虚拟机中,重量级锁通过 ObjectMonitor 对象实现。每个对象都可以关联一个 ObjectMonitor 对象。
ObjectMonitor 的关键字段:
ObjectMonitor() {
_header = NULL; // displaced object header word
_count = 0; // 记录个数
_waiters = 0; // 等待线程数
_recursions = 0; // 重入次数
_object = NULL; // 关联的对象
_owner = NULL; // 持有锁的线程
_WaitSet = NULL; // 等待队列(wait()方法)
_WaitSetLock = 0;
_Responsible = NULL;
_succ = NULL;
_cxq = NULL; // 竞争队列
_EntryList = NULL; // 入口队列
_SpinFreq = 0;
_SpinClock = 0;
OwnerIsThread = 0;
}
核心数据结构:
| 字段 | 说明 |
|---|---|
_owner | 指向持有 ObjectMonitor 对象的线程 |
_cxq | 存放所有因竞争锁失败而第一次被挂起(park)的线程(BLOCKED) |
_EntryList | 处于等待锁阻塞状态的线程队列(BLOCKED) |
_WaitSet | 调用 wait() 方法后等待的线程队列(WAITING) |
_recursions | 锁的重入次数 |
_count | 用于辅助计数 |
7.3 重量级锁的加锁流程
7.4 重量级锁的解锁流程
重量级锁释放时怎么唤醒阻塞线程的?有公平性吗?
- 唤醒流程:
当持有锁的线程(
_owner)退出同步块时,会调用ObjectMonitor::exit方法。- 将重入计数器
_recursions减1,如果减到0,表示真正释放锁。 - 将
_owner设为NULL。 - 根据预设的
Knob_ExitPolicy策略,决定如何处置等待的线程。一个典型的策略是:- 先将
_cxq队列中的线程按 LIFO 顺序全部移动到_EntryList中。 - 然后,从
_EntryList中唤醒(unpark)头部的一个或几个线程。被唤醒的线程会感知到_owner为NULL,并通过 CAS 竞争去尝试将自己设为新的_owner。
- 先将
- 将重入计数器
- 公平性:
重量级锁的默认策略是“非公平”的。 这主要体现在:
- 新来的线程(刚执行到 synchronized)可以与刚被唤醒的线程一起竞争,而无需排队。
_cxq的 LIFO 顺序,使得后到的竞争线程可能先被处理。_EntryList的唤醒顺序也不严格保证 FIFO。
为什么设计成非公平? 核心是最大化系统吞吐量。公平性保证了“先来后到”,但会引入更多的上下文切换和线程调度开销(比如唤醒的线程可能因为调度延迟不能立即运行)。非公平锁允许“插队”,虽然可能导致某些线程饥饿,但在高并发场景下,减少了整体线程的挂起和唤醒次数,让CPU更忙碌,从而获得更高的总体吞吐量。Java的
synchronized在设计上就倾向于性能而非绝对公平。如果需要公平锁,应使用java.util.concurrent.locks.ReentrantLock并指定公平模式。
7.5 wait/notify 机制
重量级锁还支持 wait()、notify()、notifyAll() 方法,这些方法的实现依赖 ObjectMonitor。
wait() 流程:
- 线程调用
wait()方法 - 释放持有的锁(
_owner置为 NULL,_recursions置为 0) - 线程进入
_WaitSet队列,状态变为 WAITING - 线程被阻塞
notify() 流程:
- 线程调用
notify()方法 - 从
_WaitSet中取出一个线程 - 将该线程移到
_EntryList或_cxq队列 - 线程状态从 WAITING 变为 BLOCKED
- 该线程等待重新竞争锁
线程状态转换图:
注意:
wait()被唤醒后线程先进入 BLOCKED 状态等待重新获取锁,获取成功后才变为 RUNNABLE。
8. 锁优化技术
除了锁升级机制,JVM 还提供了多种锁优化技术来提升 synchronized 的性能。
8.1 锁消除(Lock Elimination)
原理: JIT 编译器在运行时,通过逃逸分析,如果判断一段代码中,堆上的所有数据都不会逃逸出去被其他线程访问到,就可以将它们当作栈上数据对待,认为它们是线程私有的,同步加锁自然就无须进行。
示例:
public String concatString(String s1, String s2, String s3) {
StringBuffer sb = new StringBuffer();
sb.append(s1);
sb.append(s2);
sb.append(s3);
return sb.toString();
}
StringBuffer 的 append() 方法是同步方法,但在这个例子中,sb 对象不会逃逸出方法外,因此 JIT 编译器会自动消除 StringBuffer 内部的同步锁。
JVM 参数:
-XX:+DoEscapeAnalysis(默认启用):启用逃逸分析-XX:+EliminateLocks(默认启用):启用锁消除
8.2 锁粗化(Lock Coarsening)
原理: 如果一系列的连续操作都对同一个对象反复加锁和解锁,甚至加锁操作是出现在循环体中的,JIT 编译器会将加锁同步的范围扩展(粗化)到整个操作序列的外部。
优化前:
public void method() {
synchronized(lock) {
// 操作1
}
synchronized(lock) {
// 操作2
}
synchronized(lock) {
// 操作3
}
}
优化后:
public void method() {
synchronized(lock) {
// 操作1
// 操作2
// 操作3
}
}
循环中的锁粗化:
// 优化前
for (int i = 0; i < 1000; i++) {
synchronized(lock) {
// 操作
}
}
// 优化后
synchronized(lock) {
for (int i = 0; i < 1000; i++) {
// 操作
}
}
8.3 锁的内存语义
synchronized 除了保证原子性,还保证了可见性和有序性。
内存语义:
| 操作 | 内存语义 |
|---|---|
| 加锁(monitorenter) | 清空工作内存中共享变量的值,从主内存中重新读取 |
| 解锁(monitorexit) | 将工作内存中共享变量的值刷新到主内存 |
这保证了:
- 可见性:一个线程释放锁后,所有的修改对后续获得该锁的线程可见
- 有序性:禁止 JVM 和处理器对监视器内的代码进行重排序优化
happens-before 规则:
- 对一个监视器的解锁,happens-before 于随后对这个监视器的加锁
9. 性能对比与使用场景
9.1 JDK 版本差异
| JDK 版本 | 偏向锁状态 | 说明 |
|---|---|---|
| JDK 6 | 引入偏向锁 | 默认启用,延迟 4 秒启动 |
| JDK 7-14 | 默认启用 | 性能优化的重要特性 |
| JDK 15 | 默认禁用(JEP 374) | 可通过 -XX:+UseBiasedLocking 手动启用 |
| JDK 18-20 | 参数过时(obsolete) | 启用参数被忽略,输出警告信息 |
| JDK 21 | 虚拟线程引入(JEP 444) | 偏向锁仍禁用,虚拟线程改变同步语义 |
| JDK 22-25 | 代码保留,功能禁用 | 偏向锁代码仍在 JVM 中,但未移除,以兼容旧应用 |
禁用偏向锁的原因(JEP 374):
- 维护成本高:批量重偏向、批量撤销机制增加了代码复杂度
- 收益有限:现代应用多为高并发场景,单线程重复获取锁的场景减少
- 启动延迟问题:偏向锁延迟 4 秒启动,影响某些应用的启动性能
- 与新特性冲突:偏向锁与 Project Loom(虚拟线程)的设计存在兼容性问题
关于偏向锁移除计划
截至 JDK 25(2025 年),偏向锁代码尚未从 HotSpot JVM 中完全移除。OpenJDK 团队采取了保守策略,保留代码以兼容可能依赖偏向锁行为的旧应用。但请注意,偏向锁功能已被禁用且不建议使用。
9.4 虚拟线程与 synchronized
JDK 21 正式引入虚拟线程(Virtual Threads,JEP 444),这对 synchronized 的使用有重要影响:
| 特性 | 平台线程 | 虚拟线程 |
|---|---|---|
| synchronized 阻塞行为 | 阻塞 OS 线程 | 固定(pin) 载体线程 |
| 重量级锁开销 | 用户态/内核态切换 | 可能导致载体线程被占用 |
| 推荐替代方案 | - | ReentrantLock 或其他 java.util.concurrent 锁 |
虚拟线程中的最佳实践:
- 避免在虚拟线程中长时间持有 synchronized 锁
- 对于可能阻塞的操作,优先使用
ReentrantLock - 使用
-Djdk.tracePinnedThreads=full诊断固定问题
什么是"固定"(Pinning)?
当虚拟线程在 synchronized 块中阻塞时,底层的载体(平台)线程无法被释放去执行其他虚拟线程,这被称为"固定"。这会降低虚拟线程的可伸缩性优势,因此在高并发虚拟线程应用中应尽量避免使用 synchronized。
10. 源码分析
版本说明
本节源码分析基于 OpenJDK 8。在更高版本 JDK 中,部分实现细节可能有所不同(如偏向锁相关代码在 JDK 15+ 中虽然保留但已默认禁用)。建议结合目标 JDK 版本的源码进行对照阅读。
10.1 核心源码位置
在 OpenJDK 8 中,synchronized 相关的核心源码位于以下位置:
| 文件路径 | 说明 |
|---|---|
hotspot/src/share/vm/runtime/synchronizer.cpp | 同步器实现,锁的获取和释放 |
hotspot/src/share/vm/runtime/objectMonitor.cpp | ObjectMonitor 实现 |
hotspot/src/share/vm/runtime/biasedLocking.cpp | 偏向锁实现 |
hotspot/src/share/vm/oops/markOop.hpp | Mark Word 定义 |
hotspot/src/share/vm/interpreter/bytecodeInterpreter.cpp | 字节码解释器,monitorenter/monitorexit |
10.2 Mark Word 定义
// hotspot/src/share/vm/oops/markOop.hpp
enum {
locked_value = 0, // 00 轻量级锁
unlocked_value = 1, // 01 无锁或偏向锁
monitor_value = 2, // 10 重量级锁
marked_value = 3, // 11 GC 标记
biased_lock_pattern = 5 // 101 偏向锁
};
10.3 偏向锁核心代码
// hotspot/src/share/vm/runtime/biasedLocking.cpp
// 偏向锁的撤销与重偏向操作
// 返回值类型 Condition 表示操作结果状态
BiasedLocking::Condition BiasedLocking::revoke_and_rebias(
Handle obj, bool attempt_rebias, TRAPS) {
// 断言:此操作不能在安全点执行
// 因为需要与其他线程交互,安全点会导致死锁
assert(!SafepointSynchronize::is_at_safepoint(), "must not be at safepoint");
// 获取对象的 Mark Word
markOop mark = obj->mark();
// 步骤1:检查是否为偏向锁模式(锁标志位 01,偏向位 1)
if (!mark->has_bias_pattern()) {
// 不是偏向模式,可能是无锁、轻量级锁或重量级锁
return NOT_BIASED;
}
// 步骤2:获取当前偏向的线程 ID(存储在 Mark Word 中)
JavaThread* biased_thread = mark->biased_locker();
// 步骤3:检查是否偏向当前线程
if (biased_thread == THREAD) {
// 已经偏向当前线程,无需任何操作,直接返回
return BIAS_REVOKED;
}
// 步骤4:偏向锁指向其他线程,需要撤销
if (biased_thread != NULL) {
// 偏向锁已经偏向其他线程
// 必须等待安全点(Safepoint)才能撤销,以保证线程安全
// VM_RevokeBias 是一个 VM 操作,会在安全点执行
VM_RevokeBias op(obj, attempt_rebias);
VMThread::execute(&op); // 提交到 VM 线程执行
return op.status_code();
}
// 步骤5:匿名偏向状态(ThreadID == 0),尝试 CAS 偏向当前线程
markOop biased_value = mark;
markOop unbiased_prototype = markOopDesc::prototype()->set_age(mark->age());
// CAS 操作:尝试将 Mark Word 从匿名偏向改为偏向当前线程
markOop res_mark = obj->cas_set_mark(biased_value, unbiased_prototype);
if (res_mark == unbiased_prototype) {
return BIAS_REVOKED;
}
return NOT_REVOKED;
}
10.4 轻量级锁核心代码
// hotspot/src/share/vm/runtime/synchronizer.cpp
// 同步器的快速入口和慢速入口实现
// 快速入口:优先尝试偏向锁
void ObjectSynchronizer::fast_enter(Handle obj, BasicLock* lock,
bool attempt_rebias, TRAPS) {
// 检查是否启用偏向锁(JDK 15+ 默认禁用)
if (UseBiasedLocking) {
// 如果启用偏向锁,先尝试偏向锁路径
if (!SafepointSynchronize::is_at_safepoint()) {
// 非安全点:尝试撤销并重偏向
BiasedLocking::Condition cond = BiasedLocking::revoke_and_rebias(
obj, attempt_rebias, THREAD);
if (cond == BiasedLocking::BIAS_REVOKED_AND_REBIASED) {
// 重偏向成功,直接返回
return;
}
} else {
// 安全点:直接撤销偏向锁
BiasedLocking::revoke_at_safepoint(obj);
}
}
// 偏向锁路径失败或未启用,进入轻量级锁路径
slow_enter(obj, lock, THREAD);
}
// 慢速入口:轻量级锁获取逻辑
void ObjectSynchronizer::slow_enter(Handle obj, BasicLock* lock, TRAPS) {
// 获取对象当前的 Mark Word
markOop mark = obj->mark();
// 情况1:无锁状态(is_neutral() 检查锁标志位为 01 且偏向位为 0)
if (mark->is_neutral()) {
// 步骤1:将原始 Mark Word 保存到栈上的 Lock Record(Displaced Mark Word)
lock->set_displaced_header(mark);
// 步骤2:CAS 尝试将对象头替换为指向 Lock Record 的指针
if (mark == obj()->cas_set_mark((markOop) lock, mark)) {
// CAS 成功:成功获取轻量级锁,锁标志位变为 00
return;
}
// CAS 失败:说明有其他线程竞争,继续下面的流程
}
// 情况2:轻量级锁重入检测
else if (mark->has_locker() &&
THREAD->is_lock_owned((address)mark->locker())) {
// Mark Word 指向当前线程的栈帧,说明是锁重入
// 重入时 Displaced Mark Word 设为 NULL(作为重入计数的标记)
lock->set_displaced_header(NULL);
return;
}
// 情况3:存在竞争,需要膨胀为重量级锁
// 设置特殊标记,表示需要膨胀
lock->set_displaced_header(markOopDesc::unused_mark());
// inflate() 方法创建或获取 ObjectMonitor,然后调用 enter() 获取锁
ObjectSynchronizer::inflate(THREAD, obj(), inflate_cause_monitor_enter)
->enter(THREAD);
}
10.5 重量级锁核心代码
// hotspot/src/share/vm/runtime/objectMonitor.cpp
// ObjectMonitor 是重量级锁的核心实现,每个被重量级锁保护的对象都关联一个 ObjectMonitor
// enter() 方法:尝试获取重量级锁
void ATTR ObjectMonitor::enter(TRAPS) {
Thread * const Self = THREAD; // 当前线程
// 快速路径:尝试 CAS 获取锁(_owner 从 NULL 变为当前线程)
void * cur = Atomic::cmpxchg_ptr(Self, &_owner, NULL);
if (cur == NULL) {
// CAS 成功:锁空闲,当前线程成功获取锁
return;
}
// 检查是否为锁重入(当前线程已经持有锁)
if (cur == Self) {
// 重入:增加重入计数
_recursions++;
return;
}
// 特殊情况:当前线程曾经通过轻量级锁持有过这个锁
// (从轻量级锁膨胀而来的情况)
if (Self->is_lock_owned((address)cur)) {
_recursions = 1; // 设置重入次数为 1
_owner = Self; // 更新所有者为当前线程
return;
}
// 慢速路径:无法快速获取锁,进入等待队列
EnterI(THREAD);
}
// EnterI() 方法:将线程加入等待队列并阻塞
void ObjectMonitor::EnterI(TRAPS) {
Thread * const Self = THREAD;
// 先尝试自旋获取锁(自适应自旋,由 JVM 动态调整次数)
if (TrySpin(Self) > 0) {
// 自旋成功获取锁
return;
}
// 自旋失败,准备加入等待队列
// 创建等待节点(ObjectWaiter)表示当前线程
ObjectWaiter node(Self);
Self->_ParkEvent->reset(); // 重置 park 事件
node._prev = (ObjectWaiter *) 0xBAD; // 哨兵值,用于调试
node.TState = ObjectWaiter::TS_CXQ; // 设置状态为在 CXQ 队列中
// CAS 循环:将节点加入 _cxq(Contention Queue)队列头部
// _cxq 是一个 LIFO 栈结构,用于存放竞争锁的线程
ObjectWaiter * nxt;
for (;;) {
node._next = nxt = _cxq;
// CAS:尝试将 _cxq 头指针指向新节点
if (Atomic::cmpxchg_ptr(&node, &_cxq, nxt) == nxt) break;
// CAS 失败则重试
}
// 主等待循环:阻塞等待被唤醒,然后尝试获取锁
for (;;) {
// 被唤醒后先尝试获取锁
if (TryLock(Self) > 0) break;
// 阻塞等待(使用 OS 原语)
if (_Responsible == Self || (SyncFlags & 1)) {
// 如果是"负责线程",使用带超时的 park,定期唤醒检查
Self->_ParkEvent->park((jlong) RecheckInterval);
} else {
// 普通线程,无限期等待
Self->_ParkEvent->park();
}
// 被唤醒后再次尝试获取锁
if (TryLock(Self) > 0) break;
// 获取失败则继续等待
}
// 成功获取锁,从等待队列中移除自己
UnlinkAfterAcquire(Self, &node);
// 清除 successor 标记(如果有)
if (_succ == Self) _succ = NULL;
}
11. 总结
11.1 技术演进
| 时期 | synchronized 实现 | 特点 |
|---|---|---|
| JDK 1.6 之前 | 重量级锁 | 直接使用操作系统互斥量,性能较差 |
| JDK 1.6 | 引入锁升级机制 | 偏向锁、轻量级锁、重量级锁,性能大幅提升 |
| JDK 1.7-1.14 | 持续优化 | 自适应自旋、锁消除、锁粗化等优化技术 |
| JDK 15-17 | 禁用偏向锁(JEP 374) | 偏向锁默认关闭,可手动启用 |
| JDK 18-20 | 偏向锁参数过时 | -XX:+UseBiasedLocking 等参数被标记为 obsolete |
| JDK 21+ | 虚拟线程时代(JEP 444) | synchronized 在虚拟线程中会导致固定(pinning),推荐使用 ReentrantLock |
JDK 锁机制演进总览:
| JDK 版本 | 关键变化 | 影响 |
|---|---|---|
| 6-14 | 偏向锁默认启用 | 低争用场景性能提升 |
| 15 | 默认禁用,参数弃用 | 简化 JVM,聚焦高并发场景 |
| 18 | 参数过时(obsolete) | 启用尝试被忽略 |
| 21-25 | 虚拟线程引入,偏向锁代码保留但禁用 | synchronized 语义在虚拟线程下变化,偏向锁仍禁用 |
11.2 相关 JVM 参数速查表
| 参数 | 说明 | 默认值 | 状态(JDK 21+) |
|---|---|---|---|
-XX:+UseBiasedLocking | 启用偏向锁 | JDK 6-14: 开启 JDK 15+: 关闭 | obsolete |
-XX:BiasedLockingStartupDelay | 偏向锁延迟启动时间(ms) | 4000 | obsolete |
-XX:+UseSpinning | 启用自旋锁 | JDK 6-7 需显式启用 JDK 8+ 默认开启 | 已移除 |
-XX:PreBlockSpin | 自旋次数 | 10 | 已移除(JDK 9+) |
-XX:+DoEscapeAnalysis | 启用逃逸分析 | 默认开启 | 正常 |
-XX:+EliminateLocks | 启用锁消除 | 默认开启 | 正常 |
-XX:+PrintBiasedLockingStatistics | 打印偏向锁统计信息 | 关闭 | obsolete |
参数状态说明
- obsolete:参数已过时,使用时会输出警告并被忽略
- 已移除:参数已从 JVM 中删除,使用会导致启动失败
- 正常:参数仍然有效
虚拟线程相关诊断参数(JDK 21+):
| 参数 | 说明 |
|---|---|
-Djdk.tracePinnedThreads=full | 打印虚拟线程被固定的详细堆栈信息 |
-Djdk.tracePinnedThreads=short | 打印虚拟线程被固定的简要信息 |
本文基于 OpenJDK 8 源码分析。随着 JVM 的持续演进,部分内容(特别是偏向锁相关)在更高版本 JDK 中已发生变化。建议读者参考 OpenJDK JEP 索引 和官方发布说明获取最新信息。
参考资料:
- JEP 374: Deprecate and Disable Biased Locking
- JEP 444: Virtual Threads
- Lock (Java SE 23 & JDK 23) - Oracle Help Center
- JMH - Java Microbenchmark Harness
又是没有大厂约面日子😣😣😣,小编还在找实习的路上,这篇文章是我的笔记汇总整理。