JVM虚拟线程Pinning问题排查与定位实战
在 Java 21 引入虚拟线程(Virtual Threads)后,高并发应用的吞吐量迎来了质的飞跃。然而,在实际落地过程中,许多团队会遭遇一个严重的性能瓶颈——虚拟线程固定(Virtual Thread Pinning)。
当虚拟线程被“固定”在平台线程(Carrier Thread)上时,它无法在遭遇阻塞操作时主动让出底层的 CPU 资源。这会导致整个 ForkJoinPool 的线程被耗尽,虚拟线程退化为传统的阻塞线程,甚至导致应用出现大面积的高延迟和吞吐量断崖式下跌。
本文将深入探讨 Pinning 问题发生的本质原因,并提供一套生产级别的排查工具与方法。
为什么会发生 Pinning?
虚拟线程的调度是由 JVM 内部的 ForkJoinPool 管理的。在正常情况下,当虚拟线程执行阻塞操作(如网络 I/O、无锁等待)时,它会被“卸载”(Unmount),让出底层的平台线程。
但是在以下两种场景下,虚拟线程无法被卸载,从而发生 Pinning:
- 进入了
synchronized同步块或同步方法。 - 执行了本地方法(Native Method)或外部函数接口(FFI)调用。
在这两种状态下,虚拟线程的栈帧保留在平台线程的本地堆栈中。如果此时虚拟线程尝试进行阻塞操作,它将锁死底层的平台线程,导致其他虚拟线程无法被调度。
排查工具与定位方法
要彻底解决 Pinning 问题,首先需要能够精准捕获并定位到发生 Pinning 的代码位置。以下是三种主流的排查武器。
1. 动态诊断参数:-Djdk.tracePinnedThreads
这是最直接、也是开发测试阶段最推荐的排查手段。JVM 提供了系统参数,用于在发生 Pinning 时直接将堆栈信息打印到控制台。
该参数支持两个级别:
-Djdk.tracePinnedThreads=short:当发生 Pinning 且线程被阻塞时,打印出简短的堆栈信息(仅包含触发 Pinning 的关键栈帧)。-Djdk.tracePinnedThreads=full:打印出完整的线程堆栈。
实战配置演示
在应用启动命令行中加入:
java -Djdk.tracePinnedThreads=full -jar my-service.jar
当程序运行并发生 Pinning 时,控制台会输出类似如下的日志:
Thread[#28,ForkJoinPool-1-worker-1,5,CarrierThreads]
java.base/java.lang.VirtualThread$PinnedThreadPrinter.printStackTrace(VirtualThread.java:311)
java.base/java.lang.VirtualThread.park(VirtualThread.java:581)
java.base/java.lang.System$2.park(System.java:2196)
java.base/java.util.concurrent.locks.LockSupport.park(LockSupport.java:221)
java.base/java.util.concurrent.locks.AbstractQueuedSynchronizer.acquire(AbstractQueuedSynchronizer.java:715)
java.base/java.util.concurrent.locks.AbstractQueuedSynchronizer.acquire(AbstractQueuedSynchronizer.java:938)
java.base/java.util.concurrent.locks.ReentrantLock$Sync.lock(ReentrantLock.java:153)
java.base/java.util.concurrent.locks.ReentrantLock.lock(ReentrantLock.java:322)
com.example.Service.processData(Service.java:45) <== 业务代码位置
com.example.Service$$Lambda$1/0x0000000800c01240.run(Unknown Source)
java.base/java.lang.VirtualThread.run(VirtualThread.java:309)
解析: 通过控制台日志,我们可以清晰地看到 com.example.Service.processData 在第 45 行持有了某种锁,并随后触发了 park 操作,由于此时它处于 synchronized 保护的代码块中,从而导致了 Pinning。
2. 生产级利器:Java Flight Recorder (JFR)
在生产环境下,打印控制台日志(tracePinnedThreads)会带来极高的 I/O 开销,因此不适合常驻开启。此时,JFR(Java飞行记录器) 是最优的选择。
JDK 的虚拟线程实现中内置了特殊的 JFR 事件:jdk.VirtualThreadPinned。
如何通过 JFR 捕获 Pinning
启动 JFR 记录
使用以下命令启动应用,并开启 JFR:
java -XX:StartFlightRecording=disk=true,dumponexit=true,filename=recording.jfr,settings=profile -jar my-service.jar分析 JFR 文件
推荐使用 JDK Mission Control (JMC) 打开生成的
recording.jfr文件。在 JMC 界面中:
- 导航至 "Event Browser"(事件浏览器)。
- 搜索
Virtual Thread Pinned事件。 - 在事件属性中,你可以清晰地看到每一次 Pinning 的持续时间(Duration)以及发生时的调用栈(Stack Trace)。
建议重点关注持续时间超过 20ms 的 Pinning 事件,这些通常是导致系统吞吐量抖动的元凶。
3. 轻量级快照:jcmd 线程转储
如果你怀疑当前进程因为 Pinning 处于死锁或卡死状态,可以使用 jcmd 工具实时抓取虚拟线程的生存状态。
传统的 jstack 无法很好地展示虚拟线程的挂载(Mount)关系,而 jcmd 提供了全新的指令:
jcmd <PID> Thread.dump_to_file -format=json thread_dump.json
或者输出为文本格式:
jcmd <PID> Thread.dump_to_file -format=text thread_dump.txt
在导出的文件结构中,你可以检索 carrierThread 字段。如果某个虚拟线程处于 PARKED 状态,并且其 carrierThread 指向了一个正在运行的平台线程,那么基本可以断定该线程正在经历 Pinning。
经典场景解决之道
定位到 Pinning 问题后,如何进行修复?
场景一:使用 synchronized 进行互斥
问题代码:
public class SharedResource {
private final Object lock = new Object();
public void access() {
synchronized (lock) {
// 执行了阻塞式的网络 I/O 或数据库调用
doSomeHeavyBlockingIO();
}
}
}
解决方案:
使用 java.util.concurrent.locks.ReentrantLock 代替 synchronized。ReentrantLock 内部使用 AQS 实现,其等待和唤醒机制能够与虚拟线程完美协同,不会触发 Pinning。
优化后的代码:
import java.util.concurrent.locks.ReentrantLock;
public class SharedResource {
private final ReentrantLock lock = new ReentrantLock();
public void access() {
lock.lock();
try {
// 此时执行阻塞 I/O,虚拟线程可以正常让出底层平台线程
doSomeHeavyBlockingIO();
} finally {
lock.unlock();
}
}
}
场景二:第三方组件或 JDBC 驱动引发的 Pinning
在实际开发中,大量的 Pinning 并非我们自己的代码引起,而是来自于老旧的底层框架或数据库驱动(例如早期的 MySQL JDBC 驱动、Jedis 等,它们内部广泛使用了 synchronized)。
应对策略:
- 升级依赖: 许多主流开源组件都已经对 JDK 21 的虚拟线程进行了适配。例如,升级到最新的 Spring Boot 3.x、MySQL Connector/J 8.3+,或者改用基于 Netty(支持虚拟线程调度)的非阻塞客户端。
- 限制并发度/线程隔离: 对于无法修改源码的第三方老旧同步组件,如果其调用耗时极短,偶尔的 Pinning 往往对系统大局无伤。但如果调用耗时较长,建议使用独立的经典平台线程池(Platform Thread Pool)或信号量(Semaphore)来限制此类操作的并发数,避免将虚拟线程的 ForkJoinPool 耗尽。