深入解析Project Loom虚拟线程的底层实现原理
深入解析Project Loom虚拟线程的底层实现原理
前言
本文旨在记录近期研读Java源码的学习心得与疑难问题。由于个人理解水平有限,文中内容难免存在疏漏,恳请读者不吝指正。
虚拟线程的底层实现原理
Project Loom 从根本上改变了 Java 的并发模型。它并没有修改 Linux 内核的线程调度算法,而是通过在 JVM 用户态实现 Continuation(执行续体) 与 基于 ForkJoinPool 的 Task 调度器,将线程的“执行权”与“操作系统调度实体”彻底解耦。
一、 M:N 调度架构与 Linux 内核交互模型
1. 1:1 平台线程与 M:N 虚拟线程对比
在传统的 1:1 模型中,每个 java.lang.Thread 对应一个 HotSpot JavaThread(C++ 对象),并在 Linux 内核中由 POSIX pthread_create 建立一个对应 task_struct 的 LWP(Lightweight Process)。
-
1:1 模型瓶颈:
-
内存开销:内核需要为每一个
task_struct分配独立的内核栈(通常为 8KB/16KB)以及用户态 Native Stack(由-Xss指定,默认 1MB)。 -
上下文切换开销:线程切换由 Linux 内核 CFS/EEVDF 调度器触发,需要经历 Ring 3 → \to → Ring 0 状态切换、通用寄存器(RSP, RBP, RAX…)与 AVX/SIMD 寄存器恢复、TLB (Translation Lookaside Buffer) 刷新以及 CPU L1/L2 Cache 的热度失效,单次切换耗时在 1–10 微秒级。
-
M:N 模型设计:
-
用户态解耦: M M M 个
VirtualThread(单纯的 Java 堆对象)共享 N N N 个 Carrier Threads(载体线程,本质上是普通的 1:1JavaThread,通常由ForkJoinPool管理,数量默认等于 CPU 核心数)。 -
内核无感知:Linux 内核只调度 N N N 个 Carrier 线程的
task_struct。对内核而言,这些 Carrier 线程始终处于连续运行或在futex上等待的状态。虚拟线程之间的切换完全发生在 JVM 用户态,单次切换仅消耗 10–100 纳秒。
+-----------------------------------------------------------------+
| [Virtual Thread 1] [Virtual Thread 2] ... [Virtual Thread M] | User Space (JVM Heap)
+-------------------------+---------------------------------------+
| Continuation.yield() / run()
v
+-----------------------------------------------------------------+
| ForkJoinPool Carrier Threads (JavaThread 1 ... JavaThread N) | User Space (Native Stack)
+-------------------------+---------------------------------------+
| 1:1 POSIX pthread / task_struct
v
+-----------------------------------------------------------------+
| Linux Kernel CFS / EEVDF Scheduler | Kernel Space
+-----------------------------------------------------------------+
| Hardware Thread Execution
v
+-----------------------------------------------------------------+
| CPU Cores (Core 0, Core 1, ...) | Hardware
+-----------------------------------------------------------------+
二、 stackChunkOop 内存布局与 HotSpot C++ 源码
传统平台线程必须预分配连续的固定 Native Stack,而虚拟线程将调用栈帧序列化存储在 Java 堆内存中,其核心载体即为 stackChunkOop。
1. C++ 类声明与结构 (src/hotspot/share/oops/stackChunkOop.hpp)
stackChunkOop 继承自 instanceOopDesc,表明它在 HotSpot 中是一个受 GC 管理的真实堆对象。
class stackChunkOopDesc : public instanceOopDesc {
private:
int _parent; // 指向父级 stackChunkOop (逻辑偏移/oop offset),栈过深时构成单向链表
int _size; // 当前 Chunk 在堆内占用的 Word 物理大小
int _sp; // 当前栈顶 relative offset (相对 Payload 起始点)
address _pc; // 虚拟线程挂起时的 Return Address (RIP)
int _argsize; // 挂起帧传入参数所占的字节数
uint8_t _flags; // 状态标记 (FLAG_HAS_INTERPRETER_FRAMES, FLAG_CLEAN, FLAG_GC_MODE 等)
int _max_thawing_size; // 解冻恢复到 Native Stack 时所需的最大物理字节数
// 紧随其后的 Header Payload 区:物理存储 Java 栈帧 (Interpreter / C1 / C2 编译帧)
};
2. 堆内内存分布图解
+-------------------------------------------------------+
| Mark Word (64 bit) | Object Header
+-------------------------------------------------------+
| Compressed Klass Pointer (32 bit) |
+-------------------------------------------------------+
| parent (oop offset) -> 指向父级 stackChunkOop |
| size (int) -> Chunk 物理容量 | Metadata
| sp (int) -> 当前逻辑栈顶 Offset | Fields
| pc (address) -> 恢复执行指令入口 (RIP) |
| argsize (int) -> 参数占用字节 |
| flags (uint8_t) -> 标记位 |
+-------------------------------------------------------+
| Frame Payload (字节流,逆向或正向填充 Java 栈帧) |
| +-------------------------------------------------+ |
| | Frame N (Top Frame: 局部变量表 + 操作数栈) | |
| +-------------------------------------------------+ | Serialized
| | Frame N-1 (Caller Frame) | | Java Frames
| +-------------------------------------------------+ |
| | ... | |
| +-------------------------------------------------+ |
| | Base Frame (Continuation.run 入口锚点帧) | |
| +-------------------------------------------------+ |
+-------------------------------------------------------+
3. Stack Chunk 动态扩容与 GC Root 扫描
- Chunk 链表化: 虚拟线程刚启动时,JVM 仅分配极小的 Chunk(例如几百字节)。当调用链加深导致 Chunk 溢出时,HotSpot 会动态分配新的
stackChunkOop,并将旧 Chunk 地址写入新 Chunk 的_parent字段,形成单向链表。 - OopMap 与 GC 追踪:
stackChunkOop内保存的栈帧包含指向 Java 堆中对象的引用。GC(如 G1、ZGC)在 Concurrent Mark 阶段,会通过内嵌的OopMap迭代遍历stackChunkOop的 Payload,将栈帧内的局部变量作为 GC Root 扫描。在 ZGC 标记阶段,若读取了 Chunk 内的指针,同样会触发 Load Barrier 进行指针自愈(Self-healing)。
三、 Freeze (Unmount) 与 Thaw (Mount) 汇编级执行流程
Mount(挂载/Thaw)与 Unmount(卸载/Freeze)是虚拟线程上下文切换的核心动作。其底层由 C++ 代码 (src/hotspot/share/runtime/continuationFreezeThaw.cpp) 与特定架构的机器码 Stub (stubGenerator_x86_64.cpp) 共同完成。
[VirtualThread.park()]
|
v
[Continuation.yield()]
|
v (C++ Continuation::freeze)
+-----------------------------------------------------------------------+
| 1. Stack Walking: 漫游 Native Stack 至 ContinuationEntry 锚点 |
| 2. Copy Frames: 将 Native Stack 机器帧打包 memcpy 至 stackChunkOop |
| 3. Pointer Fixup: 绝对地址修正为 relative offset |
| 4. Restore Carrier: 重置 CPU RSP/RBP 至 Entry 之前,恢复 Carrier 状态 |
+-----------------------------------------------------------------------+
|
v
Carrier 恢复运行,返回 ForkJoinPool 调度其他 Task
1. Freeze(卸载/冻结)
当虚拟线程触发 LockSupport.park() 或非阻塞 I/O 等待时,系统发起卸载流程:
- 确定切分边界: 汇编 Stub 读取当前 CPU 寄存器
RSP与RBP,沿着 Carrier 线程的 Native Stack 从低地址向高地址漫游,直到找到 Mount 时建立的ContinuationEntry标记帧。 - 计算与分配: 计算此区间内所有栈帧(含 C2 编译帧、C1 帧、解释器帧)的总字节数。若当前的
stackChunkOop容量不足,则向 JVM 堆申请新的 Chunk。 - 帧拷贝与指针相对化:
- 将 Native Stack 上物理连续的字节流拷贝至
stackChunkOopPayload。 - 指针修正:解释器帧(Interpreter Frame)内包含指向局部变量表(Monitors/Locals)的绝对地址指针(如
rbx/r13)。HotSpot 在拷贝时会将这些物理指针转换为相对于 Chunk Payload 的相对偏移量(Relative Offset)。
- 重置 Carrier 栈指针: 将 Carrier 线程 Native Stack 的
RSP指针直接修正重置为ContinuationEntry帧之前的位置,解绑 Thread-Local 的currentThread,Carrier 线程通过ret指令直接返回ForkJoinPool的 Worker 循环。
2. Thaw(挂载/解冻)
当被挂起的虚拟线程收到唤醒信号并被 ForkJoinPool 重新调度时:
- 检查 Native Stack 空间: Carrier 线程获取该
VirtualThread任务后,调用Continuation.run()进入thawStub。首先比较当前 Carrier 线程 Native Stack 剩余空间与stackChunkOop._max_thawing_size,若空间不足则触发 Native 栈伸缩。 - 反序列化与绝对地址还原:
- 将
stackChunkOopPayload 中的字节流拷贝回 Carrier 线程的 Native Stack 空间。 - 绝对指针还原:根据当前 Native Stack 实际的
RSP/RBP物理基址,将 Chunk 内保存的相对 Offset 重新计算还原为物理内存地址。
- 设置 CPU 寄存器并跳转: 将 CPU 的
RSP寄存器调整为恢复后的栈顶地址,将Thread.currentThread()设置为该VirtualThread,随后执行jmp或ret跳转至stackChunkOop._pc所保存的代码入口点继续向下执行。
四、 协作式调度、Park/Unpark 与 Linux epoll 深度集成
虚拟线程在代码层面呈现为“同步阻塞”,但在底层依赖的是 Linux 内核的非阻塞 I/O 与 epoll 唤醒机制。
+------------------+ sys_read(fd) +----------------------+
| Virtual Thread | ---------------------------> | Linux Kernel Socket |
+------------------+ +----------------------+
| |
| Returns -EAGAIN / -EWOULDBLOCK | Data not ready
v v
+------------------+ epoll_ctl(EPOLL_CTL_ADD) +----------------------+
| JVM Poller | ---------------------------> | Linux epoll_fd |
+------------------+ +----------------------+
| |
| Continuation.yield() |
v | Net Packet Arrives
+------------------+ | Interruption
| Unmount Carrier | v
+------------------+ +----------------------+
| | Kernel wakes epoll |
| Carrier handles other tasks +----------------------+
| |
| Poller catches event (epoll_wait) |
v v
+------------------+ unpark() / Push Queue +----------------------+
| ForkJoinPool | <--------------------------- | JVM Poller Thread |
+------------------+ +----------------------+
|
v Mount & Thaw
+------------------+ sys_read(fd)
| Virtual Thread | ---------------------------> Read Data Successfully
+------------------+
1. I/O 挂起全路径
- Socket 异步化: 在 JDK 21+ 中,所有
java.net.Socket、ServerSocket及FileChannel底层均改由NIO重新实现,并且在创建套接字时默认添加O_NONBLOCK标志。 - 捕获
EAGAIN: 当虚拟线程发起socket.read()时,直接执行系统调用sys_read。如果内核 Socket 接收缓冲区无数据,内核立即返回-EAGAIN或-EWOULDBLOCK。 - 注册 Poller: JVM 捕获该错误码后,调用
Poller.register(fd, Net.POLLIN)。Poller 内部通过系统调用epoll_ctl(epoll_fd, EPOLL_CTL_ADD, fd, &event)将该 fd 与该虚拟线程的句柄绑定。 - Yield 释放 Carrier: 虚拟线程调用
VirtualThread.park(),触发Continuation.yield()完成 Freeze 流程,Carrier 线程重获自由并去执行其他就绪任务。
2. I/O 唤醒与 Re-mount 路径
- Kernel 中断通知: 当网卡接收到网络数据包,触发硬件中断,内核将数据写入 Socket 缓冲区,套接字变为可读状态。
- Poller 捕获事件: JVM 启动时会保留极少数专用平台线程作为
Poller线程,持续在epoll_wait(epoll_fd, events, ...)上阻塞等待。当 fd 就绪时,epoll_wait返回。 - Unpark 与重新入队:
Poller线程根据返回的 fd 找到对应的VirtualThread引用,调用LockSupport.unpark(vthread)。 - Re-mount 执行: 虚拟线程被重新
push入ForkJoinPool的工作队列(WorkQueue)。某个 Carrier 线程pop到该任务后,执行 Thaw 还原栈帧,再次发起sys_read并成功读取数据。
五、 Pinning (线程钉住) 机制与 HotSpot 演进
当虚拟线程处于某些特定状态时,HotSpot 的 Continuation::freeze 无法将栈帧安全地拷贝至堆内存,这种情况被称为 Pinning(钉住)。
1. Pinning 的触发条件与影响
- 触发场景:
- Native 帧临界区:虚拟线程调用了 JNI Native 方法,或者通过 Foreign Function & Memory (FFM) API 执行了 C/C++ 动态库代码,栈帧中包含了 C/C++ 的物理 Native 栈帧。
synchronized块/方法(JDK 21):在 JDK 21 中,synchronized的 Mark Word 会膨胀为 HotSpot 的ObjectMonitor,其内部的 Monitor Enter/Exit 逻辑与当前物理 Carrier 线程的JavaThread指针及 Lock Record 强绑定。
- 物理后果: 当发生 Pinning 时,如果虚拟线程试图
yield(),Continuation::is_pinned()返回true,JVM 将拒绝 Freeze 操作。此时虚拟线程会在 Carrier 线程上直接阻塞(如进入 OS 级的futex_wait),导致 Carrier 线程被锁定,无法复用去处理其他虚拟线程。
2. JDK 21 与 JDK 23+ 的优化演进
| 特性 / 版本 | JDK 21 | JDK 23+ / JDK 24 |
|---|---|---|
synchronized 钉住 | 触发 Pinning。依赖 ForkJoinPool 动态创建补偿线程(Compensating Thread)补充并行度。 | 彻底解决。HotSpot 重构了 ObjectMonitor 锁抢占逻辑,锁队列与 JavaThread 解耦,synchronized 内部支持 Unmount。 |
| JNI / FFM 钉住 | 触发 Pinning。无法避免。 | 针对 FFM API 进行优化,但传统 JNI 依然保持 Pinning 特性。 |
| Pinning 检测 | 设置 -Djdk.tracePinnedThreads=full 打印警告日志与堆栈。 | 提供 JFR 事件 jdk.VirtualThreadPinned 进行精细化追踪。 |
六、 系统级 Profiling 与 Debug 实操指南
由于虚拟线程的生命周期不对应 Linux 内核线程,传统基于 POSIX pthread_id 或内核 PID/LWP 的性能剖析工具(如 top、strace)将无法精准感知虚拟线程的真实状态。
+-----------------------------------+
| eBPF / sys_enter_futex | -> 监控 Carrier 物理阻塞
+-----------------------------------+
|
+-----------------------------------+
| async-profiler 3.0+ | -> 区分 Carrier / Virtual 视图
+-----------------------------------+
|
+-----------------------------------+
| JFR (Java Flight Recorder) | -> 过滤 Pinning 与 Submit 事件
+-----------------------------------+
1. async-profiler 3.0+ 虚拟线程感知剖析
传统的 perf_events 采样只会捕捉 Carrier 线程。从 async-profiler 3.0 起,工具引入了对 HotSpot VirtualThread 对象的解包支持:
# 按照 Virtual Thread 视角归集 CPU 采样火焰图
./asprof -e cpu -f vthread_cpu.html -o flamegraph --vthreads <pid>
# 追踪虚拟线程的内存分配 (Allocation)
./asprof -e alloc -f vthread_alloc.html --vthreads <pid>
2. JFR (Java Flight Recorder) 诊断 Pinning
通过配置 JFR 事件,可以精准捕捉发生 Pinning 的位置与频率:
java -XX:StartFlightRecording=filename=loom_dump.jfr,settings=profile \
-Djdk.tracePinnedThreads=short \
-jar app.jar
在 JMC (Java Mission Control) 中重点分析以下事件:
jdk.VirtualThreadStart/jdk.VirtualThreadEnd:虚拟线程创建与销毁频次。jdk.VirtualThreadPinned:触发钉住的类名、方法名以及具体栈帧深度。jdk.VirtualThreadSubmitFailed:ForkJoinPool队列溢出或拒绝事件。
3. eBPF 内核级联合追踪
可编写 eBPF 脚本监控 futex 系统调用,判断 ForkJoinPool 的 Carrier 线程是否因为 Pinning 发生了内核态上下文切换:
# bcc / eBPF 示例:追踪 Carrier 线程发起 futex 阻塞的频率
from bcc import BPF
bpf_text = """
#include <uapi/linux/ptrace.h>
BPF_HASH(start, u32);
int trace_futex_wait(struct pt_regs *ctx) {
u32 pid = bpf_get_current_pid_tgid();
u64 ts = bpf_ktime_get_ns();
start.update(&pid, &ts);
return 0;
}
"""
# 关联 sys_enter_futex,分析 Carrier 物理停顿对 P999 延迟的影响
openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构
更多推荐
所有评论(0)