WEBKT

Java虚拟线程因为synchronized锁死?聊透Pinning问题的成因与改造方案

5 0 0 0

引入 Java 21 的虚拟线程(Virtual Threads)后,不少开发者在将高并发服务迁移到新架构时遇到了诡异的性能瓶颈:系统吞吐量不仅没有如期暴涨,反而出现了大面积的延迟飙升,甚至服务直接假死。

通过线程栈 dump 或者 JFR(Java Flight Recorder)分析,会发现一个高频出现的词:Pinning(线程固定)。而导致 Pinning 的罪魁祸首,往往就是我们用了十几年的 synchronized 关键字。

什么是虚拟线程的 Pinning 问题?

要理解 Pinning,得先看看虚拟线程的调度模型。

虚拟线程(Virtual Thread)是用户态的轻量级线程,它不能直接在操作系统上运行,必须挂载到平台线程(Platform Thread,也就是承载它的 Carrier Thread)上。

+-----------------------------------+
|     Virtual Thread (虚拟线程)      |  <-- 用户代码运行在这里
+-----------------------------------+
                  | (Mount 挂载)
                  v
+-----------------------------------+
|     Carrier Thread (载体线程)      |  <-- 真实的物理线程 (ForkJoinPool)
+-----------------------------------+

在正常情况下,当虚拟线程遇到阻塞操作(比如网络 I/O、Thread.sleep()ReentrantLock)时,JVM 会把这个虚拟线程从 Carrier Thread 上**卸载(Unmount)**下来,将其堆栈保存到 Java 堆内存中。此时,空闲出来的 Carrier Thread 就可以去执行其他虚拟线程。

然而,当虚拟线程在执行 synchronized 块或 synchronized 方法时,如果发生了阻塞,JVM 无法将其从 Carrier Thread 上卸载。

这时候,虚拟线程就被“固定”(Pinned)在了这个 Carrier Thread 上。

为什么 synchronized 会导致 Pinning?

这涉及到 JVM 内部的 Monitor(监视器锁)实现机制。

目前 HotSpot JVM 的 synchronized 锁是与系统底层的本地线程(Native Thread)状态紧密绑定的。当一个线程进入 synchronized 块时,JVM 会在调用栈中生成特定的 Monitor 记录(ObjectMonitor),并且在字节码层面生成 monitorentermonitorexit 指令。

由于历史技术债,JVM 现有的调用栈解析和垃圾回收机制,无法在线程持有本地 Monitor 锁的状态下,安全地将该线程的栈帧(Stack Frame)移动到 Java 堆中。

一旦发生 Pinning:

  1. 虚拟线程无法下放,导致承载它的 Carrier Thread 被同步阻塞。
  2. 默认情况下,Carrier Thread 线程池(ForkJoinPool)的大小等于 CPU 核心数。
  3. 如果这几个仅有的 Carrier Thread 全部因为 Pinning 被锁死,整个应用就会陷入线程饥饿状态,后续所有的虚拟线程都将得不到调度,系统彻底卡死。

如何精准定位 Pinning 代码?

在庞大的工程中,我们很难用肉眼找出所有的 synchronized。好在 JVM 提供了非常实用的诊断工具。

1. 系统启动参数(推荐开发阶段使用)

在启动 JVM 时,加入以下系统属性:

-Djdk.tracePinnedThreads=full

当虚拟线程在运行中遭遇 Pinning 阻塞时,控制台会直接打印出完整的堆栈信息,精确到哪一行代码触发了固定。

如果觉得 full 模式打印的信息太多,可以使用 short 模式:

-Djdk.tracePinnedThreads=short

它只会打印出触发 Pinning 的类名、方法名以及对应的平台线程。

2. 使用 Java Flight Recorder (JFR)

在生产环境中,开启 tracePinnedThreads 会带来不小的性能开销。更优雅的方式是使用 JFR。

JFR 中有一个专门的事件叫 jdk.VirtualThreadPinned。你可以通过 JDK 自带的 Mission Control (JMC) 工具,加载 JFR 录制文件,在事件浏览器中搜索该事件,即可直观地看到 Pinning 发生的频次、持续时间以及关联的代码位置。

核心重构方案:用 ReentrantLock 替代 synchronized

解决 Pinning 问题最直接、最有效的手段,就是将导致阻塞的 synchronized 替换为 java.util.concurrent.locks.ReentrantLock

因为 ReentrantLock 是基于 AQS(AbstractQueuedSynchronizer)实现的,其底层的等待队列和线程挂起完全是由 Java 代码控制的。当虚拟线程在 ReentrantLock.lock() 处阻塞时,JVM 能够完美地将其卸载,从而释放出 Carrier Thread。

代码改造示例

改造前(会导致 Pinning):

public class SharedDataHolder {
    private final Object lock = new Object();

    public void updateData() {
        synchronized (lock) {
            // 假设这里有一个耗时的 I/O 操作或网络调用
            byte[] data = networkService.fetchData(); 
            process(data);
        }
    }
}

改造后(完美契合虚拟线程):

import java.util.concurrent.locks.ReentrantLock;

public class SharedDataHolder {
    private final ReentrantLock lock = new ReentrantLock();

    public void updateData() {
        lock.lock();
        try {
            // 即使这里发生 I/O 阻塞,虚拟线程也会被安全地卸载,不影响 Carrier Thread
            byte[] data = networkService.fetchData(); 
            process(data);
        } finally {
            lock.unlock();
        }
    }
}

是不是所有的 synchronized 都需要替换?

答案是:不需要。

重构也是有成本的。我们只需要替换**包裹了阻塞操作(如 I/O、数据库访问、长延时第三方接口调用)**的 synchronized 块。

如果你的 synchronized 只是为了保护一段纯内存操作(比如简单的 map.put(key, value),或者几微秒就能执行完的快速计算),那么即使发生了短暂的 Pinning,对系统整体吞吐量的影响也微乎其微,完全可以保持原样。

另外,如果使用的是 final 修饰的、执行极快的同步代码(例如各种工具类中的静态同步方法),通常也不需要刻意重构。

曙光:JEP 491 对 Pinning 的终极救赎

对于开发者来说,手动重构大量的第三方库(例如老旧的 JDBC 驱动、经典的本地缓存库)几乎是不可能完成的任务。

好消息是,Java 官方已经在着手从 JVM 底层彻底根治这个问题。

JEP 491: Broaden Reentrancy of Virtual Threads(目前规划在 Java 24 中引入)中,JVM 团队对 HotSpot 的 ObjectMonitor 机制进行了底层重构。

JEP 491 达成后,虚拟线程在执行 synchronized 块并遭遇阻塞时,也将支持直接卸载(Unmount)。届时,开发者将不再需要为了使用虚拟线程而强行将所有的 synchronized 重构为 ReentrantLock,Pinning 的历史难题将从 JVM 层面成为过去式。

但在当前的 Java 21 / 22 黄金 LTS 时代,养成使用 ReentrantLock 代替 synchronized 的习惯,配合 -Djdk.tracePinnedThreads 进行常态化检测,依然是保障虚拟线程高并发优势的标准姿势。

技术琐话 Java虚拟线程并发编程

评论点评