ForkJoinPool 的工作窃取是怎么回事?

#Java#面试八股

ForkJoinPool 的工作窃取是怎么回事?

结论先行:ForkJoinPool 是为分治任务设计的线程池。每个 worker 线程有自己的双端队列(deque):自己产生的子任务压入队头、也从队头取(LIFO,缓存友好);空闲时从别的队列尾部偷任务(FIFO,冲突小、偷到的粒度大)。这套机制叫工作窃取(work stealing)。

1. 与普通线程池的差别

维度ThreadPoolExecutorForkJoinPool
队列模型所有线程共享一个阻塞队列每个 worker 一个双端队列
取任务方式从队头取,FIFO自己从队头取(LIFO),别人从队尾偷
任务拆分不支持,任务粒度固定支持 fork/join 递归拆分
空闲线程阻塞等待主动去偷,保持忙碌
适用独立任务的并发执行可递归分治的计算任务

2. 窃取过程

Worker-1 的双端队列(自己从队头取,LIFO)
   队头                                          队尾
   [A3][A2][A1]  ←── 先执行 A3(最新拆出来的,数据还在缓存里)
              ▲
              │  Worker-2 空闲,从队尾偷 A1(最老的任务,粒度最大)
              │
Worker-2 的双端队列
   [B3][B2][B1]

三个设计要点:

  1. 自己取队头(LIFO):刚拆出来的子任务数据大概率还在 CPU 缓存里,局部性好;
  2. 偷队尾(FIFO):队尾是最早放入、粒度最大的任务,偷一次能偷到足够多的活,减少偷取次数;两端操作也避免了窃取者和被窃取者抢同一个位置;
  3. 偷不到就阻塞/帮忙: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 不空转。

小结

工作窃取解决的是"任务拆分后负载不均"的问题:自己的活自己从头上拿(缓存友好),没活了去偷别人的尾巴(冲突小、粒度大)。它让分治计算能自动负载均衡,代价是一旦任务里出现阻塞,整套机制就退化成普通线程池。

笔记加载中…