ForkJoinPool 是什么?工作窃取(work-stealing)是怎么工作的?
简化版
ForkJoinPool 是 Java 7 引入的、专门用于「分治并行计算」的线程池——把一个大任务递归拆分(fork) 成多个小任务并行执行,再把结果合并(join),适合「大任务能拆成独立小任务」的场景(大数组求和、归并排序、并行流)。它和普通线程池(ThreadPoolExecutor)最大的区别是「工作窃取(work-stealing)」算法:每个工作线程有自己的双端队列(deque)存任务,处理完自己的任务后,会去「偷」其他忙碌线程队列尾部的任务来做——这样让线程都不闲着、负载均衡,比「所有线程抢一个公共队列」(普通线程池)竞争更小、效率更高。parallelStream() 和 CompletableFuture 默认用的 ForkJoinPool.commonPool() 就是它。
详细版
Fork/Join 的核心思想:分治:
// 用 ForkJoin 并行计算大数组的和(分治)
class SumTask extends RecursiveTask<Long> { // 有返回值用 RecursiveTask,无返回用 RecursiveAction
int[] arr; int lo, hi;
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) / 2;
SumTask left = new SumTask(arr, lo, mid);
SumTask right = new SumTask(arr, mid, hi);
left.fork(); // fork:拆出子任务,异步执行
long rightResult = right.compute(); // 当前线程直接算右半(优化:不都 fork)
long leftResult = left.join(); // join:等左半结果
return leftResult + rightResult; // 合并
}
}
ForkJoinPool pool = new ForkJoinPool();
long total = pool.invoke(new SumTask(arr, 0, arr.length));
工作窃取(work-stealing):
普通线程池(ThreadPoolExecutor):
所有线程共享一个任务队列 → 取任务要竞争同一把锁 → 高并发下队列成瓶颈
ForkJoinPool(工作窃取):
每个工作线程有自己的双端队列(work-stealing deque)
- 自己的任务从队列"头部"取(LIFO,后进先出,利于缓存)
- 偷别人的任务从别人队列"尾部"偷(FIFO,减少和队列主人的冲突)
→ 线程忙完自己的就去偷别人的,都不闲着、负载均衡、竞争小
⚠️ ForkJoinPool 适合「CPU 密集 + 任务能拆分 + 任务独立」的计算,不适合有阻塞操作(IO、锁等待)的任务——因为 ForkJoinPool 的线程数默认等于 CPU 核数,如果任务里有阻塞(如查数据库),线程被阻塞会导致「没有足够线程干活」、CPU 利用率上不去。所以「ForkJoin 用于纯计算分治,IO 密集用普通线程池」。
parallelStream底层是 commonPool,所以并行流里也别做阻塞操作。
完整版教学
一、Fork/Join 解决什么:分治并行
ForkJoinPool 是为「分治(divide and conquer)算法的并行化」设计的——很多问题可以「拆成小问题、分别解决、再合并」:
分治的思路(很多问题都适用):
大问题 → 拆成若干小问题(Fork)→ 小问题分别解决 → 合并结果(Join)
例:
数组求和:拆成两半分别求和、再相加
归并排序:拆成两半分别排序、再归并
快速排序:按基准拆分、分别排序
Fork/Join 把"分治"并行化:
拆出的子任务可以"并行"执行(不同线程同时算不同的小任务)
→ 充分利用多核,加速计算
Fork/Join 的价值是「让分治算法能并行、充分利用多核」——普通的递归分治是单线程的(一个个算),Fork/Join 让拆出的子任务并行执行。它的两个核心操作:fork()(拆出子任务、异步执行) 和 join()(等子任务结果、合并)。适合「大任务能拆成独立小任务」的 CPU 密集计算。理解「ForkJoin 是分治并行、fork 拆 join 合、适合能拆分的 CPU 密集任务」,就理解了它的定位——它是「并行版的分治」。
二、RecursiveTask 与 RecursiveAction
Fork/Join 的任务要继承两个抽象类之一,实现 compute():
RecursiveTask<V>:有返回值的任务(compute 返回 V)
如数组求和(返回和)、归并排序(返回排序结果)
RecursiveAction:无返回值的任务(compute 返回 void)
如原地排序、并行遍历处理
compute() 的标准写法(分治模板):
if (任务足够小) {
直接计算并返回; // 递归基:小任务直接算,不再拆
} else {
拆成子任务;
子任务.fork(); // 异步执行子任务
合并子任务的结果; // join 等结果
}
任务的写法遵循「分治模板」——先判断「任务够不够小」(够小就直接算,这是递归基/阈值),否则拆成子任务、fork 执行、join 合并。阈值(THRESHOLD)的选择很关键:太小(拆得太细)→ 任务太多、fork/join 的开销超过并行收益;太大(拆得太粗)→ 并行度不够、多核用不满。一般阈值设成「让每个小任务的计算量足够大、盖过 fork/join 开销」。理解「RecursiveTask 有返回值/RecursiveAction 无返回、compute 用分治模板、阈值要合适」,就掌握了写 Fork/Join 任务的方法。
三、工作窃取:ForkJoinPool 的核心
ForkJoinPool 和普通线程池最大的区别是「工作窃取(work-stealing)」算法——这是它高效的关键:
普通线程池(ThreadPoolExecutor):
所有工作线程共享一个任务队列
→ 每个线程取任务都要竞争这个公共队列的锁
→ 线程多、任务多时,公共队列成为竞争瓶颈
ForkJoinPool(工作窃取):
每个工作线程有自己的"双端队列(deque)"存任务
→ 线程处理自己队列的任务(从头部取,LIFO)
→ 处理完了,去"偷"其他线程队列尾部的任务(work-stealing)
→ 大部分时间各线程操作自己的队列(无竞争),只有偷任务时才涉及别人的队列
工作窃取的精髓是「让每个线程尽量处理自己的任务(无竞争),忙完了才去偷别人的(负载均衡)」。这解决了普通线程池「所有线程抢一个公共队列」的竞争问题——ForkJoin 里每个线程有私有队列,大部分操作无锁竞争。而「偷任务」保证了「没有线程闲着」(一个线程的任务做完了,去帮忙做别人的),实现负载均衡。理解「工作窃取=每线程私有双端队列+忙完偷别人尾部任务、减少竞争+负载均衡」,就掌握了 ForkJoinPool 的核心机制——这是它区别于普通线程池的关键。
四、为什么偷「尾部」、自己取「头部」
工作窃取有个精妙的细节——线程处理自己的任务从队列「头部」取,偷别人的任务从队列「尾部」偷,这个方向设计有讲究:
线程自己的任务队列(双端队列):
[尾] ←── 新 fork 的子任务从这端加入,也从这端取(自己用,LIFO 头部)── [头]
自己取任务:从"头部"(最近 fork 的,LIFO 后进先出)
→ 最近拆出的子任务,相关数据可能还在 CPU 缓存里 → 缓存友好
→ 且 LIFO 有利于"深度优先"完成一条分治链
别的线程偷任务:从"尾部"(最早的、最大的任务)
→ 尾部是最早 fork 的、通常是"更大的任务"(还没被拆分的)
→ 偷一个大任务,偷一次能干很久,减少偷的频率
→ 且尾部远离队列主人正在操作的头部 → 减少和主人的冲突
两个方向的设计原因:① 自己从头部取(LIFO)——最近 fork 的任务数据在缓存里(缓存友好),且利于深度优先完成分治;② 偷的从尾部偷(FIFO)——尾部是最早的、通常最大的任务(偷一次能干很久、减少偷的开销),且尾部远离主人操作的头部(减少冲突)。这个「自己头部、偷尾部」的双端设计,让「自己用高效、偷任务冲突小」。理解「自己头部取(缓存友好+深度优先)、偷尾部(偷大任务减少频率+减少冲突)」,就理解了工作窃取的精妙细节——这是能体现深度的追问点。
五、commonPool 与并行流
ForkJoinPool 有一个特殊的「公共池(commonPool)」——ForkJoinPool.commonPool(),是全 JVM 共享的默认 ForkJoinPool:
ForkJoinPool.commonPool():
全 JVM 共享的公共 ForkJoinPool
线程数默认 = CPU 核数 - 1(为 CPU 密集设计)
谁在用它:
parallelStream():并行流底层用 commonPool
CompletableFuture 不传线程池时:用 commonPool
→ 所以并行流和 CF 的默认并行都跑在这一个公共池上
一个重要的坑(前面 CompletableFuture 题讲过):commonPool 是全 JVM 共享、线程数约等于 CPU 核数,如果在里面做阻塞操作(IO、锁等待),会占满线程、拖垮其他用 commonPool 的任务。所以:① parallelStream 里别做阻塞操作(会阻塞 commonPool 的线程、影响整个 JVM 的并行流);② CompletableFuture 的 IO 任务要传自定义线程池(别用默认的 commonPool)。理解「commonPool 是全 JVM 共享的默认 ForkJoinPool、parallelStream/CompletableFuture 默认用它、别在里面做阻塞操作」,就理解了 ForkJoin 在实际中的应用和坑。
六、适用场景与注意点
ForkJoinPool 的适用场景和注意点:
适合 ForkJoin(CPU 密集 + 可拆分 + 独立):
✓ 大数组/大集合的并行计算(求和、映射、过滤、归约)
✓ 归并排序、快速排序等分治算法的并行化
✓ 大规模的独立计算任务
不适合(会拖垮 ForkJoinPool):
✗ 有阻塞操作的任务(IO、锁等待、sleep)
→ 线程数=核数,阻塞会导致没线程干活、CPU 利用率低
→ 这类用普通线程池(线程数可以设多)
✗ 任务之间有依赖/共享状态(分治要求子任务独立)
✗ 任务太小、拆分开销超过收益(阈值要设合理)
注意点:
- 阈值(THRESHOLD)要合适:太小任务太多开销大、太大并行度不够
- fork 后用 join 等结果(别忘了 join)
- 优化:拆成两半时,一个 fork、另一个当前线程直接 compute(少一次 fork/join 开销)
核心判断:ForkJoin 用于「CPU 密集、能拆分成独立小任务」的计算(大数组处理、分治算法),不适合有阻塞(IO)或任务有依赖的场景(那用普通线程池)。实践中直接用 ForkJoinPool 写分治的不多(parallelStream 已经封装好了),但理解它的原理(工作窃取)很重要——它是并行流、CompletableFuture 的底层。理解「ForkJoin 适合 CPU 密集可拆分独立任务、不适合阻塞/有依赖、阈值要合适」,就掌握了它的选型。
记忆钩子:「ForkJoinPool 是分治并行线程池:fork 拆子任务、join 合结果,任务继承 RecursiveTask(有返回)/RecursiveAction(无返回)、compute 用分治模板(够小直接算否则拆);核心是工作窃取——每线程私有双端队列,自己从头部取(LIFO 缓存友好)、忙完偷别人尾部(FIFO 偷大任务减冲突),减少竞争+负载均衡;commonPool 是全 JVM 共享默认池(parallelStream/CompletableFuture 用它,别做阻塞操作);适合 CPU 密集可拆分独立任务,不适合阻塞/有依赖」。
七、常见误区与追问
- 误区:ForkJoinPool 和普通线程池一样。 最大区别是工作窃取——每个线程有私有双端队列、忙完偷别人的任务(减少竞争、负载均衡),而普通线程池所有线程共享一个队列(竞争瓶颈)。
- 误区:ForkJoin 适合所有并行任务。 只适合 CPU 密集、能拆分成独立小任务的计算;有阻塞操作(IO)的任务会占满线程(线程数=核数)、拖垮它,应用普通线程池。
- 误区:parallelStream 用的是自己的线程池。 用的是全 JVM 共享的 ForkJoinPool.commonPool(CompletableFuture 默认也用它),所以并行流里做阻塞操作会影响整个 JVM 的并行流。
- 误区:任务拆得越细并行越快。 拆太细任务太多、fork/join 开销超过并行收益;要设合适的阈值(让每个小任务计算量盖过 fork/join 开销)。
- 追问:工作窃取是怎么工作的? 每个工作线程有私有的双端队列,处理自己队列的任务(从头部取,LIFO),忙完后去偷其他线程队列尾部的任务(FIFO)——自己头部取缓存友好、偷尾部偷大任务减少冲突,实现减少竞争和负载均衡。
- 追问:为什么自己从头部取、偷从尾部偷? 自己头部取是 LIFO(最近 fork 的任务数据在缓存里、缓存友好、利于深度优先完成分治);偷从尾部(最早 fork 的、通常是更大的任务,偷一次能干很久减少偷的频率,且远离主人操作的头部减少冲突)。
- 追问:ForkJoin 里能做 IO 操作吗? 不推荐——ForkJoinPool 线程数默认等于 CPU 核数,IO 阻塞会导致线程被占、没有足够线程干活、CPU 利用率低;IO 密集任务应用线程数可调大的普通线程池。
八、加强记忆
ForkJoinPool(Java 7)是专门用于「分治并行计算」的线程池——把大任务递归拆分(fork) 成独立小任务并行执行、再合并(join) 结果,适合「大任务能拆成独立小任务」的 CPU 密集计算(大数组处理、归并/快排等分治算法)。任务继承 RecursiveTask<V>(有返回值) 或 RecursiveAction(无返回值),compute() 用分治模板(任务够小直接算、否则拆成子任务 fork/join,阈值要合适——太小开销大、太大并行度不够)。它和普通线程池最大的区别是「工作窃取(work-stealing)」——每个工作线程有私有的双端队列,自己的任务从头部取(LIFO、缓存友好、利于深度优先)、忙完后去偷别人队列尾部的任务(FIFO、偷大任务减少频率+远离主人减少冲突),从而减少竞争 + 负载均衡(优于普通线程池「所有线程抢一个公共队列」)。ForkJoinPool.commonPool() 是全 JVM 共享的默认池,parallelStream/CompletableFuture 默认用它——别在里面做阻塞操作(线程数≈核数,阻塞会拖垮)。ForkJoin 不适合有阻塞(IO)或任务有依赖的场景(那用普通线程池)。一句话「ForkJoin 分治并行(fork 拆 join 合、RecursiveTask/Action)、核心是工作窃取(私有双端队列、自己头部取偷别人尾部、减竞争+负载均衡)、commonPool 全 JVM 共享别阻塞、适合 CPU 密集可拆分任务」。