ForkJoinPool 的工作窃取是怎么回事?
结论先行:ForkJoinPool 是为分治任务设计的线程池。每个 worker 线程有自己的双端队列(deque):自己产生的子任务压入队头、也从队头取(LIFO,缓存友好);空闲时从别的队列尾部偷任务(FIFO,冲突小、偷到的粒度大)。这套机制叫工作窃取(work stealing)。
1. 与普通线程池的差别
| 维度 | ThreadPoolExecutor | ForkJoinPool |
|---|---|---|
| 队列模型 | 所有线程共享一个阻塞队列 | 每个 worker 一个双端队列 |
| 取任务方式 | 从队头取,FIFO | 自己从队头取(LIFO),别人从队尾偷 |
| 任务拆分 | 不支持,任务粒度固定 | 支持 fork/join 递归拆分 |
| 空闲线程 | 阻塞等待 | 主动去偷,保持忙碌 |
| 适用 | 独立任务的并发执行 | 可递归分治的计算任务 |
2. 窃取过程
Worker-1 的双端队列(自己从队头取,LIFO)
队头 队尾
[A3][A2][A1] ←── 先执行 A3(最新拆出来的,数据还在缓存里)
▲
│ Worker-2 空闲,从队尾偷 A1(最老的任务,粒度最大)
│
Worker-2 的双端队列
[B3][B2][B1]
三个设计要点:
- 自己取队头(LIFO):刚拆出来的子任务数据大概率还在 CPU 缓存里,局部性好;
- 偷队尾(FIFO):队尾是最早放入、粒度最大的任务,偷一次能偷到足够多的活,减少偷取次数;两端操作也避免了窃取者和被窃取者抢同一个位置;
- 偷不到就阻塞/帮忙:
ForkJoinPool的 worker 在空闲时会扫描其他队列,实在没有任务才park。
3. 代码:分治求和
class SumTask extends RecursiveTask<Long> {
private static final int THRESHOLD = 10_000;
private final long[] arr;
private final int lo, hi;
SumTask(long[] arr, int lo, int hi) {
this.arr = arr; this.lo = lo; this.hi = hi;
}
@Override
protected Long compute() {
if (hi - lo <= THRESHOLD) { // 小到阈值就直接算
long sum = 0;
for (int i = lo; i < hi; i++) sum += arr[i];
return sum;
}
int mid = (lo + hi) >>> 1;
SumTask left = new SumTask(arr, lo, mid);
SumTask right = new SumTask(arr, mid, hi);
left.fork(); // 压入自己队列,可能被别人偷走
long rightResult = right.compute(); // 当前线程先算右半边
long leftResult = left.join(); // 等左半边(若被偷则等它算完)
return leftResult + rightResult;
}
}
long[] arr = new long[10_000_000];
long total = ForkJoinPool.commonPool().invoke(new SumTask(arr, 0, arr.length));
fork() 不创建线程,只是把任务压进当前 worker 的队列;join() 也不是简单阻塞——如果任务还没被偷走,当前线程会就地把它执行掉,避免空等。
4. 使用禁忌
| 禁忌 | 后果 | 替代 |
|---|---|---|
| 在任务里做阻塞 IO | 占住 worker,窃取机制失效 | 用独立线程池或虚拟线程 |
| 任务粒度过小 | fork/join 开销超过计算本身 | 调大 THRESHOLD |
| 任务间有共享可变状态 | 仍需自己同步 | 用不可变数据或归约结果 |
在 commonPool 里跑长任务 | 拖慢同 JVM 所有并行流 | 自建 ForkJoinPool |
5. 常见追问
追问一:commonPool 的并行度是多少?
默认是 CPU 核数 - 1(至少 1)。减去 1 是为了给调用线程留一个核,避免并行任务把整个机器占满。可以通过 -Djava.util.concurrent.ForkJoinPool.common.parallelism=N 调整。
追问二:为什么并行流要慎用?
parallelStream() 用的就是 commonPool。在 Web 应用里对一个阻塞型操作开并行流,会把公共池占满,导致其他并行流全部排队;而且它无法设置超时、无法隔离。
追问三:工作窃取一定比共享队列快吗?
不一定。它适合"任务可以拆成很多小任务且执行时间不均"的场景;如果任务本身粒度均匀、数量固定,ThreadPoolExecutor 的共享队列更简单、开销更低。
追问四:join() 会阻塞吗?
视情况。如果被 join 的任务还在自己队列里没被偷走,当前线程会直接执行它("帮助"语义);如果已经被偷走,才真正阻塞等待。这保证了 CPU 不空转。
小结
工作窃取解决的是"任务拆分后负载不均"的问题:自己的活自己从头上拿(缓存友好),没活了去偷别人的尾巴(冲突小、粒度大)。它让分治计算能自动负载均衡,代价是一旦任务里出现阻塞,整套机制就退化成普通线程池。