虚拟线程底层ForkJoinPool的工作窃取算法机制

虚拟线程底层ForkJoinPool的工作窃取算法机制

  • 前言
  • 虚拟线程底层工作窃取算法
    • [一、 虚拟线程调度的底层承载架构:ForkJoinPool 深度解构](#一、 虚拟线程调度的底层承载架构:ForkJoinPool 深度解构)
      • [1. 核心组件拆解与内存布局](#1. 核心组件拆解与内存布局)
    • [二、 Work-Stealing(工作窃取)算法的核心数据结构与并发控制](#二、 Work-Stealing(工作窃取)算法的核心数据结构与并发控制)
      • [1. Top 与 Base 指针的并发语义](#1. Top 与 Base 指针的并发语义)
      • [2. Push、Pop 与 Steal 的无锁算法逻辑](#2. Push、Pop 与 Steal 的无锁算法逻辑)
        • [Push 操作(Owner 线程执行)](#Push 操作(Owner 线程执行))
        • [Pop 操作(Owner 线程执行)](#Pop 操作(Owner 线程执行))
        • [Steal 操作(Stealer 线程执行)](#Steal 操作(Stealer 线程执行))
      • [3. 64 位 `ctl` 状态寄存器与 Phase-based 挂起唤醒机制](#3. 64 位 ctl 状态寄存器与 Phase-based 挂起唤醒机制)
    • [三、 虚拟线程与 ForkJoinPool 的深层交织与 Mount/Unmount 流程](#三、 虚拟线程与 ForkJoinPool 的深层交织与 Mount/Unmount 流程)
    • [四、 CPU 密集型与 I/O 密集型混合场景下的性能瓶颈分析](#四、 CPU 密集型与 I/O 密集型混合场景下的性能瓶颈分析)
      • [1. 痛点一:Carrier Thread 被 CPU 密集型任务长久占据(Starvation & Latency Spike)](#1. 痛点一:Carrier Thread 被 CPU 密集型任务长久占据(Starvation & Latency Spike))
      • [2. 痛点二:不可 Unmount 的阻塞导致的 Carrier 线程暴涨(Compensating Threads)](#2. 痛点二:不可 Unmount 的阻塞导致的 Carrier 线程暴涨(Compensating Threads))
    • [五、 FIFO 与 LIFO 队列模式对比与深度调优](#五、 FIFO 与 LIFO 队列模式对比与深度调优)
      • [1. 队列模式底层机制对比](#1. 队列模式底层机制对比)
      • [2. 为什么虚拟线程默认且必须强制开启 `FIFO (asyncMode)`?](#2. 为什么虚拟线程默认且必须强制开启 FIFO (asyncMode)?)
    • [六、 混合场景参数调优与系统工程最佳实践](#六、 混合场景参数调优与系统工程最佳实践)
      • [1. 核心 JVM 参数调优矩阵](#1. 核心 JVM 参数调优矩阵)
        • [① `parallelism`(并行度 / 核心 Carrier 线程数)](#① parallelism(并行度 / 核心 Carrier 线程数))
        • [② `maxPoolSize`(最大 Carrier 线程上限)](#② maxPoolSize(最大 Carrier 线程上限))
        • [③ `minRunnable`(最小保证运行线程数)](#③ minRunnable(最小保证运行线程数))
      • [2. 混合场景架构落地三大原则(舱壁隔离与防 Pinning)](#2. 混合场景架构落地三大原则(舱壁隔离与防 Pinning))
        • [原则一:CPU 密集型任务与虚拟线程解耦(舱壁隔离 Bulkheading)](#原则一:CPU 密集型任务与虚拟线程解耦(舱壁隔离 Bulkheading))
        • [原则二:彻底消除 Pinning(载体线程锚定)](#原则二:彻底消除 Pinning(载体线程锚定))
        • [原则三:控制文件 I/O 的并发粒度](#原则三:控制文件 I/O 的并发粒度)
    • [七、 总结](#七、 总结)

前言

本文旨在记录近期研读Java源码的学习心得与疑难问题。由于个人理解水平有限,文中内容难免存在疏漏,恳请读者不吝指正。

虚拟线程底层工作窃取算法

一、 虚拟线程调度的底层承载架构:ForkJoinPool 深度解构

在 Java 21+ 的 Project Loom 体系中,虚拟线程(VirtualThread)并不直接对应操作系统的内核线程,而是作为轻量级的 Java 对象存在于 JVM 堆内存中。虚拟线程的调度器(Scheduler)默认由一个专门配置的 ForkJoinPool 实例承载(即 VirtualThread.DEFAULT_SCHEDULER)。

ForkJoinPool 与传统用于分化汇总计算的 ForkJoinPool.commonPool() 在拓扑结构与调度策略上存在显著差异。

复制代码
+-----------------------------------------------------------------------------------+
|                            VirtualThread.DEFAULT_SCHEDULER                        |
|                                (ForkJoinPool)                                     |
|                                                                                   |
|  +-------------------------------------+   +-----------------------------------+  |
|  |  WorkerThread 0 (Carrier Thread)    |   |  WorkerThread 1 (Carrier Thread)  |  |
|  |  +-------------------------------+  |   |  +-------------------------------+ |  |
|  |  | WorkQueue 1 (Deque)           |  |   |  | WorkQueue 3 (Deque)           | |  |
|  |  | [VT-Cont] [VT-Cont] [VT-Cont] |  |   |  | [VT-Cont] [VT-Cont]           | |  |
|  |  +-------------------------------+  |   |  +-------------------------------+ |  |
|  +------------------│------------------+   +------------------▲----------------+  |
|                     │ (LIFO Pop / FIFO Push)                  │                   |
|                     ▼                                         │ (FIFO Steal)      |
|           Owner Thread 独自操作 Top 端 ─────────────────────────┘                   |
|                                                                                   |
|  +-----------------------------------------------------------------------------+  |
|  |  Submission Queues (Shared External WorkQueues: Index 0, 2, 4...)            |  |
|  |  [VT-Unparked]  [VT-Unparked]  [VT-Unparked]                                 |  |
|  +-----------------------------------------------------------------------------+  |
+-----------------------------------------------------------------------------------+

1. 核心组件拆解与内存布局

  • 载体线程(Carrier Thread): 实际占用操作系统内核线程运行的平台线程,本质上是 ForkJoinWorkerThread。它负责执行虚拟线程的 Continuation 栈帧。

  • 双端工作队列(WorkQueue): ForkJoinPool 内部维护了一个 WorkQueue[] 数组。

  • 奇数索引队列(Worker Queue): 属于特定的 Carrier Thread 私有,无锁化压入任务。

  • 偶数索引队列(Submission Queue): 共享的外部提交队列。当非 Carrier Thread(如系统的 Timer 线程、I/O 响应线程)调用 LockSupport.unpark(virtualThread) 唤醒虚拟线程时,任务会被提交到偶数队列中,此过程需要 CAS 加锁或锁重试。

  • 环形缓冲区(WorkQueue.array): 每个 WorkQueue 内部持有一个动态扩容的数组,专门存放待执行的 ForkJoinTask<?>(在虚拟线程体系中,包装为 VirtualThread.submitRunContinuation() 返回的 Runnable/Task)。


二、 Work-Stealing(工作窃取)算法的核心数据结构与并发控制

Work-Stealing 算法的核心思想是:让空闲的 Carrier Thread(Stealer)主动从繁忙的 Carrier Thread(Owner)的队列尾部窃取任务,以最大化 CPU 所有核心的利用率,并消除全局锁竞争。

1. Top 与 Base 指针的并发语义

每个 WorkQueue 依靠两个核心游标指针维持双端队列的无锁并发:

WorkQueue = { array : Task ,   top : int ,   base : volatile int } \text{WorkQueue} = \{ \text{array}: \text{Task}\[\], \, \text{top}: \text{int}, \, \text{base}: \text{volatile int} \} WorkQueue={array:Task\[\],top:int,base:volatile int}

复制代码
                   窃取端 (Steal / FIFO)                             压入/弹出端 (Push/Pop / LIFO or FIFO)
                           │                                                          │
                           ▼                                                          ▼
               +───┬───┬───┬───┬───┬───┬───┬───+
  Array Memory |   | T | T | T | T | T |   |   |
               +───+───+───+───+───+───+───+───+
                     ▲                   ▲
                     │                   │
                   base                 top
             (Stealer CAS 修改)     (Owner 独占修改)
  • top 指针(Owner 独占): 仅由当前 WorkQueue 的归属 Carrier Thread 修改。Push 和 Pop 操作默认在 top 端进行。由于是单线程写入,top 的递增/递减不需要重量级的 CAS 指令,仅需 VarHandle.setRelease 保证内存屏障语义。
  • base 指针(多线程共享): 记录队列中最早入队的任务位置。当其他空闲 Carrier Thread 前来窃取任务时,通过 CAS 指令递增 base,从 base 端拉取任务(FIFO 顺序)。

2. Push、Pop 与 Steal 的无锁算法逻辑

Push 操作(Owner 线程执行)

当当前 Carrier Thread 产生的新的虚拟线程变为 Runnable 状态时:

java 复制代码
// 简化后的逻辑示意(基于 Java 21 源码 VarHandle 原语)
final void push(ForkJoinTask<?> task) {
    int b = base, s = top;
    ForkJoinTask<?>[] a = array;
    if (a != null) {
        int m = a.length - 1;
        // 将任务写入环形数组 top 位置 (使用 Release 语义)
        QA.setRelease(a, i, task);
        // top 指针加 1
        TOP.setRelease(this, s + 1);
        int sub = s - b;
        // 若队列满,触发扩容或唤醒其他空闲 Worker
        if (sub <= 1)
            signalWork();
        else if (sub >= m)
            growArray();
    }
}
Pop 操作(Owner 线程执行)

Owner 线程优先从自己的 top 端获取任务:

java 复制代码
final ForkJoinTask<?> pop() {
    int b = base, s = top;
    ForkJoinTask<?>[] a = array;
    if (a != null && b != s) {
        int i = (a.length - 1) & --s;
        // 预扣 top 指针
        TOP.setRelease(this, s);
        ForkJoinTask<?> t = (ForkJoinTask<?>) QA.getAcquire(a, i);
        if (t == null)
            return null; // 被窃取或空
        // 将数组对应位置置空
        if (QA.compareAndSet(a, i, t, null))
            return t;
        // 临界点:当只剩下最后一个元素 (s == b) 时,Owner 与 Stealer 产生竞争,退化为 CAS
    }
    return null;
}
Steal 操作(Stealer 线程执行)

当 Carrier Thread 自身的队列为空时,进入 scan() 阶段,随机挑选其他 WorkQueue 尝试窃取:

java 复制代码
final ForkJoinTask<?> pollAt(int b) {
    ForkJoinTask<?>[] a = array;
    if (a != null) {
        int i = (a.length - 1) & b;
        ForkJoinTask<?> t = (ForkJoinTask<?>) QA.getAcquire(a, i);
        if (t != null) {
            // CAS 更新 base:从 b 变为 b + 1
            if (BASE.compareAndSet(this, b, b + 1)) {
                QA.setRelease(a, i, null); // 清空已被窃取的槽位
                return t;
            }
        }
    }
    return null;
}

3. 64 位 ctl 状态寄存器与 Phase-based 挂起唤醒机制

ForkJoinPool 没有使用传统的 ReentrantLock 来管理线程池状态,而是依赖一个 volatile long ctl 64 位复合复合状态寄存器:

复制代码
+-------------------+-------------------+-------------------+-------------------+
|  AC (Active Count)|  TC (Total Count) |   SS (Status/Seq) |   ID (Parked Head)|
|      16 bits      |      16 bits      |      16 bits      |      16 bits      |
+-------------------+-------------------+-------------------+-------------------+
 63               48 47               32 31               16 15                0
  • AC (Active Count): 正在执行任务的活跃 Carrier Thread 数量。如果 AC <= 0,说明没有足够的线程在处理任务。
  • TC (Total Count): 当前池内创建的总 Carrier Thread 数量。
  • SS (Status/Seq) + ID: 构成一个 Treiber Stack(无锁无栈锁),保存当前被 LockSupport.park() 挂起(休眠)的 Carrier Thread 链表头节点及版本号(防 ABA 问题)。

伪随机扫描与退避(Scan & Park):

  1. Step 1: 线程生成一个伪随机步长,遍历 WorkQueue[] 数组。
  2. Step 2: 如果扫描一圈未发现可窃取任务,调用 Thread.yield() 降低 CPU 占用。
  3. Step 3: 连续多次扫描(Scan)失败后,更新 ctl 将自身推入空闲线程栈(Treiber Stack),并调用 LockSupport.park() 挂起内核线程,等待新的虚拟线程被唤醒时通过 signalWork() 精准 Unpark。

三、 虚拟线程与 ForkJoinPool 的深层交织与 Mount/Unmount 流程

虚拟线程的调度并不是把虚拟线程直接放到 Carrier 队列里,而是将其封装为 Continuation

复制代码
[VirtualThread.run()]
        │
        ▼
[Continuation.run()]
        │
        ├───> 正常执行业务代码
        │
        └───> 遇到 blocking I/O 或 LockSupport.park()
                │
                ▼
        [Continuation.yield()]
                │
                ├───> 1. 将 CPU 寄存器与 JVM 栈帧保存至 Heap (VirtualThread 实例内)
                ├───> 2. VirtualThread 状态从 RUNNING 变为 PARKED
                └───> 3. Carrier Thread 直接调用 top/pop() 或 scan() 获取下一个 VT

当被阻塞的虚拟线程收到了网络 I/O 就绪信号(通过 JVM 内部的 Poller/epoll 线程):

  1. Poller 线程找到该虚拟线程对应的 VirtualThread 对象。
  2. 修改其状态为 RUNNABLE
  3. 将虚拟线程的 runContinuation 任务投递到 ForkJoinPoolSubmission Queue (偶数索引队列)或当前 Carrier Thread 的 Worker Queue 中。
  4. 执行 signalWork(),唤醒挂起的 Carrier Thread 重新挂载(Mount)该 Continuation 继续执行。

四、 CPU 密集型与 I/O 密集型混合场景下的性能瓶颈分析

在真实的微服务或复杂业务场景中,应用往往既包含大量的网络/数据库 I/O,又包含 JSON 序列化、加密解密、规则引擎等 CPU 密集型计算。在虚拟线程架构下,这种混合场景极其容易触发以下性能陷阱:

1. 痛点一:Carrier Thread 被 CPU 密集型任务长久占据(Starvation & Latency Spike)

虚拟线程是非抢占式调度(Non-preemptive Scheduling) 。只有当虚拟线程主动调用了可挂起的方法(如 Socket I/O、Thread.sleep()LockSupport.park())时,Continuation.yield() 才会触发 Unmount。

如果某个虚拟线程在执行一段耗时 200ms 的 CPU 密集型计算(如大 JSON 解析/图像处理):

  • 该虚拟线程将一直锁死当前的 Carrier Thread。
  • Carrier Thread 无法剥离该 VT,其 WorkQueue 后方排队的其它 I/O 型虚拟线程无法得到响应。
  • 导致系统 P99/P999 服务响应延迟(Tail Latency)剧烈飙升。

2. 痛点二:不可 Unmount 的阻塞导致的 Carrier 线程暴涨(Compensating Threads)

当虚拟线程陷入真正的操作系统级阻塞(例如:synchronized 块引起的 Pinning、原生 JNI 调用、文件 FileChannel 同步读写):

  • JVM 无法触发 Continuation.yield()
  • ForkJoinPool 依靠 ManagedBlocker 机制检测到 AC(活跃线程数)下降。
  • 为了维持系统预设的 parallelism 吞吐量,FJP 会被迫创建新的 Carrier Thread(Compensating Thread)
  • 一旦并发量激增,Carrier Thread 数量可能迅速达到默认上限(maxPoolSize = 256),引发剧烈的操作系统线程上下文切换,直接击穿系统 CPU 缓存(L1/L2 Cache)。

五、 FIFO 与 LIFO 队列模式对比与深度调优

ForkJoinPool 内部支持两种任务获取模式:LIFO(后进先出)与 FIFO(先进先出,也称 asyncMode)。

1. 队列模式底层机制对比

特性 LIFO 模式 (asyncMode = false) FIFO 模式 (asyncMode = true)
任务弹出方向 局部 Owner 线程从 top 端 Pop(后压入的先执行) 局部 Owner 线程从 base 端 Poll(先压入的先执行)
算法倾向 深度优先(Depth-First) 广度优先(Breadth-First)
CPU 缓存利用率 极高(刚生成的任务数据还在 L1/L2 Cache 中) 较低(先入队的任务缓存可能已经失效)
公平性与延迟分布 不保证公平,先入队任务可能陷入长尾延迟 严格保证公平性,任务响应时间分布均匀
适用场景 传统 Fork/Join 分治计算(如 RecursiveTask) 虚拟线程调度、事件驱动、HTTP 请求处理

2. 为什么虚拟线程默认且必须强制开启 FIFO (asyncMode)

VirtualThread.DEFAULT_SCHEDULER 中,FJP 的 asyncMode 被硬编码设置为 true(即 FIFO 模式)。

理由如下:

当一个虚拟线程处理网络请求时,可能会频繁触发 unpark() 并重新压入 WorkQueue

  • 如果使用 LIFO 模式 :最新被唤醒的虚拟线程总是优先抢占 CPU,极度活跃的 VT 会导致早期挂起但已被唤醒的 VT 长期无法获得 Carrier 执行权,造成严重的线程饥饿(Starvation)与高 P99 延迟
  • 如果使用 FIFO 模式 :所有就绪的虚拟线程按照入队顺序依次排队 Mount 到 Carrier Thread 上,确保了处理请求的响应时间公平性(Fairness)

六、 混合场景参数调优与系统工程最佳实践

为了在 CPU 密集与 I/O 密集混合的高并发场景下最大化虚拟线程吞吐量,必须针对 JVM 参数及代码架构进行精准调优。

1. 核心 JVM 参数调优矩阵

虚拟线程调度器提供了一系列以 jdk.virtualThreadScheduler. 开头的系统属性:

bash 复制代码
java \
  -Djdk.virtualThreadScheduler.parallelism=16 \
  -Djdk.virtualThreadScheduler.maxPoolSize=32 \
  -Djdk.virtualThreadScheduler.minRunnable=8 \
  -Djdk.tracePinnedThreads=short \
  -jar app.jar
parallelism(并行度 / 核心 Carrier 线程数)
  • 默认值: Runtime.getRuntime().availableProcessors()(逻辑 CPU 核心数)。
  • 调优策略:
  • 纯非阻塞 I/O 场景: 保持默认值即可。增加 parallelism 并不能提升网络 I/O 吞吐,反而增加线程切换开销。
  • 混合型场景(含 CPU 计算): 千万不要 为了解决 CPU 密集计算慢而盲目调大 parallelism!这会导致 CPU 核心过度上下文切换。应该将 parallelism 维持在物理 CPU 核心数附近(例如设置为 N N N 或 N + 1 N+1 N+1)。
maxPoolSize(最大 Carrier 线程上限)
  • 默认值: 256
  • 调优策略:
  • maxPoolSize 是当发生无法 Unmount 的阻塞(如 Pinning 或 ManagedBlocker)时,FJP 容忍创建的最大应急补偿线程数。
  • 如果代码库中存在无法消除的阻塞(如旧版 JDBC 驱动、文件 I/O),应当限制 maxPoolSize(如设为 2 * parallelism64),防止在大促流量下 JVM 瞬间创建数百个内核线程导致 OOM 或 CPU 瘫痪
minRunnable(最小保证运行线程数)
  • 默认值: Math.max(1, parallelism / 2)
  • 调优策略: 当大量 Carrier Thread 因补偿机制处于挂起状态时,保证池内至少有 minRunnable 个活跃 Carrier 处于可运行状态。对于低延迟敏感型应用,可将其调整为等于 parallelism

2. 混合场景架构落地三大原则(舱壁隔离与防 Pinning)

原则一:CPU 密集型任务与虚拟线程解耦(舱壁隔离 Bulkheading)

绝对不要在虚拟线程中直接跑耗时超过 1ms 的纯 CPU 计算逻辑! 必须将 CPU 密集型计算剥离到独立的、有界限的平台线程池(Platform Thread Pool)中执行:

java 复制代码
public class HybridWorkloadService {
    // 专门用于 CPU 密集型任务(如复杂加解密/图像处理)的平台线程池
    private static final ExecutorService CPU_BOUND_EXECUTOR = 
        Executors.newFixedThreadPool(Runtime.getRuntime().availableProcessors(),
            new ThreadFactoryBuilder().setNameFormat("cpu-worker-%d").build());

    public void handleRequest(Request req) {
        // 1. 虚拟线程处理 I/O (网络读取)
        String rawData = httpSubRequest(req.getUrl());

        // 2. 将 CPU 密集型计算切出到平台线程池,避免锁死 Carrier Thread
        CompletableFuture<Result> future = CompletableFuture.supplyAsync(() -> {
            return heavyCpuCalculation(rawData); // 耗时计算
        }, CPU_BOUND_EXECUTOR);

        // 3. 虚拟线程无阻塞等待结果 (Continuation.yield, 释放 Carrier)
        Result result = future.join(); 

        // 4. 虚拟线程继续处理 I/O (数据库写入)
        saveToDb(result);
    }
}
原则二:彻底消除 Pinning(载体线程锚定)

在 JVM 启动参数中加入 -Djdk.tracePinnedThreads=full。在日志中监控是否有 pinned 提示。

  • 痛点: 虚拟线程在 synchronized 块内执行 I/O 或挂起时,会导致 Carrier 被 Pin 住。
  • 重构方案: 全面使用基于 AQS 的 ReentrantLock 替换 synchronized。AQS 内部使用 LockSupport.park(),在虚拟线程下完美支持 Continuation.yield(),可以 100% 释放 Carrier Thread。
java 复制代码
// ❌ 极度危险:在 synchronized 内部发起 blocking I/O 导致 Carrier Thread Pinning
public synchronized String badRead() {
    return restTemplate.getForObject(url, String.class);
}

// ✅ 正确写法:使用 ReentrantLock (基于 AQS),无 Pinning 风险
private final ReentrantLock lock = new ReentrantLock();
public String goodRead() {
    lock.lock();
    try {
        return restTemplate.getForObject(url, String.class);
    } finally {
        lock.unlock();
    }
}
原则三:控制文件 I/O 的并发粒度

在 Linux 操作系统中,本地文件读写(File I/O)在内核层面通常不支持真正的非阻塞 epoll 异步唤醒。Java 的 FileChannel 阻塞操作会依赖 FJP 的 ManagedBlocker 触发补偿线程创建。

  • 最佳实践: 对于超高并发的文件读写,应使用有界信号量(Semaphore)限制同时进行文件 I/O 的虚拟线程数量,或者使用专门的 I/O 平台线程池代理文件读取,防止 FJP 的 Carrier 线程池因为文件阻塞而触发暴涨。

七、 总结

  1. 底层机制: 虚拟线程的承载池 ForkJoinPool 采用无锁双端队列(Deque),通过 top(LIFO,Owner 独占)和 base(FIFO,Stealer CAS 争抢)指针实现了极致的 Work-Stealing 吞吐。
  2. 调度策略: 虚拟线程强制启用 FIFO 模式(asyncMode = true ,牺牲了部分 L1/L2 Cache 的极小局部性,换取了高并发请求场景下线程调度的公平性与极低的尾部延迟(Tail Latency)
  3. 调优策略: 在 CPU 与 I/O 混合场景中,不能盲目增加 parallelism**;解决性能瓶颈的根本方法是 隔离 CPU 密集型任务至专属平台线程池**,并消除 synchronized 导致的 Carrier Pinning
相关推荐
吹什么轩1 小时前
c++复习:c++11:lambda表达式
开发语言·c++
不会代码的小猴1 小时前
标准模板库(STL)
开发语言·c++·笔记·算法
掉鱼的猫1 小时前
Solon 的日志设计哲学:不只是用 SLF4J 做统一门面
java
szephyr1 小时前
腾讯云 ADP 智能体的 Skills 配置变更后没有走灰度,直接全量替换出了问题怎么定位?
java·前端·腾讯云·腾讯云adp
leisoo80971 小时前
股票数据本地化存储实战:JSON、数据库与列式存储的方案对比
jvm·数据库·json
evans在进步2 小时前
LeetCode 200:岛屿数量——Java DFS 染色法详解
java·leetcode·深度优先
旖旎夜光2 小时前
LeetCode 202:快乐数(双指针问题) —— 题解
数据结构·c++·算法·leetcode·双指针
带刺的坐椅2 小时前
Solon 的日志设计哲学:不只是用 SLF4J 做统一门面
java·logback·solon·log4j2
Flittly2 小时前
【雕虫大技】Agent 动态 Skill 供应链安全加固(二):执行隔离沙箱实战
java·spring boot·spring