前言

本文旨在记录近期研读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:1 JavaThread,通常由 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 等待时,系统发起卸载流程:

  1. 确定切分边界: 汇编 Stub 读取当前 CPU 寄存器 RSP 与 RBP,沿着 Carrier 线程的 Native Stack 从低地址向高地址漫游,直到找到 Mount 时建立的 ContinuationEntry 标记帧。
  2. 计算与分配: 计算此区间内所有栈帧(含 C2 编译帧、C1 帧、解释器帧)的总字节数。若当前的 stackChunkOop 容量不足,则向 JVM 堆申请新的 Chunk。
  3. 帧拷贝与指针相对化:
  • 将 Native Stack 上物理连续的字节流拷贝至 stackChunkOop Payload。
  • 指针修正:解释器帧(Interpreter Frame)内包含指向局部变量表(Monitors/Locals)的绝对地址指针(如 rbx/r13)。HotSpot 在拷贝时会将这些物理指针转换为相对于 Chunk Payload 的相对偏移量(Relative Offset)。
  1. 重置 Carrier 栈指针: 将 Carrier 线程 Native Stack 的 RSP 指针直接修正重置为 ContinuationEntry 帧之前的位置,解绑 Thread-Local 的 currentThread,Carrier 线程通过 ret 指令直接返回 ForkJoinPool 的 Worker 循环。

2. Thaw(挂载/解冻)

当被挂起的虚拟线程收到唤醒信号并被 ForkJoinPool 重新调度时:

  1. 检查 Native Stack 空间: Carrier 线程获取该 VirtualThread 任务后,调用 Continuation.run() 进入 thaw Stub。首先比较当前 Carrier 线程 Native Stack 剩余空间与 stackChunkOop._max_thawing_size,若空间不足则触发 Native 栈伸缩。
  2. 反序列化与绝对地址还原:
  • 将 stackChunkOop Payload 中的字节流拷贝回 Carrier 线程的 Native Stack 空间。
  • 绝对指针还原:根据当前 Native Stack 实际的 RSP/RBP 物理基址,将 Chunk 内保存的相对 Offset 重新计算还原为物理内存地址。
  1. 设置 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 挂起全路径

  1. Socket 异步化: 在 JDK 21+ 中,所有 java.net.Socket、ServerSocket 及 FileChannel 底层均改由 NIO 重新实现,并且在创建套接字时默认添加 O_NONBLOCK 标志。
  2. 捕获 EAGAIN: 当虚拟线程发起 socket.read() 时,直接执行系统调用 sys_read。如果内核 Socket 接收缓冲区无数据,内核立即返回 -EAGAIN 或 -EWOULDBLOCK。
  3. 注册 Poller: JVM 捕获该错误码后,调用 Poller.register(fd, Net.POLLIN)。Poller 内部通过系统调用 epoll_ctl(epoll_fd, EPOLL_CTL_ADD, fd, &event) 将该 fd 与该虚拟线程的句柄绑定。
  4. Yield 释放 Carrier: 虚拟线程调用 VirtualThread.park(),触发 Continuation.yield() 完成 Freeze 流程,Carrier 线程重获自由并去执行其他就绪任务。

2. I/O 唤醒与 Re-mount 路径

  1. Kernel 中断通知: 当网卡接收到网络数据包,触发硬件中断,内核将数据写入 Socket 缓冲区,套接字变为可读状态。
  2. Poller 捕获事件: JVM 启动时会保留极少数专用平台线程作为 Poller 线程,持续在 epoll_wait(epoll_fd, events, ...) 上阻塞等待。当 fd 就绪时,epoll_wait 返回。
  3. Unpark 与重新入队: Poller 线程根据返回的 fd 找到对应的 VirtualThread 引用,调用 LockSupport.unpark(vthread)。
  4. Re-mount 执行: 虚拟线程被重新 push 入 ForkJoinPool 的工作队列(WorkQueue)。某个 Carrier 线程 pop 到该任务后,执行 Thaw 还原栈帧,再次发起 sys_read 并成功读取数据。

五、 Pinning (线程钉住) 机制与 HotSpot 演进

当虚拟线程处于某些特定状态时,HotSpot 的 Continuation::freeze 无法将栈帧安全地拷贝至堆内存,这种情况被称为 Pinning(钉住)。

1. Pinning 的触发条件与影响

  • 触发场景:
  1. Native 帧临界区:虚拟线程调用了 JNI Native 方法,或者通过 Foreign Function & Memory (FFM) API 执行了 C/C++ 动态库代码,栈帧中包含了 C/C++ 的物理 Native 栈帧。
  2. 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 21JDK 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 延迟的影响

Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐