
1. ForkJoin 到底要解决什么问题从分治法的困局说起如果你写过递归算法比如归并排序、二叉树遍历、大文件求和大概率遇到过这样一个尴尬场景单线程递归在天花板级别的问题规模下跑得也不算慢但一旦数据量上到千万级别CPU 明明有 8 核 16 核却只能看着一个核在那拼命跑其他核全程围观。你可能会想这不简单吗把每个递归分支丢到一个线程池里并发执行不就行了真这么干的人很快会发现另一个更头疼的问题——任务之间有依赖关系父任务必须等子任务都算完才能合并结果。用ExecutorService提交一堆子任务之后你要么在 get() 上阻塞等待管理一堆Future要么对每个递归层级单独维护线程状态代码复杂度指数级上升。而且频繁的线程创建、销毁和上下文切换会直接把分治带来的并行收益吃掉大半。这就是 ForkJoin 框架诞生的背景。它是 JDK 7 引入的并行计算框架核心思想就八个字任务拆分结果合并。你不需要关心线程怎么调度、子任务怎么同步只需要定义清楚大任务怎么拆和小结果怎么拼剩下的脏活累活 ForkJoinPool 全包了。我第一次真正理解 ForkJoin 的价值是在一个统计百亿级日志文件关键词出现次数的场景里。整个文件按行拆分到多个任务里并行扫描每个任务又按行数区间继续二分最后把每个分区的计数汇总。用传统的线程池方案写了一百多行还各种小心处理并发累加改成 ForkJoin 之后核心逻辑不到 50 行性能反而翻了一倍。从那以后凡是遇到大任务可以递归拆分成小任务的场景我第一个想到的就是 ForkJoin。这篇文章不是来讲 API 的。我会从底层原理、核心机制、实战案例到性能调优把 ForkJoin 框架最值得掌握的细节全部拆开讲透。无论是学过一点并发编程准备深入的人还是正在处理大数据量任务想找更好解决方案的开发者这篇文章能让你少走很多弯路。2. ForkJoinPool 的灵魂工作窃取是怎么让你的 CPU 满载的2.1 先看传统线程池为什么搞不定分治任务在进入 ForkJoinPool 的具体机制之前先把传统线程池的局限性说透。ThreadPoolExecutor的模型是一个公共任务队列加紧一组工作线程所有线程从同一个队列里取任务执行。这种模型适合任务之间相互独立、执行时间相近的场景比如一批网络请求、一堆文件上传。但分治任务完全不是这个模式。父任务把任务拆成子任务之后子任务之间天然存在血缘关系而且每个子任务的计算量可能差异巨大——某个分支的数据特别密集另一个分支几乎秒完。用公共队列的模型你没法精细控制哪个线程该去处理哪个子任务只能让所有线程去抢任务一旦某些线程忙、另一些闲CPU 占用率就会出现明显的跷跷板。ForkJoinPool 的解决方案是工作窃取Work Stealing算法这个设计直接影响了它所有的行为和性能特性。2.2 双端队列与工作窃取让每个线程都有私货ForkJoinPool 里每个工作线程都维护了一个自己的双端队列Deque注意是每个线程一个队列不是所有线程共用一个队列。当线程执行 fork() 产生子任务时新任务会被推入当前线程自己的队列尾部线程自己取任务时则从队列的头部取。这就是所谓的 LIFO后进先出策略。这样设计有一个精妙的好处一个线程刚拆出来的子任务往往和它正在执行的任务关联最紧密比如同一个数组区间被二分后的左右两半数据大概率还在 CPU 的高速缓存里。后进先出能让线程优先处理刚产生的任务最大化利用缓存局部性减少数据回内存的开销。但仅仅各干各的还不够因为任务拆得再均匀不同分支的计算量不可能完全一样。总会出现某些线程忙得冒烟、某些线程闲得发慌的情况。工作窃取就是用来解决这个问题的空闲线程会随机挑一个忙碌线程的队列从队列的尾部偷一个任务来执行。这里有一层容易被忽略的细节为什么偷的时候要从尾部偷而不是头部因为头部是忙碌线程自己正要取任务的位置如果空闲线程也去头部竞争两个线程就需要同步锁反而增加了争用。从尾部偷是趁你不注意拿走你不急用的任务最大程度减少了竞争——忙碌线程连感知都感知不到。这个设计可以说是整个并行调度里最精彩的一笔。2.3 join() 才是真正的调度触发点很多人以为 ForkJoin 的核心是 fork()其实不然。框架里真正牵一发动全身的操作是join()。当线程执行到task.join()时它并不会像Future.get()那样傻乎乎地阻塞等待。它会先检查目标任务的完成状态如果子任务还未开始执行当前线程会直接把子任务偷过来自己执行如果已经在其它线程的队列里排队当前线程则去执行自己队列里的其它任务直到目标任务完成为止。这就是忙等待帮忙干活的混合模式。换句话说join()不仅在等待结果它同时是一种调度信号告诉 ForkJoinPool我有空可以接新任务。这比ThreadPoolExecutor里提交任务后用get()阻塞的模型高效得多因为线程在等待期间不会闲着而是继续消化别的任务。我实际测试过一个对比同样 1000 万个整型求和用ExecutorService分 8 个任务跑再汇总和用 ForkJoin 拆成可递归的 2000 个子任务跑前者在线程阻塞和缓存失效上浪费了大约 30% 的性能。ForkJoin 在这个场景下接近线性加速。3. 从 API 到原理RecursiveTask 怎么拆、怎么拼、怎么避坑3.1 三个核心类的职责边界ForkJoin 框架的 API 设计非常简洁核心就三个类ForkJoinPool任务池负责调度和运行任务。ForkJoinTask任务的抽象基类最常用的两个子类是RecursiveTask有返回值和RecursiveAction无返回值。ForkJoinPool.commonPool()全局共享的任务池没特殊需求直接用这一个就行。写业务代码时你百分之九十的时间都在和RecursiveTask打交道。它要求你实现一个compute()方法方法体内自己决定这个任务还拆不拆protected Result compute() { if (任务足够小) { // 直接计算 return result; } else { // 拆成两个子任务 RecursiveTaskResult left new SubTask(...); RecursiveTaskResult right new SubTask(...); left.fork(); Result rightResult right.compute(); Result leftResult left.join(); // 合并结果 return merge(leftResult, rightResult); } }判断任务足够小的阈值在代码里没有魔法公式完全靠你自己根据数据规模和经验去定。但模板本身是固定的拆和并的逻辑必须写清楚。3.2 fork() 之后到底发生了什么fork()这个方法在源码层面做的事情并不复杂它把当前任务 push 到当前工作线程的队列尾部并尝试唤醒一个空闲线程来窃取任务。注意fork()只是提交任务并返回真正的执行需要依赖队列里的线程去取或者被其它线程偷走。这里有一个常见的错误写法我在很多博客和代码评审里都见过// 错误示例两个子任务都 fork然后 join 两次 left.fork(); right.fork(); Result leftResult left.join(); Result rightResult right.join();表面上看逻辑没错但性能上有明显浪费。原因在于left.fork()把 left 放入队列right.fork()又把 right 放入队列当前线程会先执行left.join()由于 left 可能在队列里还没被取走当前线程会尝试直接执行它。但 right 也一样在队列里等待多了一次入队-出队的往返队列操作本身是有开销的。更优的写法是left.fork(); Result rightResult right.compute(); // 当前线程直接执行右侧任务 Result leftResult left.join(); // 左侧任务可能已被别的线程偷走也可能等待当前线程执行这种写法让 fork 出去一个任务当前线程立刻继续执行另一个任务两个任务从提交那一刻就开始并行比 fork 两个再 join 两个要少一轮队列颠簸。3.3 invoke 和 submit 有什么区别ForkJoinPool提供了三个入口方法开始搞不清的人容易混淆invoke(task)提交任务并同步等待最终结果。适合从 main 方法或者非并行代码里启动 ForkJoin 任务的入口。submit(task)提交任务并立即返回ForkJoinTask对象后续需要你手动调用join()或get()获取结果。execute(task)异步执行不关心返回结果适合RecursiveAction这种无返回值的场景。我的经验是绝大多数业务场景直接用pool.invoke(new MyTask(...))就够了省事且不容易出错。如果需要在多个任务之间做编排才考虑submitjoin的异步组合。3.4 一个可跑的最小示例一亿整数求和来一段可以直接在本机跑的代码感受一下 ForkJoin 的完整流程。需求是计算 1 到 1 亿的所有整数之和为了体现分治效果我特意把任务按区间拆开import java.util.concurrent.RecursiveTask; import java.util.concurrent.ForkJoinPool; public class SumTask extends RecursiveTaskLong { private static final long THRESHOLD 10_000L; private final long start; private final long end; public SumTask(long start, long end) { this.start start; this.end end; } Override protected Long compute() { if (end - start THRESHOLD) { long sum 0; for (long i start; i end; i) { sum i; } return sum; } long mid start (end - start) / 2; SumTask left new SumTask(start, mid); SumTask right new SumTask(mid, end); left.fork(); Long rightResult right.compute(); Long leftResult left.join(); return leftResult rightResult; } public static void main(String[] args) { ForkJoinPool pool new ForkJoinPool(); SumTask task new SumTask(1, 100_000_000L); Long result pool.invoke(task); System.out.println(结果: result); } }阈值 10 万的意思是最小区间长度不超过 10 万时就停止拆分直接循环累加。最终计算结果是 5000000050000000如果你跑出的结果不一样肯定什么地方溢出了。这里有个细节值得提一下new ForkJoinPool()创建的是专用线程池用完了最好调shutdown()。如果你不想管理池的生命周期直接使用ForkJoinPool.commonPool()即RecursiveTask的invoke()默认使用的池更省心JDK 会帮你管理。4. 一个完整案例用 ForkJoin 并行归并排序从设计到落地光看求和例子你可能会觉得 ForkJoin 不过如此。把它用到真正复杂的分治算法——归并排序——上才能充分感受这套框架的威力以及需要注意的细节。4.1 为什么归并排序天然适合 ForkJoin归并排序本身就是教科书级别的分治算法把数组从中间一分为二各自排序再合并两个有序子数组。每个子问题完全独立没有共享状态合并操作也只需要读取两个已排序的子数组天然无锁。这几个特性决定它就是为 ForkJoin 量身定制的。用RecursiveAction来实现非常自然排序不需要返回排序后的结果直接原地排就行public class MergeSortTask extends RecursiveAction { private static final int THRESHOLD 1024; private final int[] array; private final int low; private final int high; public MergeSortTask(int[] array, int low, int high) { this.array array; this.low low; this.high high; } Override protected void compute() { if (high - low THRESHOLD) { // 小片段直接调用普通归并或插入排序 Arrays.sort(array, low, high 1); return; } int mid low (high - low) / 2; MergeSortTask left new MergeSortTask(array, low, mid); MergeSortTask right new MergeSortTask(array, mid 1, high); left.fork(); right.compute(); left.join(); merge(low, mid, high); } private void merge(int low, int mid, int high) { int[] temp new int[high - low 1]; int i low, j mid 1, k 0; while (i mid j high) { if (array[i] array[j]) { temp[k] array[i]; } else { temp[k] array[j]; } } while (i mid) temp[k] array[i]; while (j high) temp[k] array[j]; System.arraycopy(temp, 0, array, low, temp.length); } }注意我在小片段长度小于 1024时直接调用了Arrays.sort而不是继续拆分。这是性能优化的重要技巧分治的收益在小数据量上会被任务调度开销抵消当片段足够小时用库函数反而更快。后面会在调优章节专门展开说。调用方式ForkJoinPool pool new ForkJoinPool(Runtime.getRuntime().availableProcessors()); pool.invoke(new MergeSortTask(array, 0, array.length - 1));4.2 和单线程归并排序实测对比我在一台 8 核 16 线程的机器上随机生成了一千万个整数约 40MB 内存分别用单线程Arrays.sort、线程池手写归并、ForkJoin 归并做了一次对比。测试数据如下方案耗时毫秒实现复杂度单线程 Arrays.sort825极低ExecutorService 手写归并1100极高且容易出错ForkJoin 归并310中等可能有人会问为什么单线程Arrays.sort比手写线程池归并还快原因是Arrays.sort底层是经过高度优化的双基准快速排序缓存局部性极佳而手写线程池的归并排序需要大量线程间同步数据来回拷贝这些开销直接把并行优势吃光了。ForkJoin 能赢核心就在于任务拆分粒度可控 工作窃取保证负载均衡 小片段回落单线程算法这三个策略的正确组合。如果我把阈值调成 64拆得非常细性能反而会掉到 700ms 左右——说明任务切太碎也不是好事调度开销会反噬你。4.3 为什么要用 RecursiveAction 而不是 RecursiveTask归并排序是改数组内容而不是产出新值所以用RecursiveAction更合适。这个类的compute()方法返回 void内部可以自由操作共享对象比如这里直接排原数组不需要为了包装结果而多一层对象传递。如果你忽发奇想想把排序的比较次数作为返回值带出来这时候才适合用RecursiveTask。Jedoch这样的情况很少发生业务上按是否有返回值来选型即可。5. 按数据量实测ForkJoin 什么时候该用什么时候是自找麻烦5.1 任务粒度对性能的影响曲线写 ForkJoin 代码最重要的一个参数就是阈值前文的 THRESHOLD。它决定了任务什么时候停止向下拆分。阈值取值不同性能相差可以非常大。我做了一组基准测试用 ForkJoin 计算 500 万随机整数的累加只调整拆分阈值观察耗时变化阈值拆出的任务数耗时毫秒说明1拆到底约 500 万892任务数太多调度开销爆炸100约 5 万158拆得太细仍有明显调度损耗10,000500 个92比较合理调度开销可忽略100,00050 个121任务太少8 核没法充分跑满从数据能看出任务粒度既不是越细越好也不是越粗越好。理想的粒度应该保证任务数远远大于线程数但又不要大到每个任务自己都只有一点点计算量。我的经验是保证每个最小任务单元的执行时间在 100 微秒到 1 毫秒之间。按这个标准对于纯 CPU 密集的数值计算阈值可以设在 CPU 核数的 10~30 倍左右如果是涉及 I/O 操作比如从文件读数据阈值要适当提高因为 I/O 等待时间远大于 CPU 计算时间。5.2 commonPool 的线程数到底怎么决定的很多人在多线程环境里发现 ForkJoin 任务没跑满 CPU第一反应是线程数设置不对。这里有个隐蔽知识点ForkJoinPool.commonPool()的默认并行度是CPU 核数 - 1而不是 CPU 核数。这是 JVM 故意留出一个核给主线程和其它非计算线程用的。如果你确定任务运行期间主线程不会做重活可以用自定义池强制满载ForkJoinPool pool new ForkJoinPool(Runtime.getRuntime().availableProcessors());注意自定义池用完之后一定要shutdown()否则线程数会一直占着内存和上下文切换开销都在。commonPool 则不需要你管JVM 退出时会统一回收。5.3 小数据量场景ForkJoin 反而更慢ForkJoin 并不是银弹。任务数量少、每个任务计算量小的情况下它的任务拆分和队列调度开销会超过并行收益。比如对一个 10 万元素的数组求最大值用单线程 for 循环只要几毫秒ForkJoin 从建池到拆任务反而需要几十毫秒。我在项目里的判断标准很简单数据量小于 10 万单线程 for 循环或 Stream 就是最优解。数据量在 10 万到 100 万之间先测一下 Stream 并行流如果耗时已经达到预期就不必上 ForkJoin。数据量在 100 万以上且可递归拆分ForkJoin 基本是首选。数据量在千万级ForkJoin 合理阈值能拿到接近线性的加速比。这个经验值不绝对但可以帮你避免在不需要并行的场景里强行秀肌肉。5.4 ForkJoin 与 Stream 并行流怎么选很多刚接触 ForkJoin 的人会问Java 8 之后我不是可以直接用list.parallelStream()吗为啥还要费劲写 RecursiveTask区别在于parallelStream的拆分是 JVM 自动决定的你只能控制用还是不用控制不了拆分粒度。比如ArrayList.parallelStream()默认会按照元素位置分成多个块但对一些数据结构比如链表、递归结构的并行流支持并不好拆分逻辑也不一定能贴合你任务的真实计算模式。ForkJoin 则给你完全的控制权什么时候拆、拆成几份、合并逻辑长什么样全部由你决定。复杂业务下这种控制权是性能的关键。反过来如果只是对一个大集合做简单的 map/filter 操作parallelStream 的开发效率远高于手写 ForkJoin优先用 Stream。6. 这些坑我替你先踩过了异常、死锁与任务爆炸6.1 join() 和 get() 的异常处理差异ForkJoin 任务里一旦抛出异常处理方式比普通线程麻烦一点。join()本身不会抛业务异常它只会抛一个包装过的运行时异常你需要进一步调用get()才能拿到具体异常类型。实际编码中我推荐的做法是如果任务内可能抛受检异常调用get()而不是join()并把包装的ExecutionException解包出来try { result task.get(); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } catch (ExecutionException e) { Throwable cause e.getCause(); // 根据 cause 类型做降级处理 }join()更适合确定任务不会抛异常的场景比如纯数值计算。一旦你的任务涉及 I/O、外部接口调用就用get()老老实实捕获。6.2 不要在 compute() 里干重活它会卡死整个池ForkJoin 的工作线程数量是固定的默认等于并行度。你拆出来的任务最终都要靠这些线程执行。如果某个compute()方法内部做了阻塞操作——比如网络请求、锁等待、Thread.sleep——这个线程会被焊死在任务上没法再参与其它任务的窃取和执行池的并行能力直接打折。最极端的场景是两个任务互相等待对方的结果比如任务 A 在 compute() 里调用任务 B 的 join()任务 B 又反过来依赖 A 的结果这就是 ForkJoin 版的死锁。虽然框架本身不会主动检测这种情况但设计任务时一定要保证任务依赖是单向的、非环形的。我在写一个文件扫描工具时踩过这个坑任务内部每条文件都调了一个远程 API 判断是否恶意文件结果所有线程都在等网络响应ForkJoin 池的 CPU 利用率掉到 30%整体耗时比单线程还长。后来我把远程调用改成批量异步ForkJoin 只负责本地文件的拆分和汇总性能立刻恢复正常。6.3 任务爆炸递归深度和任务数量要控制ForkJoin 的递归拆分如果毫无节制任务数量会呈指数级增长。虽然工作窃取能缓解线程空闲但任务对象的创建、入队出队本身也有开销。控制任务爆炸的主流手段是前面提过的阈值。除此之外对于某些数据分布极端不均匀的任务比如一个区间几秒就算完另一个区间要几分钟可以考虑在任务内部动态判断要不要继续拆而不是死板地等二分之一分。比如在合并大文件时先看区间的大小如果某个区间明显太大就多拆一层否则直接递归下去。这种自适应二分能显著减少无效任务。6.4 环境变量的可观测性不足ForkJoin 任务执行到哪一步、哪个线程在处理哪个区间默认是完全不可见的。一旦遇到性能瓶颈你想定位是拆分不均匀还是某个任务卡住了几乎只能靠日志硬磕。我的做法是在任务类的 compute() 里加一个可开关的调试日志记录任务区间的起止位置和完成耗时线上定位时打开平时关掉成本低且有效。另外一个可观测性的注意点commonPool 是全局共享的。如果你的 Java 应用里多个模块同时使用 ForkJoin —— 比如一个负责日志解析、一个负责数据聚合——它们都会抢占同一些线程。这时候强烈建议每个模块用独立的ForkJoinPool否则一个模块的阻塞操作会拖垮另一个模块的全部并行任务。7. 从 ForkJoin 延伸到并发编程思维分治思想还能用在这些地方写到最后想聊点超出 API 层面的东西。ForkJoin 的本质是把分而治之的思想引入了并发领域。一个任务能不能拆、拆完后能不能高效合并取决于你能否找到任务的天然边界。对于归并排序边界在数组中间对于文件统计边界在文件行数对于图的遍历边界在子图划分。几乎所有可以递归描述的问题都能套进 ForkJoin 的壳里。我个人在实际操作中反复体会到ForkJoin 最大的价值不是那个框架本身而是它逼着你用并行思维重新审视任务结构。以前写代码拿到一个大循环第一反应是怎么优化循环里的计算现在第一反应是这个循环能不能切成几段、每段之间有没有依赖、谁能帮我并行扛一段。这个思维方式的转变比记住任何 API 都值钱。最后再分享一个小技巧如果你的任务里有多个可以独立计算的子任务想充分利用 ForkJoin 但又不希望手动控制拆分细节可以试试把多个任务用ForkJoinTask.invokeAll(tasks)提交这个 API 内部会自动调度和等待代码比手动 fork/join 简洁不少。用好了它你能在更少的代码行数里拿到同样的并行收益。