JVM虚拟线程的底层实现原理分析
- 前言
- 虚拟线程的底层实现原理
-
- 1. M:N 调度架构与 Linux 内核交互
-
- 抽象层级映射
- 内核视角的“无感”与 CPU 上下文切换对比
- 用户态调度器:`ForkJoinPool`
- 2. 堆内栈结构 `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
+————————————————————————-+
| 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 作为调度器:
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:
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 交互机制
- 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 阻塞时:
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)。
R
S
P
RSP
RSP 和
R
B
P
RBP
RBP 修正回 ContinuationEntry 之前的数值,逻辑上“抹去”了 Native Stack 上的这一段 Java 栈帧。
Mount (Thaw / 恢复流程)
当挂起的虚拟线程被唤醒(如收到 unpark 信号):
→
\\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 的
R
S
P
RSP
RSP 和R
B
P
RBP
RBP 设置为解压后的栈顶和基址。 - 将 Thread-Local 的 currentThread 覆盖为 VirtualThread 实例。
- 执行 ret 汇编指令,跳转至 _pc 记录的代码地址,恢复虚拟线程执行。
4. Pinning (钉住) 机制与锁演进
当虚拟线程在执行特定操作时,HotSpot 无法将其栈帧从 Native Stack 卸载(Freeze)。这种状态被称为 Pinning。
导致 Pinning 的两大根源
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) 成功获取数据
6. 挂载/卸载与 I/O 调度可视化
下图演示了虚拟线程在发生 I/O 阻塞时的 Freeze (卸载)、Epoll 注册 以及唤醒后的 Thaw (重新挂载) 完整生命周期图解:
7. 系统工程师 Profiling 与 eBPF / JFR 观测指南
由于虚拟线程是在用户态调度的,传统的 Linux 性能工具(如 top、htop、原生 perf)只能看到 Carrier 线程,无法直接识别虚拟线程。必须结合 JVM 特定的跟踪机制与 eBPF。
JDK Flight Recorder (JFR) 专属事件
| jdk.VirtualThreadStart | 虚拟线程创建并开始执行时 | 追踪虚拟线程创建频率与生命周期。 |
| jdk.VirtualThreadEnd | 虚拟线程执行完毕销毁时 | 计算虚拟线程平均生存周期。 |
| jdk.VirtualThreadSubmit | 虚拟线程被提交/重新提交到 ForkJoinPool 时 | 评估调度队列延时。 |
| jdk.VirtualThreadPinned | 虚拟线程发生 Pinning 无法 Unmount 时 | 重点关注! 包含 Pinning 发生的具体 Java 栈轨迹 (pinningReason)。 |
可通过配置 JFR 策略打印 Pinning 事件:
java -XX:+FlightRecorder \\
-XX:StartFlightRecording=filename=loom.jfr,settings=profile \\
-Djdk.tracePinnedThreads=full \\
-jar app.jar
eBPF 联合追踪系统调用与 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-profiler 3.0+,它已原生支持虚拟线程栈跟踪:
# 采样 CPU 消耗并区分 Virtual Thread 与 Carrier Thread 栈
./asprof -e cpu -f profile.html -t –vthreads $JAVA_PID


