一、前言
Java 21 正式引入虚拟线程后,很多开发者迫不及待地将其应用到生产环境。然而,不少人很快发现:虚拟线程的性能远低于预期,甚至出现了服务卡死的情况。
罪魁祸首之一就是Pinning(固定)——当虚拟线程在synchronized块中阻塞时,它会被“钉”在载体线程上,导致载体线程无法去执行其他虚拟线程。
📌核心结论先行:在 JDK 21 ~ JDK 23 中,
synchronized会导致虚拟线程 Pinning;JDK 24 通过 JEP 491 彻底解决了这个问题。本文将从底层原理到 JDK 24 的解决方案,带你完整理解这个问题的来龙去脉。
二、虚拟线程基础回顾
2.1 虚拟线程的挂载与卸载
虚拟线程(Virtual Thread)是 JDK 而非操作系统提供的轻量级线程。它的核心优势在于:当虚拟线程发生阻塞时,JVM 可以将其从载体线程(Carrier Thread)上“卸载”,让载体线程去执行其他虚拟线程。
虚拟线程底层复用载体线程来执行任务。当虚拟线程发生阻塞时,JVM 会将虚拟线程从载体线程上“卸载”(yield)——中断载体线程,把线程栈中涉及虚拟线程的部分复制到堆内存中保存,载体线程再去执行其他任务。IO 就绪后,JVM 重新装载虚拟线程继续执行。
2.2 什么是 Pinning?
Pinning 指的是虚拟线程在阻塞时无法从载体线程上卸载的状态。一旦发生 Pinning,载体线程就被这个虚拟线程“独占”了,无法去执行其他虚拟线程——虚拟线程的核心优势瞬间丧失。
三、synchronized 为什么会导致 Pinning?
3.1 根本原因:锁的所有者是载体线程
在 Java 24 之前,当虚拟线程进入synchronized方法或代码块时,JVM 会将锁(Monitor)的所有者记录为底层的载体线程(Carrier Thread),而不是虚拟线程本身。
这带来了一个根本性的矛盾:
- 如果 JVM 允许虚拟线程在持有锁时被卸载,那么载体线程就可能被调度去执行其他虚拟线程;
- 但此时锁的所有者信息仍然指向这个载体线程,会导致锁的所有权信息混乱。
为了避免这种混乱,JVM 采取了一个简单粗暴的策略:只要虚拟线程在 synchronized 块内,就禁止将其从载体线程上卸载。
3.2 更严重的情况:进入 synchronized 失败时也会 Pinning
根据 JEP 425 的原始描述,虚拟线程在“执行 synchronized 块内的代码”时会被 Pinning。但实际上,当虚拟线程尝试进入 synchronized 块但获取锁失败时,它就已经被 Pinning 了。
这意味着:
synchronized(object){// 虚拟线程在这段代码执行期间被 Pinning// 即使只是在等待获取锁,就已经被 Pinning 了}这个行为在早期引发了广泛的讨论——有开发者发现,MongoDB Java Driver 使用synchronized块后,应用程序因为载体线程被“消耗”而挂起。Alan Bateman(Project Loom 核心开发者)确认这是预期行为:“当前限制是,虚拟线程在阻塞等待 monitor 时无法被卸载,因为 monitor 被另一个线程持有。”
3.3 为什么 Pinning 会导致程序卡死?
阿里云开发者社区的一篇文章深入分析了 synchronized Pinning 导致死锁的场景:
- 虚拟线程 A 成功获取 object monitor,但需要等待某种资源,因此 Pinning;
- 其余许多虚拟线程在等待 object monitor,占用了剩余的载体线程,因此 Pinning;
- 能够释放某种资源的虚拟线程任务没有载体线程可以执行,无法唤醒虚拟线程 A;
- 程序完全停止。
根本原因在于:ObjectMonitor::enter的相关逻辑是用 C/C++ 编写的,其调用栈是 C native frame,含有 native frame 的虚拟线程无法进行上下文切换。
3.4 完整的 Pinning 场景
综合来看,在 Java 21~23 中,以下两种场景会导致虚拟线程 Pinning:
| Pinning 场景 | 说明 |
|---|---|
| 执行 synchronized 块/方法内的代码 | 无论是否发生阻塞,只要在 synchronized 内就被 Pinning |
| 执行 native 方法或外部函数 | JNI 调用、Foreign Function & Memory API |
四、诊断 Pinning 问题
4.1 JFR 事件:jdk.VirtualThreadPinned
JDK 提供了 JFR(Java Flight Recorder)事件来诊断 Pinning 问题。jdk.VirtualThreadPinned事件表示虚拟线程被 Pinning 且载体线程未被释放超过阈值时间(默认 20ms)。
使用方式:
# 启动应用时开启 JFRjava-XX:StartFlightRecording:filename=pinning.jfr,duration=60s MyApp# 或使用 jcmd 动态开启jcmd<pid>JFR.startname=pinningduration=60sfilename=pinning.jfr4.2 分析 JFR 记录
jfr print--eventsjdk.VirtualThreadPinned pinning.jfr输出示例:
jdk.VirtualThreadPinned { startTime = 10:31:19.654 duration = 25 ms eventThread = "virtual-thread-1" (virtual) stackTrace = [ java.lang.Object.wait(Object.java) com.example.MyService.process(MyService.java:42) ... ] }💡注意:
jdk.VirtualThreadPinned事件只在运行时尝试 park 一个被 Pinning 的线程时才会触发。例如,Thread.sleep()调用会触发该事件,但仅仅是进入 synchronized 块不会触发。
4.3 传统的 jdk.tracePinnedThreads
在 JDK 21~23 中,可以通过系统属性输出 Pinning 时的线程栈:
java-Djdk.tracePinnedThreads=full MyApp不过在 JDK 24 的 EA 构建中,这个属性已经不再打印任何内容,建议使用 JFR 事件替代。
五、JDK 24 的终极方案:JEP 491
5.1 概述
JEP 491: Synchronize Virtual Threads without Pinning是 JDK 24 中最重要的虚拟线程改进之一。它的目标是:让虚拟线程在 synchronized 块中阻塞时,能够释放其底层平台线程供其他虚拟线程使用,从而消除几乎所有虚拟线程被 Pinning 的情况。
5.2 核心实现原理
JEP 491 的核心思路是让锁的所有权不再依赖于具体的载体线程。
具体来说,做了两件事:
第一,锁记录的转移。当虚拟线程被卸载(冻结)时,JVM 会将其持有的锁信息从载体线程的 LockStack 中复制出来,保存到虚拟线程的 stackChunk 中;当虚拟线程被重新挂载(解冻)时,再将锁信息恢复回去。
第二,锁所有者的记录方式变更。对于膨胀锁(Inflated Monitor),JVM 不再记录持有锁的载体线程(JavaThread*),而是直接记录拥有该锁的java.lang.Thread的 ID(tid)。这样锁的所有者就与具体的载体线程解耦了。
5.3 改动的范围
JEP 491 的实现覆盖了以下场景:
- 允许虚拟线程在持有 monitor的情况下被卸载
- 允许虚拟线程在阻塞等待获取 monitor时被卸载
- 允许虚拟线程在
Object.wait()及定时等待变体中阻塞时被卸载
该修复覆盖了所有 4 个实现 continuations 的平台:x64、aarch64、riscv 和 ppc。
5.4 效果验证
社区提供了一个直观的性能对比 Demo。测试代码启动 5000 个虚拟线程,每个线程先做 CPU 计算,然后获取一个独立的锁并在锁内阻塞 5ms,虚拟线程调度器并行度限制为 1:
| JDK 版本 | 总执行时间 |
|---|---|
| JDK 21 | 31.791 秒 |
| JDK 24 | 0.454 秒 |
性能提升接近70 倍。
💡 这个 Demo 的关键设计是:每个任务使用不同的锁对象。如果使用共享锁,synchronized 块本身就是串行执行的,无法体现 Pinning 带来的差异。
六、JDK 21~23 的应对方案:ReentrantLock
在 JDK 24 发布之前,官方推荐的解决方案是使用ReentrantLock替代synchronized。ReentrantLock属于java.util.concurrent.locks包,其锁机制与 Monitor 不同,不会导致虚拟线程 Pinning。
6.1 代码对比
// ❌ JDK 21~23 中会 Pinning 的写法publicsynchronizedvoidwrongMethod(){try{Thread.sleep(1000);// 阻塞操作导致 Pinning}catch(InterruptedExceptione){Thread.currentThread().interrupt();}}// ✅ 使用 ReentrantLock,不会 PinningprivatefinalReentrantLocklock=newReentrantLock();publicvoidrightMethod(){lock.lock();try{Thread.sleep(1000);// 虚拟线程可以被卸载}catch(InterruptedExceptione){Thread.currentThread().interrupt();}finally{lock.unlock();}}6.2 注意事项
- 短时间持有的锁不需要替换:JEP 425 原文指出,只在锁内执行内存操作且不涉及长时间阻塞的场景,不需要替换
- 只在锁保护 IO 操作时才需要替换:MongoDB 的案例中,开发者最终只需要替换保护 socket IO 的锁,而不是所有锁
6.3 JDK 24+ 的变化
从 JDK 24 开始,synchronized已经不再需要替换为ReentrantLock了。如果你的项目仍在使用 JDK 21~23,替换方案依然有效;但如果你已经升级到 JDK 24+,直接用synchronized即可。
⚠️ 社区的提醒:不要再拿 JDK 21 的“口诀”去改 JDK 25 的代码了。JDK 24 已经修复了 synchronized 的 Pinning 问题,盲目替换反而增加代码复杂度。
七、其他 Pinning 场景
7.1 Native 代码调用
JEP 491 只解决了synchronized导致的 Pinning。调用 native 代码(JNI 或 Foreign Function & Memory API)仍会导致 Pinning,这在所有 Java 版本中都存在。
原因是虚拟线程的栈存储在 Java 堆中,可以在内存中移动。但 native 代码的栈帧难以移动,因为它们可能包含指向栈本身的指针,而 Java 无法感知这些指针。
7.2 类加载与初始化
类加载和初始化过程中涉及 VM 内部的 C/C++ 代码,如果此时遇到需要切出的事件,也会导致 Pinning。这类问题比较隐蔽,需要通过 JFR 事件来定位。
八、总结
| 对比项 | JDK 21~23 | JDK 24+ |
|---|---|---|
| synchronized 是否 Pinning | ✅ 是 | ❌ 否 |
| 等待 monitor 是否 Pinning | ✅ 是 | ❌ 否 |
| Object.wait() 是否 Pinning | ✅ 是 | ❌ 否 |
| native 代码是否 Pinning | ✅ 是 | ✅ 是(未改变) |
| 推荐方案 | 用 ReentrantLock 替代 | 可直接使用 synchronized |
核心要点回顾:
- Pinning 的本质是虚拟线程在阻塞时无法从载体线程卸载,导致载体线程被“浪费”
- JDK 21~23 的问题在于 JVM 将 synchronized 锁的所有者记录为载体线程,为了避免锁所有权混乱,禁止虚拟线程卸载
- JDK 24 的 JEP 491通过将锁的所有者从载体线程改为虚拟线程本身,实现了锁所有权与载体线程的解耦,从根本上解决了 Pinning 问题
- Native 代码的 Pinning在所有版本中都存在,目前尚无解决方案
- 诊断工具:使用 JFR 的
jdk.VirtualThreadPinned事件来定位 Pinning 问题
如果你的项目还在 JDK 21~23,建议:
- 对长时间持有且涉及 IO 的
synchronized块,替换为ReentrantLock - 通过 JFR 监控 Pinning 事件,定位性能瓶颈
- 如果条件允许,尽快升级到 JDK 24+,从根源上解决问题