《Java 并发编程·线程与内存模型》12.1 工作窃取与 ForkJoinPool

线程池是任务等线程,ForkJoinPool 是任务找线程:每个 worker 一个双向队列,自己从头部取最新的任务,空闲线程从尾部偷最老的任务。本机 10 核实测 commonPool 并行度 9,1 亿整数求和在四轮里从顺序 67ms 降到 13ms,池自己记录到 189 次窃取;并行度改成 3 后是 17ms。

承接的问题

第 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;
    }
}

三行的顺序不能换:

  1. fork() 把左半推进当前线程的队列。此刻它还不会被执行,只是「挂在那里等人偷」。
  2. compute() 直接在当前线程算右半。注意是 compute() 不是 invoke(),不能又走一遍提交。
  3. 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
在任务里阻塞等 IOworker 被占住,池饿死把 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,commonPool 13ms(189 次窃取),并行度 3 时 17ms。8192 个叶子只对应几十次窃取,说明窃取发生在子树层级。
  • 适合纯 CPU、可切分、切片不重叠的任务;IO 型、超短任务、需要逐任务控制的任务都不该用它。

下一节把同样的机制换一层皮:parallelStream()。语法上只多了一个 .parallel(),但它把「用哪个池、拆多细、有没有副作用」三件事都藏了起来。

阅读导航:上一节 11.3 调度漂移、幂等与补偿 | 下一节 12.2 并行流的边界与 commonPool 陷阱

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《Java 消息中间件·事件驱动架构》附录 B 架构决策记录模板
  2. 《Java 消息中间件·事件驱动架构》附录 A 术语表
  3. 《Java 消息中间件·事件驱动架构》15.3 全书验收