承接的问题
第 10 章的线程池模型是「一个共享队列 + 固定数量的线程」。这个模型有两个隐含前提:任务是独立的,任务数量在提交时大致已知。分治类任务两样都不满足——子任务是递归产生的,父任务必须等子任务算完。
用 ThreadPoolExecutor 硬做分治,会撞上一个结构性麻烦:
worker 拿到父任务 → 拆成两半 → 一半提交回队列 → 自己算另一半 → 阻塞等子任务
↓
子任务在队列里等空闲线程
↓
所有线程都在等自己提交的任务
线程数固定时,父任务占着线程等子任务,子任务等线程,就是死锁。绕开的办法只有两个:让父任务不阻塞(代码复杂度爆炸),或者线程数远大于任务数(资源浪费)。
ForkJoinPool 换了方向:不是「线程从共享队列取任务」,而是「每个线程有自己的队列,任务主动找线程」。这一节讲清三件事:队列为什么是双向的、并行度从哪里来、以及一次窃取到底搬走多少活。
每个 worker 一个双向队列
ForkJoinPool 里每个 worker 线程持有一个 WorkQueue,实现是数组版的双端队列:
| 操作者 | 动作 | 端 | 顺序 |
|---|---|---|---|
| 队列所有者 | push / pop | 头部(top) | LIFO |
| 其它 worker | 窃取 | 尾部(base) | FIFO |
两个方向是刻意反着的:
- 所有者从头部取,拿到的是自己刚
fork出去的子任务。这个任务的数据大概率还在 CPU 缓存里,而且它通常是「更小、更快能算完」的那一半。 - 窃取者从尾部取,拿到的是最老的、也就是树里层级最高、块头最大的那个任务。一次偷走一大片活,不用反复来。
这就是工作窃取的粗粒度特性:窃取次数远小于任务数,但每次搬走的是一整棵子树。后面实测会看到,8192 个叶子任务只有几十次窃取。
一个最小的 RecursiveTask 求和:
import java.util.concurrent.ForkJoinPool;
import java.util.concurrent.RecursiveTask;
public final class SumTask extends RecursiveTask<Long> {
private final int[] data;
private final int lo;
private final int hi;
private final int threshold;
public SumTask(int[] data, int lo, int hi, int threshold) {
this.data = data;
this.lo = lo;
this.hi = hi;
this.threshold = threshold;
}
@Override
protected Long compute() {
if (hi - lo <= threshold) {
long sum = 0;
for (int i = lo; i < hi; i++) {
sum += data[i];
}
return sum;
}
int mid = (lo + hi) >>> 1;
SumTask left = new SumTask(data, lo, mid, threshold);
SumTask right = new SumTask(data, mid, hi, threshold);
left.fork(); // 左半挂到本线程队列尾部
long rightSum = right.compute(); // 右半在本线程原地算
long leftSum = left.join(); // 没被偷走就在这里算,被偷走就等
return leftSum + rightSum;
}
}
三行的顺序不能换:
fork()把左半推进当前线程的队列。此刻它还不会被执行,只是「挂在那里等人偷」。compute()直接在当前线程算右半。注意是compute()不是invoke(),不能又走一遍提交。join()收左半。如果左半已经被别的线程偷走并算完,这里立刻返回;如果还在队列里,join()的线程会把它取出来自己算——join不是单纯阻塞,它会帮忙干活。
反过来写成「先 join 再 fork」,等价于串行;写成「两半都 fork 然后立刻 join」,等于把活全推回队列、自己空等,并发度反而下降。invokeAll(left, right) 是这三行的语法糖:两参数版本把第二个分片 fork 出去、在当前线程执行第一个,然后等两者结束。方向与上面的例子相反,效果一样。
还有一个容易忽略的细节:fork() 只是把任务放进队列,如果你 fork 了却从不 join,任务里抛出的异常会被吞掉。父任务拿不到子任务的结果,也拿不到它的失败。
并行度从哪里来
ForkJoinPool 的并行度是「目标并发 worker 数」,取值有三条路:
// 1. 默认:核数 - 1,最少 1
int p = ForkJoinPool.commonPool().getParallelism();
// 2. 系统属性覆盖(全 JVM 生效,启动时确定)
// -Djava.util.concurrent.ForkJoinPool.common.parallelism=3
// 3. 自建池:显式指定
try (ForkJoinPool pool = new ForkJoinPool(4)) {
long sum = pool.invoke(new SumTask(data, 0, data.length, 1 << 14));
}
三条规则值得记住:
commonPool默认是max(1, availableProcessors() - 1)。少掉的那个位置是给调用线程的:外部线程在join和并行流的终端操作里会一起干活,ForkJoinPool的源码注释把这条策略叫 caller-helps,所以并行度设在核数减一是合理的。10 核机器上就是 9 个 worker 加 1 个调用线程。- worker 是懒创建的。程序刚启动、还没提交过任务时,
commonPool.getPoolSize()是 0。它不是「启动就开 9 个线程等着」的池。 getParallelism()不是线程数的硬上限。实测里new ForkJoinPool(4)的getPoolSize()到过 7。getPoolSize()统计的是「已启动、尚未终止」的 worker,包含正在创建和正在退出的线程,所以会瞬时超过并行度;拿它当容量指标会误判。
commonPool 的线程是守护线程,且全 JVM 共享。这意味着任何一个库在你的进程里调用 parallelStream(),用的都是同一个池、同一组 worker。这条性质是 12.2 全部陷阱的根源。
本机实测:并行度、耗时与窃取
环境:Apple M1 Pro,10 核,32 GB,Temurin 25.0.5+8。测量时 load averages 在 130 上下(机器上还有别的实验在跑),所以绝对耗时只用于同一轮内的横向比较。
负载是 1 亿个 int 求和,阈值 16384,共 8192 个叶子任务。每种配置先跑一轮预热丢弃,再取三轮。
availableProcessors = 10
commonPool.parallelism = 9
commonPool.poolSize (未使用前) = 0
N = 100000000, threshold = 16384
expected sum = 4950407612
sequential loop runs=[66, 66, 67, 68] median=67ms
commonPool parallelism= 9 poolSize= 9 runs=[13ms 13ms 14ms] median=13ms steals=189 ok=true
叶子落在 10 个线程:cw-1=622, cw-2=1421, cw-3=1292, cw-4=570, cw-5=614, cw-6=1206, cw-7=365, cw-8=1239, cw-9=159, main=704
new ForkJoinPool(4) parallelism= 4 poolSize= 7 runs=[16ms 17ms 17ms] median=17ms steals=44 ok=true
叶子落在 5 个线程:fj-1-worker-1=5, fj-1-worker-3=2074, fj-1-worker-5=1976, fj-1-worker-6=2068, fj-1-worker-7=2069
new ForkJoinPool(10) parallelism=10 poolSize=10 runs=[9ms 10ms 10ms] median=10ms steals=176 ok=true
commonPool.poolSize (使用后) = 9
commonPool.stealCount 累计 = 234
再用 -Djava.util.concurrent.ForkJoinPool.common.parallelism=3 跑一遍:
commonPool.parallelism = 3
commonPool parallelism= 3 poolSize= 4 runs=[17ms 17ms 23ms] median=17ms steals=32 ok=true
叶子落在 4 个线程:cw-2=2064, cw-3=2071, cw-4=2025, main=2032
怎么读这些数:
- 顺序 67ms,9 个 worker 13ms,只有 5.2 倍而不是 9 倍。求和的瓶颈是内存带宽和最后的合并,不是纯计算;并行度越高,边际收益越小。
- 并行度 3 时是 17ms,并行度 9 时是 13ms,并行度 10 时是 10ms。三档之间是单调的,说明并行度确实映射到了实际吞吐上。如果任务本身有锁或共享写,这条曲线会提前变平——那就不是并行度的问题。
- 窃取次数 189 次(四轮累计,约每轮 47 次)对应 8192 个叶子:平均一次窃取搬走 170 多个叶子。这验证了上面说的「偷尾部的大块」——窃取发生在子树级别,不是叶子级别。
poolSize大于parallelism在实测里出现过(4→7,另一次运行里 10→11)。不要用线程数去推断并行度,反过来也不行。
还有一个观察:main 线程自己也执行了 704 个叶子。pool.invoke() 是外部提交,根任务在调用线程上执行,随后 fork 出去的分片才进入 worker 队列。所以「并行度 9」的系统里,实际参与计算的是 10 个线程。
在 libraryd-conc 里落地
借阅系统有一个库存巡检:每天闭店后对全量书目快照做一次统计,算总库存、缺货书目数、书目总数。数据已经在内存里(从库里一次性读出),纯 CPU 计算,切片互不重叠——这是 ForkJoinPool 的典型场景。
package com.plume.conc.core;
import java.util.List;
import java.util.concurrent.ForkJoinPool;
import java.util.concurrent.RecursiveTask;
public final class StockAudit {
public record Report(long totalStock, int outOfStock, int titles) {}
private static final class AuditTask extends RecursiveTask<Report> {
private static final int THRESHOLD = 512;
private final List<Book> books;
private final int lo;
private final int hi;
AuditTask(List<Book> books, int lo, int hi) {
this.books = books;
this.lo = lo;
this.hi = hi;
}
@Override
protected Report compute() {
if (hi - lo <= THRESHOLD) {
long stock = 0;
int out = 0;
for (int i = lo; i < hi; i++) {
Book book = books.get(i);
stock += book.stock();
if (book.stock() == 0) {
out++;
}
}
return new Report(stock, out, hi - lo);
}
int mid = (lo + hi) >>> 1;
AuditTask left = new AuditTask(books, lo, mid);
AuditTask right = new AuditTask(books, mid, hi);
left.fork();
Report r = right.compute();
Report l = left.join();
return new Report(l.totalStock() + r.totalStock(),
l.outOfStock() + r.outOfStock(),
l.titles() + r.titles());
}
}
private final ForkJoinPool pool = new ForkJoinPool(4);
public Report audit(List<Book> snapshot) {
if (snapshot.isEmpty()) {
return new Report(0, 0, 0);
}
return pool.invoke(new AuditTask(snapshot, 0, snapshot.size()));
}
public void close() {
pool.shutdown();
}
}
三个约束写进类里,而不是交给调用方自觉:
- 快照必须是不可变的
List<Book>。Book是 record,本身不可变;但List要传List.copyOf(...)的结果。任务里绝不能回查BookRepository——那就变成在 worker 上做 IO 了。 - 合并逻辑只在父任务里写一次。
Report是 record,合并就是三个字段相加,没有可变累加器,也就不存在「多个线程往同一个计数器上加」的竞态。 - 池有明确归属。
StockAudit自己持有 4 个 worker 的池,不用commonPool。库存巡检和业务代码里的并行流互不干扰。
这个类和 PooledLoanService 是同一层的两种选择:后者解决「大量独立请求分摊到固定线程」,前者解决「一个大任务递归切分」。选错的代价不是慢一点,是死锁或者打满 commonPool。
什么时候不要用
- 任务是 IO 型。查库、调下游、读文件时,线程在等而不是在算,并行度约等于核数的池会迅速被打空。这类活交给虚拟线程或专门的 IO 池(14.3 会讲怎么用
Semaphore保护下游)。 - 任务太短。每个叶子只有几百纳秒时,任务对象的分配、队列的 push/pop、
join的唤醒成本会超过收益。下一节的实测会给出盈亏平衡点。 - 切片之间需要共享写。分治的前提是「每一片只读自己的输入,结果在父任务合并」。如果每个任务都要往同一个
Map里写,那就退化成了一把锁加 N 个线程。 - 需要单线程语义或提交顺序。
ForkJoinPool不保证任何执行顺序,连任务之间的可见性都只靠join建立。 - 需要逐任务的超时、取消、监控。
ForkJoinTask提供了cancel,但语义很弱(12.3 会实测),没有超时,也没有按任务的执行统计。这种需求用ExecutorService+Future更直接。
常见错误与边界
| 错误 | 后果 | 正确做法 |
|---|---|---|
先 join 再 fork | 完全串行,白搭一个池 | fork → 算另一半 → join |
两半都 fork 后立刻 join | 自己空等,队列里堆满任务 | 至少留一半在本地 compute() |
fork 了不 join | 异常被吞,结果丢失 | 每个 fork 都要有对应的 join/invokeAll |
| 在任务里阻塞等 IO | worker 被占住,池饿死 | 把 IO 移到池外,或换虚拟线程 |
用 commonPool 跑长任务 | 全 JVM 的 parallelStream 一起变慢 | 自建池,明确归属 |
用 getPoolSize() 当并行度 | 误判容量(实测 4→7) | 用 getParallelism() |
在 compute() 里改共享集合 | 数据竞争,结果不确定 | 只读输入,结果向上合并 |
| 递归不设阈值 | 任务数爆炸,栈深和分配都不可控 | 叶子设阈值,见 12.3 |
递归深度也要设上限。 上面的 SumTask 靠阈值自然收敛;如果拆分条件依赖数据内容(比如按前缀分组),一定要加一个「深度到顶就顺序处理」的分支,否则遇到退化输入会栈溢出。
小结
ForkJoinPool的核心不是「更大的线程池」,而是「每个 worker 一个双端队列」:所有者从头部 LIFO 取,窃取者从尾部 FIFO 偷。方向相反是为了让本地任务热、让窃取粒度粗。fork→ 算另一半 →join的顺序不能换。join会帮忙执行,不是单纯阻塞;fork了不join会吞异常。commonPool的并行度是availableProcessors() - 1,可被系统属性覆盖;worker 懒创建;getParallelism()不是线程数上限(实测出现过 4→7)。- 本机 10 核、1 亿整数求和:顺序 67ms,
commonPool13ms(189 次窃取),并行度 3 时 17ms。8192 个叶子只对应几十次窃取,说明窃取发生在子树层级。 - 适合纯 CPU、可切分、切片不重叠的任务;IO 型、超短任务、需要逐任务控制的任务都不该用它。
下一节把同样的机制换一层皮:parallelStream()。语法上只多了一个 .parallel(),但它把「用哪个池、拆多细、有没有副作用」三件事都藏了起来。
阅读导航:上一节 11.3 调度漂移、幂等与补偿 | 下一节 12.2 并行流的边界与 commonPool 陷阱
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。