深入 JVM 源码:虚拟线程在 monitorenter 处为什么无法让出载体线程?

发布时间:2026/10/10 4:51:27
深入 JVM 源码:虚拟线程在 monitorenter 处为什么无法让出载体线程? 上个月我们组把一个核心的网关下游服务升级到了 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 层面的实现它的底层是 AQSAbstractQueuedSynchronizer。当线程获取锁失败需要阻塞时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.tracePinnedThreadsfull或者在轻量监控下使用-Djdk.tracePinnedThreadsshort当虚拟线程在持有 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 块”依然是一条铁律。技术演进从来不是一蹴而就的魔法剥开层层封装看源码才能在工程落地的泥潭中稳稳前行。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询