JVM虚拟线程的底层实现原理分析前言虚拟线程的底层实现原理1. M:N 调度架构与 Linux 内核交互抽象层级映射内核视角的“无感”与 CPU 上下文切换对比用户态调度器ForkJoinPool2. 堆内栈结构 stackChunkOop 深度剖析OpenJDK C 结构声明堆内物理内存布局动静态扩展与 GC 交互机制3. Mount / Unmount 汇编与 C 级实现Unmount (Freeze / 冻结流程)Mount (Thaw / 恢复流程)4. Pinning (钉住) 机制与锁演进导致 Pinning 的两大根源Pinning 时的 ForkJoinPool 补偿机制JDK 21→ \to→JDK 23/24 的 synchronized 解绑重构5. 异步非阻塞 I/O 与 Linux epoll 内核协作完整 IO 调用链与状态流转6. 挂载/卸载与 I/O 调度可视化7. 系统工程师 Profiling 与 eBPF / JFR 观测指南JDK Flight Recorder (JFR) 专属事件eBPF 联合追踪系统调用与 JVM 事件消除 Safepoint Bias 的 Profiling前言本文旨在记录近期研读Java源码的学习心得与疑难问题。由于个人理解水平有限文中内容难免存在疏漏恳请读者不吝指正。虚拟线程的底层实现原理Project Loom 通过在 JVM 用户态实现Continuation执行续体与Scheduler调度器的协同将传统 Java 的 1:1 线程模型重构为 M:N 的轻量级线程架构。在系统工程师视角下虚拟线程本质上是一种由用户态代码控制、以 Java 堆内存为栈存储、对 Linux 内核完全透明的任务调度机制。1. M:N 调度架构与 Linux 内核交互抽象层级映射虚拟线程把“执行实体”与“内核线程”进行解耦形成了四层映射结构------------------------------------------------------------------------- | [VirtualThread 1] [VirtualThread 2] ... [VirtualThread M] | User Space (Java) ------------------------------------------------------------------------ | java.lang.Continuation.yield() / run() v ------------------------------------------------------------------------- | Carrier Threads (ForkJoinPool Worker Threads, N ≈ CPU Cores) | User Space (HotSpot) ------------------------------------------------------------------------ | HotSpot JavaThread | C runtime representation of thread | ------------------------------------------------------------------------ | 1:1 POSIX pthread_create() v ------------------------------------------------------------------------- | Linux Kernel Task (task_struct 1 ... task_struct N) | Kernel Space ------------------------------------------------------------------------ | Linux CFS / EEVDF Scheduler v ------------------------------------------------------------------------- | Hardware CPU Cores (Core 0, Core 1, ...) | Hardware -------------------------------------------------------------------------实体所在空间关键数据结构 / 源码路径内存与调度责任VirtualThreadJava 堆java.lang.VirtualThread保存运行状态、绑定的Continuation以及ForkJoinPool引用。ContinuationHotSpot / 堆src/hotspot/share/runtime/continuation.cpp执行续体抽象负责栈帧的切出 (Freeze) 与恢复 (Thaw)。Carrier ThreadHotSpot 用户态src/hotspot/share/runtime/thread.cpp(JavaThread)载体线程本质是一个传统的 1:1JavaThread。Linux Kernel Task内核态struct task_struct(Linux Kernel)内核可调度的最小单位由 CFS/EEVDF 调度器分配 CPU 时间片。内核视角的“无感”与 CPU 上下文切换对比Linux 内核调度器CFS/EEVDF只感知到 Carrier Thread 对应的N NN个task_struct。对内核而言并没有暴增的线程上下文。内核态线程切换 (1 : 1 1:11:1平台线程):涉及 Ring 3→ \to→Ring 0 陷入、更新 CR3 寄存器若跨进程、保存/恢复通用寄存器组RSP、RBP、RAX、RBX…、XMM/AVX 浮点寄存器、刷新 TLB 页表缓存以及 CPU L1/L2 Cache 污染。切换开销通常在1 ∼ 10 μ s 1\sim 10\ \mu\text{s}1∼10μs。用户态虚拟线程切换 (M : N M:NM:N虚拟线程):不触发任何内核 Trap。仅在 HotSpot 运行时完成对寄存器RSP、RBP、RIP的重置并将 Native Stack 上的栈帧数据增量复制到 Java 堆中的stackChunkOop对象。切换开销降至10 ∼ 100 ns 10\sim 100\ \text{ns}10∼100ns。用户态调度器ForkJoinPool虚拟线程默认使用专门定制的全局ForkJoinPool作为调度器FIFO/LIFO 任务队列虚拟线程提交到ForkJoinPool时优先存入 Worker Thread 的本地WorkQueue。Work-Stealing (工作窃取):当某个 Carrier Thread 的本地队列为空时会随机从其他 Carrier Thread 队列的尾部“窃取”VirtualThread任务极大减少了线程间锁竞争。2. 堆内栈结构stackChunkOop深度剖析传统平台线程必须预先向 OS 申请固定大小的 Native Stack由-Xss参数指定默认1 MB 1\ \text{MB}1MB极其消耗虚拟地址空间与物理内存。虚拟线程将栈帧存储在 Java 堆中的stackChunkOop对象里按需分配与扩缩容。OpenJDK C 结构声明stackChunkOop位于 HotSpot 源码src/hotspot/share/oops/stackChunkOop.hppclassstackChunkOopDesc:publicinstanceOopDesc{private:int_parent;// 指向父级 stackChunkOop (当栈过深时串联为链表)int_size;// 当前 Chunk 占用的 Word 数量 (物理容量)int_sp;// 当前栈顶在 Payload 中的 Offsetaddress _pc;// 挂起时的 Return Address (指令指针 RIP)int_argsize;// 调用参数字节数uint8_t_flags;// 状态标志 (如包含 GC 标记、混合帧等)int_max_thawing_size;// 恢复至 Native Stack 所需的最大字节数oop _cont;// 指向关联的 java.lang.Continuation 对象public:// 紧随 Header 之后为连续的字节流 (Payload)存储真正的 Java 栈帧 (Interpreter / C1 / C2)addressstart_address()const;inlineoopparent()const;inlinevoidset_parent(oop p);};堆内物理内存布局------------------------------------------------------------------- | Mark Word (64 bit) | Object Header ------------------------------------------------------------------- (instanceOopDesc) | Compressed Klass Pointer (32 bit) | ------------------------------------------------------------------- | _parent (oop offset) - 指向父级 stackChunkOop (链表结构) | | _size (int) - Chunk 物理容量 | | _sp (int) - 栈顶逻辑指针 Offset | Metadata | _pc (address) - 恢复执行的代码入口 (RIP) | Fields | _argsize (int) - 参数大小 | | _flags (uint8_t) - Chunk 状态标志 | | _max_thawing_size (int) - 恢复最大字节数 | | _cont (oop) - 关联的 Continuation 实例 | ------------------------------------------------------------------- | Frame Payload Area (连续字节流按栈增长方向排列) | | ------------------------------------------------------------- | | | Frame N (Top Frame: 局部变量表 操作数栈) | | Serialized | ------------------------------------------------------------- | Java Frames | | Frame N-1 (Caller Frame) | | (C1/C2 JIT | ------------------------------------------------------------- | or Interpreter) | | ... | | | ------------------------------------------------------------- | | | Base Frame (Continuation.run 入口帧) | | | ------------------------------------------------------------- | -------------------------------------------------------------------动静态扩展与 GC 交互机制Chunk 链表化若虚拟线程调用栈非常深单次分配的stackChunkOop容纳不下时HotSpot 会分配新的stackChunkOop并通过_parent字段链接成链表Chunk Stacking避免触发大对象连续内存分配。GC 遍历与 OopMapstackChunkOop存放在 Java 堆中因此里面的栈帧若包含引用类型变量GC 必须对其进行标记。OopMap 解析当 GC如 G1、ZGC扫描到stackChunkOop时HotSpot 解析编译期生成的OopMap定位出 Payload 中所有存放指针的 Offset。ZGC Colored Pointers Load Barriers在 ZGC 下若stackChunkOop内的指针属于旧地址当 Continuation 触发 Thaw恢复读取栈帧时读屏障 (Load Barrier) 会触发指针自愈 (Self-Healing)把旧指针修正为重定位后的新地址。3. Mount / Unmount 汇编与 C 级实现虚拟线程的生命周期核心是Mount挂载/Thaw与Unmount卸载/Freeze。关键实现在src/hotspot/share/runtime/continuationFreeze.cpp与continuationThaw.cpp。[VirtualThread.start()] / [unpark] | v -------------------- | ForkJoinPool | (分配 Carrier Thread) -------------------- | v Mount (Continuation.run / Thaw) ----------------------------------------------------------------------- | Carrier Native Thread Stack | | 1. 从 stackChunkOop 拷贝栈帧至 Native Stack | | 2. 修正 RBP/RSP 指针与 Return Address | | 3. Thread.currentThread() 绑定为 VirtualThread | | 4. ret 指令跳转至 _pc 地址恢复执行 | ----------------------------------------------------------------------- | | 触发阻塞操作 (LockSupport.park / Socket Read) v Unmount (Continuation.yield / Freeze) ----------------------------------------------------------------------- | Freeze to Heap | | 1. 从当前 RSP 向上漫游至 ContinuationEntry 标记帧 | | 2. 打包 Native 栈帧增量拷贝至 stackChunkOop | | 3. 解绑 Thread.currentThread() | | 4. 重置 Carrier Native Stack 的 RSP/RBP | | 5. Carrier Thread 返回 ForkJoinPool 重新调度 | -----------------------------------------------------------------------Unmount (Freeze / 冻结流程)当虚拟线程调用LockSupport.park()或 Socket Read 阻塞时进入 C 运行时Java 层触发Continuation.yield()通过 Standard Stub 陷入 HotSpot C 函数Continuation::freeze。定位 Anchor 帧查找 Carrier 线程 Native Stack 上的ContinuationEntry标记帧这是本次 Mount 时的起始位置。Stack Walking (栈漫游):从当前 CPU 的R S P RSPRSP栈顶开始向高地址漫游逐帧检查从当前帧到ContinuationEntry帧之间的所有栈帧解析 C2/C1 JIT 编译帧的 Frame Size、OopMap。解析 Interpreter 帧的R B P RBPRBP与 Bytecode Pointer (R 13 R13R13/R S I RSIRSI)。增量写入stackChunkOop将此范围内的 Native Stack 内存字节拷贝到stackChunkOop的 Payload 区域同步更新_sp与_pc。恢复 Carrier Stack将 CPU 的R S P RSPRSP和R B P RBPRBP修正回ContinuationEntry之前的数值逻辑上“抹去”了 Native Stack 上的这一段 Java 栈帧。上下文解绑清除 Carrier Thread 上的 Thread-Local 变量将Thread.currentThread()重置为 Carrier 自身Continuation::freeze返回true。 Carrier 线程继续执行ForkJoinPool的调度 Loop。Mount (Thaw / 恢复流程)当挂起的虚拟线程被唤醒如收到unpark信号调度选中ForkJoinPool挑选一个空闲 Carrier Thread调用Continuation.run()。进入 C Thaw触发Continuation::thaw汇编 Stub。检查 Stack 空间计算stackChunkOop中解压所需的 Native Stack 空间若超过 Carrier 线程 Native Stack 的剩余容量触发 Stack Overflow / 扩展逻辑。内存拷贝 (Heap→ \to→Native Stack):将stackChunkOop里的 Payload 字节拷贝回当前 Carrier 线程 Native Stack 栈顶。修正帧内指针R B P RBPRBP链修正由于 Native Stack 每次 Mount 的基址可能变化必须遍历拷贝过来的所有帧修正其内部保存的旧R B P RBPRBP地址重新指向新的 Stack 地址。JIT Safepoint Stub 修正确保 Deoptimization 与 Safepoint Polling Page 依然指向正确的硬件物理地址。切换 CPU 寄存器并跳转将 CPU 的R S P RSPRSP和R B P RBPRBP设置为解压后的栈顶和基址。将 Thread-Local 的currentThread覆盖为VirtualThread实例。执行ret汇编指令跳转至_pc记录的代码地址恢复虚拟线程执行。4. Pinning (钉住) 机制与锁演进当虚拟线程在执行特定操作时HotSpot无法将其栈帧从 Native Stack 卸载Freeze。这种状态被称为Pinning。导致 Pinning 的两大根源原生代码调用 (JNI / Native Frames):栈帧中包含 C/C 的 JNI 本地方法调用Native 代码直接引用了 Native Stack 的内存地址JVM 无法安全移动这些地址。synchronized块 / 方法JDK 21 及以前:当虚拟线程获取了对象监视器锁ObjectMonitorHotSpot 的ObjectMonitor内部持有指向当前 Carrier 线程JavaThread*的指针。Pinning 时的 ForkJoinPool 补偿机制当虚拟线程被 Pinned 且发生阻塞时它会阻塞底层的 Carrier 线程。为了避免导致全局死锁或并行度下降ForkJoinPool会触发Compensating Worker Thread机制[Virtual Thread (Pinned)] | 触发 Blocking I/O / Park | v [Carrier Thread 被被迫同步阻塞] | v [ForkJoinPool 监测到 Active Worker Count Parallelism] | v [动态 Spawn 一个新的 Carrier Thread] (保持 OS 算力并行度但增加了 pthread 数量开销)JDK 21→ \to→JDK 23/24 的synchronized解绑重构为了彻底解决synchronized导致的 Pinning 问题OpenJDK 在 JDK 23 中对ObjectMonitor进行了重大重构JEP 491: Synchronize Virtual Threads without Pinning以前JDK 21ObjectMonitor内部的_owner必须是JavaThread*物理线程指针必须 Pin 在 Carrier 上。重构后JDK 23_owner字段升级为可以接受VirtualThread对象的指针或匿名锁状态锁竞争列表EntryList/WaitSet改由线程无关的数据结构维护。当虚拟线程在synchronized内部发生阻塞时可以像ReentrantLock一样正常进行Continuation.yield()并卸载栈帧。5. 异步非阻塞 I/O 与 Linuxepoll内核协作虚拟线程允许用“同步阻塞的代码写法”实现“高并发异步非阻塞”的吞吐量依赖于底层 JDK 网络库NIO与 Linuxepoll的联动。完整 IO 调用链与状态流转[VirtualThread] - Socket.read() | v [jdk.internal.net.SocketCleanable] - 隐式设置为 O_NONBLOCK 模式 | v [System Call] - sys_read(fd, buf, len) | --- [数据已在 Linux Socket Recv Buffer] --- 立即返回数据不发生 Switch/Unmount | --- [返回 -EAGAIN / -EWOULDBLOCK] | v [Poller Thread] - epoll_ctl(epoll_fd, EPOLL_CTL_ADD, fd, EPOLLIN) | v [VirtualThread] - Continuation.yield() - Unmount (Carrier 线程被释放) | (Linux 内核等待网卡硬件中断) | [Linux Kernel] - 硬件中断 - 拷贝数据包到 Recv Buffer - 触发 epoll 事件 | v [JVM Poller Thread] - epoll_wait() 被唤醒 | v [Unpark VThread] - LockSupport.unpark(vthread) - 重新压入 ForkJoinPool 队列 | v [Carrier Thread] - Mount 恢复 VirtualThread 执行 - 重新发起 sys_read(fd) 成功获取数据默认非阻塞模式当 Java 创建 Socket 时JDK 底层调用的socket()系统调用会自动加上O_NONBLOCK标记。捕获-EAGAIN当虚拟线程调用read()底层发起sys_read系统调用。若内核缓冲区无数据内核立即返回-EAGAIN。注册PollerJava 运行时捕获到-EAGAIN后将 fd 与当前的VirtualThread实例打包调用epoll_ctl注册到 JVM 后台单例PollerThread维护的epoll_fd中。Yield 挂起虚拟线程主动调用Continuation.yield()完成 Unmount Carrier 线程立刻去处理其他任务。epoll_wait唤醒与 Re-mount网卡收到数据包触发内核中断后epoll_wait捕获事件。PollerThread根据 fd 找到绑定的VirtualThread调用LockSupport.unpark(vthread)将其放回ForkJoinPool队列等待 Carrier 挂载并重新执行sys_read。6. 挂载/卸载与 I/O 调度可视化下图演示了虚拟线程在发生 I/O 阻塞时的Freeze (卸载)、Epoll 注册以及唤醒后的Thaw (重新挂载)完整生命周期图解7. 系统工程师 Profiling 与 eBPF / JFR 观测指南由于虚拟线程是在用户态调度的传统的 Linux 性能工具如top、htop、原生perf只能看到 Carrier 线程无法直接识别虚拟线程。必须结合 JVM 特定的跟踪机制与 eBPF。JDK Flight Recorder (JFR) 专属事件JFR 事件名称触发时机关键属性 / 诊断价值jdk.VirtualThreadStart虚拟线程创建并开始执行时追踪虚拟线程创建频率与生命周期。jdk.VirtualThreadEnd虚拟线程执行完毕销毁时计算虚拟线程平均生存周期。jdk.VirtualThreadSubmit虚拟线程被提交/重新提交到ForkJoinPool时评估调度队列延时。jdk.VirtualThreadPinned虚拟线程发生 Pinning 无法 Unmount 时重点关注包含 Pinning 发生的具体 Java 栈轨迹 (pinningReason)。可通过配置 JFR 策略打印 Pinning 事件java-XX:FlightRecorder\-XX:StartFlightRecordingfilenameloom.jfr,settingsprofile\-Djdk.tracePinnedThreadsfull\-jarapp.jareBPF 联合追踪系统调用与 JVM 事件结合bpftrace脚本可精准抓取 JVM 虚拟线程引发的epoll_ctl与线程切换行为// 跟踪 JVM Poller 注册 epoll 事件的频次与 fd tracepoint:syscalls:sys_enter_epoll_ctl /comm java/ { epoll_ops[args-op] count(); printf(PID %d [Java Poller] epoll_ctl fd%d op%d\n, pid, args-fd, args-op); } // 跟踪由于 Pinning 导致 Carrier 额外扩展的 pthread 创建行为 tracepoint:syscalls:sys_enter_clone* /comm java/ { printf(Warning: Java Thread Cloned! Potential Carrier Thread Compensation. PID: %d\n, pid); }消除 Safepoint Bias 的 Profiling传统的 JVM Profiler (如基于AsyncGetCallTrace) 在采样时可能会受到 Safepoint 影响。建议使用async-profiler3.0它已原生支持虚拟线程栈跟踪# 采样 CPU 消耗并区分 Virtual Thread 与 Carrier Thread 栈./asprof-ecpu-fprofile.html-t--vthreads$JAVA_PID