Java虚拟线程因为synchronized锁死?聊透Pinning问题的成因与改造方案
引入 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),并且在字节码层面生成 monitorenter 和 monitorexit 指令。
由于历史技术债,JVM 现有的调用栈解析和垃圾回收机制,无法在线程持有本地 Monitor 锁的状态下,安全地将该线程的栈帧(Stack Frame)移动到 Java 堆中。
一旦发生 Pinning:
- 虚拟线程无法下放,导致承载它的 Carrier Thread 被同步阻塞。
- 默认情况下,Carrier Thread 线程池(ForkJoinPool)的大小等于 CPU 核心数。
- 如果这几个仅有的 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 进行常态化检测,依然是保障虚拟线程高并发优势的标准姿势。