欢迎光临
我们一直在努力

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

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 作为调度器:

  • 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:

    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 事件:

    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

    赞(0)
    未经允许不得转载:171主机测评 » JVM虚拟线程的底层实现原理分析
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址