JVM虚拟线程的底层实现原理分析

JVM虚拟线程的底层实现原理分析

  • 前言
  • 虚拟线程的底层实现原理
    • [1. M:N 调度架构与 Linux 内核交互](#1. M:N 调度架构与 Linux 内核交互)
    • [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 作为调度器:

  1. FIFO/LIFO 任务队列: 虚拟线程提交到 ForkJoinPool 时,优先存入 Worker Thread 的本地 WorkQueue。
  2. 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 交互机制

  1. Chunk 链表化: 若虚拟线程调用栈非常深,单次分配的 stackChunkOop 容纳不下时,HotSpot 会分配新的 stackChunkOop,并通过 _parent 字段链接成链表(Chunk Stacking),避免触发大对象连续内存分配。
  2. 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 阻塞时:

  1. 进入 C++ 运行时: Java 层触发 Continuation.yield(),通过 Standard Stub 陷入 HotSpot C++ 函数 Continuation::freeze。
  2. 定位 Anchor 帧: 查找 Carrier 线程 Native Stack 上的 ContinuationEntry 标记帧(这是本次 Mount 时的起始位置)。
  3. 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)。
  1. 增量写入 stackChunkOop: 将此范围内的 Native Stack 内存字节拷贝到 stackChunkOop 的 Payload 区域,同步更新 _sp 与 _pc。
  2. 恢复 Carrier Stack: 将 CPU 的 R S P RSP RSP 和 R B P RBP RBP 修正回 ContinuationEntry 之前的数值,逻辑上"抹去"了 Native Stack 上的这一段 Java 栈帧。
  3. 上下文解绑: 清除 Carrier Thread 上的 Thread-Local 变量,将 Thread.currentThread() 重置为 Carrier 自身,Continuation::freeze 返回 true。 Carrier 线程继续执行 ForkJoinPool 的调度 Loop。

Mount (Thaw / 恢复流程)

当挂起的虚拟线程被唤醒(如收到 unpark 信号):

  1. 调度选中: ForkJoinPool 挑选一个空闲 Carrier Thread,调用 Continuation.run()。
  2. 进入 C++ Thaw: 触发 Continuation::thaw 汇编 Stub。
  3. 检查 Stack 空间: 计算 stackChunkOop 中解压所需的 Native Stack 空间,若超过 Carrier 线程 Native Stack 的剩余容量,触发 Stack Overflow / 扩展逻辑。
  4. 内存拷贝 (Heap → \to → Native Stack): 将 stackChunkOop 里的 Payload 字节拷贝回当前 Carrier 线程 Native Stack 栈顶。
  5. 修正帧内指针:
  • R B P RBP RBP 链修正: 由于 Native Stack 每次 Mount 的基址可能变化,必须遍历拷贝过来的所有帧,修正其内部保存的旧 R B P RBP RBP 地址,重新指向新的 Stack 地址。
  • JIT Safepoint Stub 修正: 确保 Deoptimization 与 Safepoint Polling Page 依然指向正确的硬件物理地址。
  1. 切换 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 的两大根源

  1. 原生代码调用 (JNI / Native Frames): 栈帧中包含 C/C++ 的 JNI 本地方法调用,Native 代码直接引用了 Native Stack 的内存地址,JVM 无法安全移动这些地址。
  2. 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) 成功获取数据
  1. 默认非阻塞模式: 当 Java 创建 Socket 时,JDK 底层调用的 socket() 系统调用会自动加上 O_NONBLOCK 标记。
  2. 捕获 -EAGAIN: 当虚拟线程调用 read(),底层发起 sys_read 系统调用。若内核缓冲区无数据,内核立即返回 -EAGAIN。
  3. 注册 Poller: Java 运行时捕获到 -EAGAIN 后,将 fd 与当前的 VirtualThread 实例打包,调用 epoll_ctl 注册到 JVM 后台单例 PollerThread 维护的 epoll_fd 中。
  4. Yield 挂起: 虚拟线程主动调用 Continuation.yield() 完成 Unmount, Carrier 线程立刻去处理其他任务。
  5. 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
相关推荐
Joe_Wang51 小时前
【从0到1学习JVM · 25】同样都要停顿所有线程,Parallel比Serial到底强在哪
java·jvm·学习·垃圾回收
keyipatience1 小时前
跳表详细解析
开发语言·数据结构·c++·算法·跳表
汉克老师1 小时前
GESP2026年9月认证C++一级( 第一部分选择题(8~15题)精讲
c++·gesp·小学生·学c++编程
M78佐菲1 小时前
ARM学习笔记(9)
linux·arm开发·笔记·嵌入式硬件·学习
赵民勇1 小时前
glib-compile-schemas命令详解
linux·运维
小小、码农1 小时前
〖Linux文件系统〗:彻底打通文件 IO 全链路
linux·开发语言·数据结构·c++·系统
大侠归来2 小时前
cJSON 源码解析:parse_string 函数深入剖析
linux·网络·算法
小小、码农2 小时前
〖Linux进程间通信〗IPC全家桶:匿名管道、FIFO、共享内存、消息队列与信号量一篇打通
linux·运维·服务器
阳光九叶草LXGZXJ2 小时前
达梦数据库-学习-67-SSL加密认证
linux·运维·数据库·sql·学习·ssl