synchronized 锁升级原理:从 JDK 8 实现到 JDK 25 演进

303 阅读28分钟

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 对象在内存中的布局分为三个部分:

Snipaste_2025-12-01_17-10-47.png

组成部分说明大小
对象头(Object Header)存储对象自身的运行时数据,包括 Mark Word 和类型指针8/12/16 字节(取决于是否开启指针压缩)
实例数据(Instance Data)对象真正存储的有效信息,即各个字段的内容不固定
对齐填充(Padding)占位符,保证对象大小是 8 字节的整数倍0-7 字节

2.2 对象头结构

对象头是实现 synchronized 锁的关键,它包含两部分:

  1. Mark Word(标记字):存储对象自身的运行时数据
  2. Klass Pointer(类型指针):指向对象的类元数据

对于数组对象,还会包含一个4字节的数组长度信息。

2.2.1 Mark Word 详解

Mark Word 是实现锁升级的核心数据结构。在 64 位 JVM 中,Mark Word 占 64 位(8 字节),在 32 位 JVM 中占 32 位(4 字节)。

Snipaste_2025-12-01_17-22-50.png

32 位 JVM 的 Mark Word 结构:

锁状态25 bit4 bit1 bit (是否偏向锁)2 bit (锁标志位)
无锁对象的 hashCode分代年龄001
偏向锁线程 ID (23 bit) + Epoch (2 bit)分代年龄101
轻量级锁指向栈中锁记录的指针00
重量级锁指向互斥量(重量级锁)的指针10
GC 标记11

Snipaste_2025-12-01_17-23-11.png

64 位 JVM 的 Mark Word 结构:

锁状态56 bit1 bit4 bit1 bit (是否偏向锁)2 bit (锁标志位)
无锁unused (25 bit) + hashCode (31 bit)unused分代年龄001
偏向锁线程 ID (54 bit) + Epoch (2 bit)unused分代年龄101
轻量级锁指向栈中锁记录的指针 (62 bit)00
重量级锁指向 ObjectMonitor 的指针 (62 bit)10
GC 标记11

锁标志位说明:

锁标志位偏向锁位锁状态
010无锁状态(Normal)
011偏向锁状态(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 锁升级流程图

lock-upgrade-flow.drawio.png

4.2 详细升级过程

4.2.1 无锁 → 偏向锁
  1. 检查对象头:线程访问同步块时,检查 Mark Word 的锁标志位和偏向锁标志
  2. 判断是否可偏向
    • 如果是可偏向状态(偏向锁标志为 1)且 ThreadID 为空
    • 使用 CAS 操作尝试将当前线程 ID 写入 Mark Word
  3. 获取偏向锁成功:CAS 成功后,以后该线程进入同步块时只需简单测试 Mark Word 中是否存储着指向当前线程的偏向锁
4.2.2 偏向锁 → 轻量级锁

当另一个线程尝试获取已被偏向的锁时:

  1. 检测到竞争:发现 Mark Word 中的线程 ID 不是自己
  2. 暂停偏向线程:到达全局安全点(Safepoint),暂停持有偏向锁的线程
  3. 检查偏向线程状态
    • 如果线程已经退出同步块或不再存活:直接撤销偏向锁,变为无锁状态
    • 如果线程仍在同步块中:将偏向锁升级为轻量级锁
  4. 恢复线程执行
4.2.3 轻量级锁 → 重量级锁

当自旋达到一定次数仍未获得锁时:

  1. 自旋失败:线程自旋等待锁的次数超过阈值(JDK 6+ 默认启用自适应自旋,由 JVM 动态调整)
  2. 膨胀为重量级锁:将轻量级锁膨胀为重量级锁
  3. 线程阻塞:未获得锁的线程进入阻塞状态,等待被唤醒

注意:早期版本可通过 -XX:PreBlockSpin 参数设置固定自旋次数(默认 10 次),但该参数在现代 JDK 中已被自适应自旋取代,不再生效。


5. 偏向锁的实现原理

5.1 偏向锁的设计理念

偏向锁的核心思想:锁不仅不存在多线程竞争,而且总是由同一线程多次获得。其核心流程如下:

  • 初始状态(可偏向):当一个对象被创建后,如果它的类被计算为可偏向(默认大部分都是),并且JVM的偏向锁延迟已过(默认4秒后),那么该对象的Mark Word会处于 “匿名偏向” 状态。此时,它的锁标志位是“101”(偏向模式),但线程ID部分为0,表示尚未偏向于任何特定线程
  • 首次加锁(CAS烙印):当第一个线程T1来尝试获取这个对象的锁时,它会执行一个CAS原子操作,试图将当前线程的ID(以及一些其他信息如epoch)写入到对象头的Mark Word中。如果这个CAS成功,那么锁就**“偏向”** 于线程T1了。这是整个过程中唯一一次可能的竞争点
  • 同一线程重入(偏向生效):在此之后,只要线程T1再次进入这个同步块,它只需要做两步极快的操作:
    1. 检查:查看对象头中的线程ID是否就是自己。
    2. 验证:如果相等,则直接认为持有锁,可以执行同步代码。 这个过程完全不涉及任何原子指令(如CAS)或操作系统调用,性能开销极低,可以理解为基本无消耗。这就是“偏向”带来的巨大收益。
  • 其他线程竞争(撤销偏向):如果另一个线程T2也来尝试获取这个锁,它会发现对象头中的线程ID不是自己。这时,偏向锁就需要“撤销”。撤销过程是安全点中一个相对昂贵的操作,需要等待原持有者T1到达安全点,然后根据情况,要么升级为轻量级锁(T1已释放锁),要么升级为重量级锁(T1仍持有锁)。

5.2 偏向锁的获取流程

biased-lock-acquire.drawio.png

5.3 偏向锁的撤销

偏向锁的撤销是一个相对昂贵的操作,需要等待全局安全点(Safepoint)。

撤销流程:

  1. 到达安全点:暂停持有偏向锁的线程
  2. 检查线程状态
    • 线程未活动或已退出同步块:直接将 Mark Word 改为无锁状态(01,0)
    • 线程仍在同步块中:将偏向锁升级为轻量级锁
  3. 恢复线程执行

批量重偏向和批量撤销:

为了优化偏向锁撤销的性能,JVM 引入了批量重偏向和批量撤销机制:

机制触发条件处理方式
批量重偏向一个类的对象被多个线程访问,但不存在竞争将该类的偏向锁指向新的线程
批量撤销某个类的撤销次数达到阈值(默认 40)将该类的所有对象都改为不可偏向

5.4 相关 JVM 参数

参数说明默认值状态
-XX:+UseBiasedLocking启用偏向锁JDK 6-14 默认启用,JDK 15+ 默认禁用JDK 18+ obsolete
-XX:BiasedLockingStartupDelay偏向锁延迟启动时间(毫秒)4000ms(见下文说明)JDK 18+ obsolete
-XX:BiasedLockingBulkRebiasThreshold批量重偏向阈值20JDK 18+ obsolete
-XX:BiasedLockingBulkRevokeThreshold批量撤销阈值40JDK 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 轻量级锁的加锁流程

lightweight-lock-acquire.drawio.png

详细步骤:

  1. 线程在自己的栈帧中分配一个锁记录(Lock Record)。
  2. 对象当前的 Mark Word 复制到锁记录的 Displaced Mark Word 中。
  3. 线程执行一个 CAS 原子操作:尝试将对象的 Mark Word 更新为指向该锁记录的指针,并将锁标志位设置为 00(轻量级锁)。
  4. 如果 CAS 成功:加锁成功,线程进入同步块执行。
  5. 如果 CAS 失败:意味着在第三步执行前,对象的 Mark Word 已经被其他线程修改了(可能是另一个线程也成功执行了CAS,或锁已升级)。此时,进入失败处理分支。

CAS 失败后的处理分支:

  • 分支一:检查是否为“锁重入”:线程会检查对象的 Mark Word 是否指向自己栈帧中的另一个锁记录。如果是,说明是同一个线程的锁重入。此时,线程会将本次新分配的锁记录中的 Displaced Mark Word 设置为 null(而不是备份对象头),作为一个重入次数的标记,然后加锁成功。这是轻量级锁处理重入的方式。
  • 分支二:存在竞争,锁升级:如果 CAS 失败,且不是锁重入,则说明存在真正的竞争(另一个线程持有了这把轻量级锁)。当前线程不会立即挂起,而是会进行一段时间的 “自旋” (空循环),尝试再次CAS获取锁。如果自旋期间成功获取,则继续执行。如果自旋失败,轻量级锁就会升级为重量级锁(10。JVM会将对象 Mark Word 更新为指向一个重量级锁监视器(Monitor)的指针,并将自己(竞争线程)挂起,等待被唤醒。

6.3 轻量级锁的解锁流程

lightweight-lock-release.drawio.png

详细步骤:

  1. CAS 恢复:使用 CAS 操作将 Displaced Mark Word 替换回对象头
    • 成功:表示没有竞争发生,解锁成功
    • 失败:表示存在竞争,锁已经膨胀为重量级锁,需要释放锁并唤醒等待的线程

6.4 自旋优化

为了避免线程频繁挂起和恢复,轻量级锁引入了 自旋(Spinning) 机制。

自旋策略:

策略类型说明JVM 参数状态
固定次数自旋自旋固定次数(默认 10 次)-XX:PreBlockSpin=10JDK 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 重量级锁的加锁流程

heavyweight-lock-acquire.drawio.png

7.4 重量级锁的解锁流程

heavyweight-lock-release.drawio.png

重量级锁释放时怎么唤醒阻塞线程的?有公平性吗?

  • 唤醒流程: 当持有锁的线程(_owner)退出同步块时,会调用 ObjectMonitor::exit 方法。
    1. 将重入计数器 _recursions 减1,如果减到0,表示真正释放锁。
    2. _owner 设为 NULL
    3. 根据预设的 Knob_ExitPolicy 策略,决定如何处置等待的线程。一个典型的策略是:
      • 先将 _cxq 队列中的线程按 LIFO 顺序全部移动到 _EntryList 中。
      • 然后,从 _EntryList唤醒(unpark)头部的一个或几个线程。被唤醒的线程会感知到 _ownerNULL,并通过 CAS 竞争去尝试将自己设为新的 _owner
  • 公平性重量级锁的默认策略是“非公平”的。 这主要体现在:
    1. 新来的线程(刚执行到 synchronized)可以与刚被唤醒的线程一起竞争,而无需排队。
    2. _cxq 的 LIFO 顺序,使得后到的竞争线程可能先被处理
    3. _EntryList 的唤醒顺序也不严格保证 FIFO。

为什么设计成非公平? 核心是最大化系统吞吐量。公平性保证了“先来后到”,但会引入更多的上下文切换和线程调度开销(比如唤醒的线程可能因为调度延迟不能立即运行)。非公平锁允许“插队”,虽然可能导致某些线程饥饿,但在高并发场景下,减少了整体线程的挂起和唤醒次数,让CPU更忙碌,从而获得更高的总体吞吐量。Java的 synchronized 在设计上就倾向于性能而非绝对公平。如果需要公平锁,应使用 java.util.concurrent.locks.ReentrantLock 并指定公平模式。

7.5 wait/notify 机制

重量级锁还支持 wait()notify()notifyAll() 方法,这些方法的实现依赖 ObjectMonitor。

wait() 流程:

  1. 线程调用 wait() 方法
  2. 释放持有的锁(_owner 置为 NULL,_recursions 置为 0)
  3. 线程进入 _WaitSet 队列,状态变为 WAITING
  4. 线程被阻塞

notify() 流程:

  1. 线程调用 notify() 方法
  2. _WaitSet 中取出一个线程
  3. 将该线程移到 _EntryList_cxq 队列
  4. 线程状态从 WAITING 变为 BLOCKED
  5. 该线程等待重新竞争锁

线程状态转换图:

thread-state-diagram.drawio.png

注意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();
}

StringBufferappend() 方法是同步方法,但在这个例子中,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)将工作内存中共享变量的值刷新到主内存

这保证了:

  1. 可见性:一个线程释放锁后,所有的修改对后续获得该锁的线程可见
  2. 有序性:禁止 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):

  1. 维护成本高:批量重偏向、批量撤销机制增加了代码复杂度
  2. 收益有限:现代应用多为高并发场景,单线程重复获取锁的场景减少
  3. 启动延迟问题:偏向锁延迟 4 秒启动,影响某些应用的启动性能
  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.cppObjectMonitor 实现
hotspot/src/share/vm/runtime/biasedLocking.cpp偏向锁实现
hotspot/src/share/vm/oops/markOop.hppMark 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 444synchronized 在虚拟线程中会导致固定(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)4000obsolete
-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 索引 和官方发布说明获取最新信息。

参考资料:

又是没有大厂约面日子😣😣😣,小编还在找实习的路上,这篇文章是我的笔记汇总整理。