JVM虚拟线程的底层实现原理分析
- 前言
- 虚拟线程的底层实现原理
-
- [1. M:N 调度架构与 Linux 内核交互](#1. M:N 调度架构与 Linux 内核交互)
-
- 抽象层级映射
- [内核视角的"无感"与 CPU 上下文切换对比](#内核视角的“无感”与 CPU 上下文切换对比)
- 用户态调度器:`ForkJoinPool`
- [2. 堆内栈结构 `stackChunkOop` 深度剖析](#2. 堆内栈结构
stackChunkOop深度剖析) -
- [OpenJDK C++ 结构声明](#OpenJDK C++ 结构声明)
- 堆内物理内存布局
- [动静态扩展与 GC 交互机制](#动静态扩展与 GC 交互机制)
- [3. Mount / Unmount 汇编与 C++ 级实现](#3. Mount / Unmount 汇编与 C++ 级实现)
- [4. Pinning (钉住) 机制与锁演进](#4. Pinning (钉住) 机制与锁演进)
-
- [导致 Pinning 的两大根源](#导致 Pinning 的两大根源)
- [Pinning 时的 ForkJoinPool 补偿机制](#Pinning 时的 ForkJoinPool 补偿机制)
- [JDK 21 → \to → JDK 23/24 的 `synchronized` 解绑重构](#JDK 21 → \to → JDK 23/24 的
synchronized解绑重构)
- [5. 异步非阻塞 I/O 与 Linux `epoll` 内核协作](#5. 异步非阻塞 I/O 与 Linux
epoll内核协作) -
- [完整 IO 调用链与状态流转](#完整 IO 调用链与状态流转)
- [6. 挂载/卸载与 I/O 调度可视化](#6. 挂载/卸载与 I/O 调度可视化)
- [7. 系统工程师 Profiling 与 eBPF / JFR 观测指南](#7. 系统工程师 Profiling 与 eBPF / JFR 观测指南)
-
- [JDK Flight Recorder (JFR) 专属事件](#JDK Flight Recorder (JFR) 专属事件)
- [eBPF 联合追踪系统调用与 JVM 事件](#eBPF 联合追踪系统调用与 JVM 事件)
- [消除 Safepoint Bias 的 Profiling](#消除 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
+-------------------------------------------------------------------------+
| 实体 | 所在空间 | 关键数据结构 / 源码路径 | 内存与调度责任 |
|---|---|---|---|
VirtualThread |
Java 堆 | java.lang.VirtualThread |
保存运行状态、绑定的 Continuation 以及 ForkJoinPool 引用。 |
Continuation |
HotSpot / 堆 | src/hotspot/share/runtime/continuation.cpp |
执行续体抽象,负责栈帧的切出 (Freeze) 与恢复 (Thaw)。 |
| Carrier Thread | HotSpot 用户态 | src/hotspot/share/runtime/thread.cpp (JavaThread) |
载体线程,本质是一个传统的 1:1 JavaThread。 |
| Linux Kernel Task | 内核态 | struct task_struct (Linux Kernel) |
内核可调度的最小单位,由 CFS/EEVDF 调度器分配 CPU 时间片。 |
内核视角的"无感"与 CPU 上下文切换对比
Linux 内核调度器(CFS/EEVDF)只感知到 Carrier Thread 对应的 N N N 个 task_struct。对内核而言,并没有暴增的线程上下文。
- 内核态线程切换 ( 1 : 1 1:1 1: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:N M:N 虚拟线程):
不触发任何内核 Trap。仅在 HotSpot 运行时完成对寄存器(RSP、RBP、RIP)的重置,并将 Native Stack 上的栈帧数据增量复制到 Java 堆中的stackChunkOop对象。切换开销降至 10 ∼ 100 ns 10\sim 100\ \text{ns} 10∼100 ns。
用户态调度器: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} 1 MB),极其消耗虚拟地址空间与物理内存。虚拟线程将栈帧存储在 Java 堆中的 stackChunkOop 对象里,按需分配与扩缩容。
OpenJDK C++ 结构声明
stackChunkOop 位于 HotSpot 源码 src/hotspot/share/oops/stackChunkOop.hpp:
cpp
class stackChunkOopDesc : public instanceOopDesc {
private:
int _parent; // 指向父级 stackChunkOop (当栈过深时串联为链表)
int _size; // 当前 Chunk 占用的 Word 数量 (物理容量)
int _sp; // 当前栈顶在 Payload 中的 Offset
address _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)
address start_address() const;
inline oop parent() const;
inline void set_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 遍历与 OopMap:
stackChunkOop存放在 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 RSP RSP(栈顶)开始向高地址漫游,逐帧检查从当前帧到
ContinuationEntry帧之间的所有栈帧:
- 解析 C2/C1 JIT 编译帧的 Frame Size、OopMap。
- 解析 Interpreter 帧的 R B P RBP RBP 与 Bytecode Pointer ( R 13 R13 R13 / R S I RSI RSI)。
- 增量写入
stackChunkOop: 将此范围内的 Native Stack 内存字节拷贝到stackChunkOop的 Payload 区域,同步更新_sp与_pc。 - 恢复 Carrier Stack: 将 CPU 的 R S P RSP RSP 和 R B P RBP RBP 修正回
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 RBP RBP 链修正: 由于 Native Stack 每次 Mount 的基址可能变化,必须遍历拷贝过来的所有帧,修正其内部保存的旧 R B P RBP RBP 地址,重新指向新的 Stack 地址。
- JIT Safepoint Stub 修正: 确保 Deoptimization 与 Safepoint Polling Page 依然指向正确的硬件物理地址。
- 切换 CPU 寄存器并跳转:
- 将 CPU 的 R S P RSP RSP 和 R B P RBP RBP 设置为解压后的栈顶和基址。
- 将 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 及以前): 当虚拟线程获取了对象监视器锁(ObjectMonitor),HotSpot 的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 21):
ObjectMonitor内部的_owner必须是JavaThread*物理线程指针,必须 Pin 在 Carrier 上。 - 重构后(JDK 23+):
_owner字段升级为可以接受VirtualThread对象的指针或匿名锁状态,锁竞争列表(EntryList/WaitSet)改由线程无关的数据结构维护。当虚拟线程在synchronized内部发生阻塞时,可以像ReentrantLock一样正常进行Continuation.yield()并卸载栈帧。
5. 异步非阻塞 I/O 与 Linux epoll 内核协作
虚拟线程允许用"同步阻塞的代码写法"实现"高并发异步非阻塞"的吞吐量,依赖于底层 JDK 网络库(NIO)与 Linux epoll 的联动。
完整 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。 - 注册
Poller: Java 运行时捕获到-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 事件:
bash
java -XX:+FlightRecorder \
-XX:StartFlightRecording=filename=loom.jfr,settings=profile \
-Djdk.tracePinnedThreads=full \
-jar app.jar
eBPF 联合追踪系统调用与 JVM 事件
结合 bpftrace 脚本,可精准抓取 JVM 虚拟线程引发的 epoll_ctl 与线程切换行为:
ebpf
// 跟踪 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-profiler 3.0+,它已原生支持虚拟线程栈跟踪:
bash
# 采样 CPU 消耗并区分 Virtual Thread 与 Carrier Thread 栈
./asprof -e cpu -f profile.html -t --vthreads $JAVA_PID