上个月我们组把一个核心的网关下游服务升级到了 JDK 21,并全面开启了虚拟线程(Virtual Threads,即 Project Loom)。刚上线的时候,组里的年轻开发兴奋地指着监控仪表盘喊:“然哥,你看并发压测,直接开了 10 万个虚拟线程,内存占用小得惊人!”
然而,好景不长。当我们在灰度集群注入真实的外部慢 RPC 依赖时,系统吞吐量不升反降,CPU 利用率卡在低位,大量请求超时。导出 Thread Dump 一看,底层的载体线程池(Carrier Thread Pool,也就是ForkJoinPool-1-worker-*)总共就配了 16 个系统物理线程,竟然全部处于卡死状态。
罪魁祸首排查出来后令人啼笑皆非:在一个古老的日志埋点组件里,竟然包含了一段极为简短的互斥同步代码:
synchronized (lock) { doRemoteOrBlockingIo(); }这段代码直接触发了虚拟线程臭名昭著的**线程固定(Thread Pinning)**现象。
几乎所有接触过 JDK 21 的 Java 工程师都听过一句话:“不要在虚拟线程里使用synchronized,改用ReentrantLock。” 但绝大多数人只停留在知其然,不知其所以然。今天我们直接翻开 HotSpot JVM 的 C++ 源码,从底层的monitorenter字节码指令和 Continuation 机制出发,彻底搞清楚:虚拟线程在执行synchronized发生阻塞时,为什么就是无法让出底层的载体线程?
虚拟线程的“让出”本质:Continuation 的栈帧转移
要理解为什么不能让出,首先要明白虚拟线程“能让出”时究竟干了什么。
虚拟线程本质上是运行在操作系统载体线程(Carrier Thread)之上的用户态逻辑线程。它的核心魔法基于 JVM 内部的jdk.internal.vm.Continuation。
当虚拟线程执行普通的阻塞操作(比如SocketInputStream.read()或LockSupport.park())时,调用链会触发VirtualThread.park(),进而调用Continuation.yield():
虚拟线程挂起 (Yield) 虚拟线程恢复 (Run) ┌───────────────────────────┐ ┌───────────────────────────┐ │ 载体线程物理栈 (Native OS) │ │ 载体线程物理栈 (Native OS) │ │ ┌─────────────────────┐ │ │ ┌─────────────────────┐ │ │ │ 虚拟线程 Java 栈帧 │ │ │ │ (从堆内存拷贝回物理栈)│ │ │ └──────────┬──────────┘ │ │ └─────────────────────┘ │ └─────────────┼─────────────┘ └───────────────────────────┘ │ (Chunk 拷贝卸载) ▲ ▼ │ ┌───────────────────────────┐ ┌─────────────┴─────────────┐ │ JVM 堆内存 (Heap) │ │ JVM 堆内存 (Heap) │ │ [Continuation Object] │ │ [Continuation Object] │ └───────────────────────────┘ └───────────────────────────┘简而言之:“让出”就是把载体线程物理栈上的 Java 栈帧打包(Freeze)成一个个 StackChunk 对象,拷贝保存到堆内存中;载体线程得以清空栈帧,立刻去调度执行下一个虚拟线程。
翻开 JVM 源码:monitorenter处的锁与物理栈绑定
然而,当字节码执行到monitorenter(即 Java 中的synchronized)时,事情的底层机制完全变了。
在 HotSpot 源码中,每个 Java 对象头中的 Mark Word 可以升级膨胀为指向重量级锁ObjectMonitor的指针。我们来看 OpenJDK 中负责处理Continuation.yield()的核心判定逻辑(位于continuationFreezeThaw.cpp):
// OpenJDK 源码片段:continuationFreezeThaw.cpp bool Continuation::is_pinned(JavaThread* thread, oop continuation_scope) { // 检查当前线程是否存在本地栈帧绑定(Pinning) if (thread->is_pinned()) { return true; } // 关键检查:如果当前线程持有任何 ObjectMonitor 锁,直接判定为 Pinned! if (thread->held_monitor_count() > 0) { return true; } // 检查是否包含 JNI 原生栈帧(Native Frame) if (has_native_frames(thread)) { return true; } return false; }注意这行致命的代码:
if (thread->held_monitor_count() > 0) return true;为什么 JVM 只要发现held_monitor_count() > 0,就必须铁面无私地判定为 Pinned,严禁执行栈帧 Freeze?
这涉及到 HotSpot 历史悠久的ObjectMonitorC++ 内存布局:
1.ObjectMonitor内部持有者是JavaThread*(OS 物理线程指针)
在 HotSpot 引擎中,ObjectMonitor结构体中的_owner字段直接存放的是操作系统物理线程的指针:
class ObjectMonitor { void* volatile _owner; // 存储的是 OS 线程(JavaThread*),而非逻辑上的 VirtualThread ... };如果允许虚拟线程在持有 monitor 的状态下 yield,那么这个物理线程就会被挪去执行其他虚拟线程。然而,底层的锁所有权机制只认当前的JavaThread*!一旦下一个虚拟线程试图进入同步块,它会发现锁的持有者居然就是“当前正在执行的物理线程”,从而发生灾难性的死锁或重入状态错乱。
2. 轻量级锁的 BasicObjectLock 位于载体线程的物理栈顶
在偏向锁被逐步废弃、对象处于轻量级锁状态时,JVM 会在当前物理栈帧上分配一个BasicObjectLock结构,对象的 Mark Word 直接存放着指向该栈上地址的指针(Displaced Mark Word)。
如果此时强行卸载栈帧到堆中,Mark Word 中原本指向物理栈地址的指针将瞬间变成悬空指针(Dangling Pointer),引发 JVM 进程崩溃(Segmentation Fault)。
3. C++ 内部运行时的锁竞争队列与唤醒机制
ObjectMonitor内部维护了_cxq和_EntryList两个阻塞等待队列,里面的等待节点封装的是操作系统底层的等待事件(ParkEvent)。这些系统原语是直接与操作系统内核线程绑定的。在早期 JVM 架构中,根本没有给用户态 Continuation 预留挂起和唤醒的接口通道。
为什么ReentrantLock却能完美支持虚拟线程?
很多人会好奇:同样是加锁互斥,为什么java.util.concurrent.locks.ReentrantLock就完全不会导致 Pinning?
因为ReentrantLock是纯 Java 层面的实现!
它的底层是 AQS(AbstractQueuedSynchronizer)。当线程获取锁失败需要阻塞时,AQS 调用的是:
LockSupport.park(this);而在 JDK 21 的重构中,LockSupport.park()是为虚拟线程量身定制的。它发现当前是VirtualThread,不会调用操作系统的pthread_mutex或底层Parker,而是直接调用Continuation.yield()。
AQS 的等待队列(Node 链表)全部常驻在 JVM 堆内存里,锁的所有者(exclusiveOwnerThread)也是一个普通的 Java 对象引用。所以,它的状态完全独立于底层的 C++ 物理线程栈,无论怎么在载体线程之间迁徙,都不会造成内存混乱。
生产排查:如何揪出代码中的 Pinning 隐患?
在生产环境下,排查由于synchronized导致的 Pinning 并不困难。JVM 原生提供了非常硬核的诊断参数。
启动应用时,加上以下参数:
-Djdk.tracePinnedThreads=full或者在轻量监控下使用:
-Djdk.tracePinnedThreads=short当虚拟线程在持有 Monitor 且发生阻塞时,控制台会立刻打印出清晰的物理堆栈跟踪:
Thread[#45,ForkJoinPool-1-worker-3,5,CarrierThreads] java.base/java.lang.VirtualThread$VThreadContinuation.onPinned(VirtualThread.java:185) java.base/jdk.internal.vm.Continuation.onPinned0(Native Method) java.base/jdk.internal.vm.Continuation.yield(Continuation.java:357) java.base/java.lang.VirtualThread.yieldContinuation(VirtualThread.java:370) java.base/java.lang.VirtualThread.park(VirtualThread.java:499) java.base/java.util.concurrent.locks.LockSupport.park(LockSupport.java:371) com.example.service.LegacyCache.get(LegacyCache.java:42) <== pinned here: holding monitor只要看到<== pinned here,就能精准定位到哪一行代码在持有synchronized的同时触发了阻塞。
总结与演进展望(JEP 491)
虚拟线程是 Java 历史上最具革命性的并发特性之一,但它的强大并非毫无代价。理解其底层的Continuation栈转移机制与 HotSpotObjectMonitor的历史包袱,能够让我们在架构选型时少走很多弯路。
虽然从后续的 JDK 版本(如 JEP 491: Synchronize Virtual Threads without Pinning)开始,JVM 团队重写了 HotSpot 内部的ObjectMonitor,使得synchronized终于也能支持虚拟线程非阻塞让出,但目前在生产主流的 JDK 21 LTS 环境下,“用 ReentrantLock 替换含 IO 的 synchronized 块”依然是一条铁律。
技术演进从来不是一蹴而就的魔法,剥开层层封装看源码,才能在工程落地的泥潭中稳稳前行。