Java 中 ForkJoinPool 和工作窃取怎么用?
ForkJoinPool 适用于 CPU 密集、可递归分治任务,通过 RecursiveTask/RecursiveAction 的 fork/join 拆分合并;工作窃取是其双端队列调度机制,默认并行度构造池等于 CPU 核数、commo
ForkJoinPool 的正确用法可以概括为一句话:把任务拆成能独立计算的子任务(用 RecursiveTask 或 RecursiveAction),子任务用 fork() 提交,结果用 join() 合并,并且只在 CPU 密集、可递归分治的场景里用。阻塞式 IO、带锁共享状态、拆分收益小于拆分成本的任务,都不该塞进这个池;并行度默认等于 CPU 核数,commonPool 还比核数少 1,池被塞满后任务只会排队,不会自动扩容。
ForkJoinPool 和工作窃取是什么关系?
工作窃取(work-stealing)是 ForkJoinPool 的调度算法,ForkJoinPool 是 Java 7 引入的、唯一使用这套算法的线程池实现(java.util.concurrent.ForkJoinPool)。结论:ForkJoinPool 是外壳,工作窃取是它区别于 ThreadPoolExecutor 的核心机制。
具体做法是:每个 worker 线程持有一个双端队列(deque)。线程自己产生的子任务用 push 压到自己队列的队头,执行时从队头 pop,也就是后进先出(LIFO),这样刚拆出来的子任务大概率还在 CPU 缓存里,命中率高。当一个线程的队列空了,它就从别人队列的队尾“偷”一个任务(FIFO),队尾是最早压入、粒度最大的任务,偷一次够干一阵,窃取频率自然低。任务从队尾被偷时,正好避开了主人线程在队头的高频操作,竞争因此很小。为防止队列头尾落在同一缓存行,JDK 8 起 ForkJoinPool.WorkQueue 用 @sun.misc.Contended 做了缓存行填充。
ForkJoinPool 的并行度默认是多少,怎么改?
结论:new ForkJoinPool() 的默认并行度等于 Runtime.getRuntime().availableProcessors(),而 ForkJoinPool.commonPool() 的并行度是 availableProcessors() - 1(最少为 1)。这两个数并不一样,写代码时不要混用。
改法有三条:
- 构造时显式传参:
new ForkJoinPool(8); - 启动参数覆盖 commonPool:
-Djava.util.concurrent.ForkJoinPool.common.parallelism=8; - Java 9 起可用
ForkJoinPool.commonPool()配合自定义ForkJoinWorkerThreadFactory设置线程名、优先级。
并行度不等于线程数:worker 线程数会在并行度基础上,因阻塞补偿(ManagedBlocker)临时增加。所以 getPoolSize() 可能大于 getParallelism()。
怎么用 RecursiveTask 写一个分治任务?
结论:标准写法是先 fork() 一半、compute() 另一半、最后 join() 那一半,只 fork 一次,不要 fork 完两个子任务再都去 join。下面是一个数组求和的完整例子,阈值 10000:
class SumTask extends RecursiveTask<Long> {
private static final int THRESHOLD = 10_000;
private final long[] a; private final int lo, hi;
SumTask(long[] a, int lo, int hi) { this.a = a; this.lo = lo; this.hi = hi; }
@Override protected Long compute() {
if (hi - lo <= THRESHOLD) {
long s = 0;
for (int i = lo; i < hi; i++) s += a[i];
return s;
}
int mid = (lo + hi) >>> 1;
SumTask left = new SumTask(a, lo, mid);
SumTask right = new SumTask(a, mid, hi);
left.fork(); // 交给别的线程偷
long r = right.compute(); // 当前线程继续算
return left.join() + r; // 等左半结果
}
}
long sum = new ForkJoinPool().invoke(new SumTask(arr, 0, arr.length));
三个细节决定性能:阈值别设太小(拆到单个元素时,fork/join 的开销会超过计算本身,1 万元素量级是常见起点,最终要压测定);compute() 里不要用 synchronized;fork() 之后当前线程必须干活(compute() 或 invokeAll),否则等于空转。
ForkJoinPool 有哪些容易踩的坑?
结论:最大的坑是把阻塞任务丢进 ForkJoinPool,尤其是 commonPool——线程被 IO 卡住后,并行度会被迅速打满,后面的任务全部排队。
四个高频问题:
- 阻塞调用:
compute()里做 JDBC、HTTP 调用,必须用ForkJoinPool.managedBlock(),并配ManagedBlocker实现,池才会补偿空闲线程;否则用独立线程池隔离。 - commonPool 被 parallelStream 共用:
list.parallelStream()默认就跑在 commonPool 上,业务自己的 ForkJoin 任务和它抢同一批线程。要么自定义池(把 stream 放到pool.submit(() -> stream...)里跑),要么业务侧不用parallelStream()。 - 跨池使用:一个
ForkJoinTask只能在它被提交的那个池里join(),跨池 join 会退化甚至卡住。 - 误用场景:斐波那契、有序链表遍历这类拆分开销大于计算量的任务,用 ForkJoin 比串行还慢。
怎么监控 ForkJoinPool 跑得好不好?
结论:看 getStealCount(),它能直接反映负载是否均衡。其他三个指标也要一起看:getParallelism()(并行度)、getActiveThreadCount()(活跃线程数)、getQueuedSubmissionCount()(排队任务数)。
窃取次数长期接近 0,说明任务粒度太粗或数据倾斜严重;持续高涨且队列长期非空,说明并行度不够或存在阻塞。这几个方法都是 O(1) 的开销,可以定期打点到监控系统。另外注意,ForkJoinPool.commonPool() 的 shutdown() 是空操作,commonPool 无法关闭。
总结一下:ForkJoinPool 的价值是把分治任务映射到大粒度 CPU 并行上,靠每个 worker 一个 deque 和“自己 LIFO、别人 FIFO”的偷取策略把竞争压到最低。用之前先确认任务是 CPU 密集型、可分治、单次拆分收益大于开销,然后按 fork 一半 + compute 一半 + join 一半 写 compute(),阈值从 1 万量级起步实测调整。一旦涉及阻塞,要么 ManagedBlocker,要么换池。
转载请注明出处,版权归原作者所有。