虚拟线程底层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(最小保证运行线程数))
- [① `parallelism`(并行度 / 核心 Carrier 线程数)](#①
- [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):
- Step 1: 线程生成一个伪随机步长,遍历
WorkQueue[]数组。 - Step 2: 如果扫描一圈未发现可窃取任务,调用
Thread.yield()降低 CPU 占用。 - 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 线程):
- Poller 线程找到该虚拟线程对应的
VirtualThread对象。 - 修改其状态为
RUNNABLE。 - 将虚拟线程的
runContinuation任务投递到ForkJoinPool的 Submission Queue (偶数索引队列)或当前 Carrier Thread 的 Worker Queue 中。 - 执行
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 * parallelism或64),防止在大促流量下 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 线程池因为文件阻塞而触发暴涨。
七、 总结
- 底层机制: 虚拟线程的承载池
ForkJoinPool采用无锁双端队列(Deque),通过top(LIFO,Owner 独占)和base(FIFO,Stealer CAS 争抢)指针实现了极致的 Work-Stealing 吞吐。 - 调度策略: 虚拟线程强制启用 FIFO 模式(
asyncMode = true) ,牺牲了部分 L1/L2 Cache 的极小局部性,换取了高并发请求场景下线程调度的公平性与极低的尾部延迟(Tail Latency)。 - 调优策略: 在 CPU 与 I/O 混合场景中,不能盲目增加
parallelism**;解决性能瓶颈的根本方法是 隔离 CPU 密集型任务至专属平台线程池**,并消除synchronized导致的 Carrier Pinning。