
1. 先搞清楚Fork/Join到底在解决什么问题1.1 分治思想从“一个人干不完”说起Fork/Join这个名字我最早在Java 7的并发工具包里见到时第一反应是“名字起得真直白”——Fork就是分叉Join就是合并。说白了它就是两大思想组成的框架分治算法加工作窃取调度。分治这块你小学就学过把一个大问题拆成若干个小问题小问题解决了大问题也就解决了。比如你要算一万个数的总和最直觉的方式是写个for循环从头加到尾但如果有四个线程完全可以切成四段每段两千五各算各的最后再把四段结果加起来。但分治思想本身并不新鲜几十年前就有了经典归并排序就是这种思路。Fork/Join真正值得聊的是它怎么处理“拆了任务之后谁来执行、执行完怎么拼回来”的编排问题。Java里实现一个分治模型用普通的ExecutorService加Future也能做每次递归拆分任务用线程池submit再get结果。但你会发现坑很多任务拆得多了每个Future都要占内存线程切换频繁而且大任务依赖小任务的结果容易形成层层阻塞跑起来又慢又耗资源。Fork/Join之所以能成为专门框架核心在于两件事它用双端队列管理每个线程的任务允许线程从别人的队列尾部偷任务来干也就是所谓的工作窃取Work-Stealing它提供了完整的任务状态管理机制任务被Fork出去之后随时可以Join回来状态通知、异常传播都不需要你操心。这套组合拳恰恰是并发分治场景里最容易踩坑的地方Fork/Join直接用框架帮你挡住了。1.2 工作窃取算法为什么它是Fork/Join的灵魂我一直觉得工作窃取是Fork/Join的精髓。它解决的是一个在并行分治场景里非常实际的问题任务拆得不均匀怎么办比如某个递归分支跑得快某个分支跑得慢或者某个任务本身比较大拆开之后两边规模差很多。如果固定把任务分给几个线程执行快的线程只能闲着等慢的并行度空有一半性能还是上不去。工作窃取的思路是**给每个工作线程维护一个双端队列自己负责从队头取任务执行别人执行完自己队列里的活之后没活干就去别的线程的队列尾部偷一个任务来跑。**这样空闲线程不会干等负载天然就均衡了。这就好比一条流水线四条工位左边工位干完自己的活儿了不等着下班而是从右边工位堆叠的半成品里拿点来一起做整体效率一下就拉上去了。而且从队尾偷、从队头取两个线程操作同一队列的两端冲突概率极低大部分情况下连锁都不用碰靠CAS就够了。关键点在于“偷小不偷大”。Fork/Join里任务拆成Fork之后子任务是在不断递归产生小任务的。一个线程把自己的子任务Fork出去后它会先处理自己队头东西当它发现别的队列有活时优先偷尾部——因为尾部的任务往往是最近Fork出来的、粒度更小的任务拿最小的任务去偷能尽量保证偷过来的任务快速完成减少偷取过程中的协作成本。这部分原理在各路资料里写得很绕我自己的经验是理解到“双端队列队头执行队尾偷取任务粒度递进”这个level就够了再往深里钻设计框架的人其实是为了把无谓竞争压到最低。2. Fork/Join的核心类与参数选择2.1 四个关键类ForkJoinPool、ForkJoinTask、RecursiveTask、RecursiveAction刚上手Fork/Join的人容易被一堆类名绕晕。抽出主线来看它其实只有四个角色需要你掌握ForkJoinPool线程池的总调度容器维护工作线程、任务队列、公共池引用。它跟普通线程池最大的区别在于支持工作窃取并且每个线程都自带双端队列。ForkJoinTask所有Fork/Join任务的抽象基类核心方法就是fork()和join()以及更高级的invokeAll()。RecursiveTask继承自ForkJoinTask代表一个有返回结果的递归任务适合求和、最值、排序这类需要返回值的问题。RecursiveAction也是ForkJoinTask的子类代表无返回值的递归任务适合遍历目录、批量处理数据、数组赋值这类只关注副作用side effect的场景。另外还有个细节挺容易忽视ForkJoinTask实现了Future接口所以你在任务里调用get()也能拿到结果。但它跟普通Future有个关键区别——它的join()方法在等待任务完成时不会像Future那样傻傻阻塞一个线程等另一个任务完成而是会在当前线程里直接执行被等待的任务。这个设计对等待型任务非常友好后面排查“线程卡死”那块我们会再来回看这一点。invokeAll()是另一个容易被低估的方法。它接受两个(left, right)子任务内部做了两个动作先fork右边的任务再在当前线程直接在本地执行左边的任务。这样一来右边的任务会由别的线程偷走或者排队等待执行但左边任务不用额外占用一个队列槽位。如果任务拆分得粒度很细用invokeAll()比手动fork()再join()要高效得多。2.2 参数怎么定线程数、拆分阈值、队列容量用Fork/Join十个有八个会栽在参数设置上。参数就三个但每一个都直接影响性能线程数、拆分阈值、队列容量严格来说没有配置式设置由池自己管理但任务粒度会间接决定队列长度。线程数怎么选ForkJoinPool默认用Runtime.getRuntime().availableProcessors()作为并行度。对于纯CPU密集型任务并行度CPU核数是最稳妥的起点。如果任务里偶尔有IO可以适度调大比如核数×2。不建议图省事直接调成几十上百因为Fork/Join工作线程一旦创建它是常驻的不干活也在占着资源。阈值怎么定这是Fork/Join最容易犯糊涂的地方。阈值太小任务拆得太多每个任务对象本身的开销、线程调度开销可能比计算本身还贵阈值太大任务根本拆不动整个框架退化成单线程递归。我的经验是先用一个不会让任务拆分超过两层或三层的初始值跑一遍再逐步调小观察曲线最低点。如果任务处理的是数组20万以下基本可以让线程跑框架里经典示范是求和的阈值设在1万以内单个元素级别的任务也可以但那个属于极端场景非必要不建议。队列长度其实你很难直接设置它是动态扩容的。但你可以通过控制“同时Fork出去的子任务数量”来间接影响队列积压。我的习惯是每次递归只fork一个子任务另一个用本地线程执行这样每个工作线程的队列积压量始终受控不会出现几千个任务对象挤在一个队列里的恐怖状况。这个习惯在没有invokeAll()的年代被很多人当作“Fork/Join的黄金准则”到现在依然适用。我要再次提个醒**并行度不是越大越好任务切分次数也绝不是越多越好。**我曾经在某台8核机器上跑数组求和默认并行度是8阈值从10万调到1万再从1万调到1000数据反而是阈值在1万时最快。原因是任务对象太多导致GC压力暴增工作线程之间抢任务队列尾部的CAS冲突也多了开销超过了并行带来的收益。这种“并行反而不如串行”的怪现象具体分析放第4章来说。3. 实战用Fork/Join实现百万级数组求和3.1 目标定义与实现思路讲完原理接下去进入实战环节。我挑了一个最经典的题目对一个长度为百万级的long数组求和。为什么选这道题因为它逻辑简单核心就一个“累加”所有性能差异都直接反映在“任务拆分调度合并”这套机制上非常适合观察Fork/Join框架自身的成本。选哪件事用Fork/Join做有几条判断标准问题能不能递归切分切出来的子问题是否相互独立子问题的结果能不能合并符合这三条的典型任务有很多——求和、找最大值、归并排序、大数组统计类运算、目录文件遍历汇总。反过来如果任务本身就不能拆或者子任务之间有大量共享状态和锁操作那Fork/Join不仅帮不上忙还会拖垮你。实战环境就是一台普通的8核机器16G内存Linux环境。为了更贴近真实开发我没用公共池公共池的并行度是CPU核数而是用自定义的ForkJoinPool这样容易控制并行度做对比。3.2 核心代码SumTask实现定义一个SumTask继承RecursiveTaskLong核心就是compute()方法。逻辑分三步先判断任务是否已经小到可以不拆如果能拆就把数组一分为二fork出子任务等两边结果之后相加如果足够小就老老实实算。这个“拆到多大就不拆了”的临界值就是传说中的阈值THRESHOLD。import java.util.concurrent.ForkJoinPool; import java.util.concurrent.ForkJoinTask; import java.util.concurrent.RecursiveTask; import java.util.concurrent.ThreadLocalRandom; public class ForkJoinSumDemo { static class SumTask extends RecursiveTaskLong { private static final int THRESHOLD 100_000; private final long[] array; private final int start; private final int end; SumTask(long[] array, int start, int end) { this.array array; this.start start; this.end end; } Override protected Long compute() { int length end - start; if (length THRESHOLD) { long sum 0; for (int i start; i end; i) { sum array[i]; } return sum; } int mid start (end - start) / 2; SumTask left new SumTask(array, start, mid); SumTask right new SumTask(array, mid, end); left.fork(); long rightResult right.compute(); long leftResult left.join(); return leftResult rightResult; } } public static void main(String[] args) { int size 1_000_000; long[] arr new long[size]; for (int i 0; i size; i) { arr[i] ThreadLocalRandom.current().nextLong(1, 100); } // 单线程直接求和 long start System.nanoTime(); long serialSum 0; for (long v : arr) { serialSum v; } long serialCost System.nanoTime() - start; // 默认并行度 ForkJoinPool pool new ForkJoinPool(); start System.nanoTime(); long parallelSum pool.invoke(new SumTask(arr, 0, arr.length)); long parallelCost System.nanoTime() - start; pool.shutdown(); System.out.println(serialSum serialSum); System.out.println(parallelSum parallelSum); System.out.printf(serial cost : %.2f ms%n, serialCost / 1_000_000.0); System.out.printf(parallel cost : %.2f ms%n, parallelCost / 1_000_000.0); } }注意上面用了个小技巧拆任务时只fork一边另一边在当前线程执行。这样设计的好处我在前面提过——避免当前线程傻等同时减少队列槽位被无关子任务占满的可能。你会在很多标准实现里看到写成SumTask left new SumTask(...); SumTask right new SumTask(...); invokeAll(left, right);这也可以。两种写法其实是同一个策略的不同表达。invokeAll(left, right)内部做的事就是先fork一个再在当前线程执行另一个手动写出来更好展示背后逻辑理解框架的人看了也不会觉得突兀。3.3 性能对比与数据分析我实测几次取中间值结果大致是这样数组长度100万数值范围1-99执行方式耗时约说明单线程for循环2.1 ms基准值最朴素方式Fork/Join8并行度阈值10万1.4 ms约1.5倍提升符合预期Fork/Join8并行度阈值1万1.2 ms拆得更细提升略多Fork/Join8并行度阈值1002.8 ms拆得太细性能反降Fork/Join并行度2阈值10万3.0 ms并行度不足接近串行有几个值得聊的点。差距为什么只有1.5倍而不是8倍这很正常。求和这种任务是典型的高带宽内存密集型任务单线程for循环已经能非常有效地把CPU缓存和内存带宽吃满了。把任务拆到多个线程并行其实是在跟同一个内存带宽抢资源收益自然有限。换成纯CPU计算任务比如斐波那契、加密运算、复杂数学公式并行度高带来的加速比会好看得多。这告诉我们不是所有任务都适合Fork/Join计算密度越高的任务越能体现出框架价值。阈值的影响为什么会呈“倒U型”曲线波动来自三个开销源的此消彼长任务对象创建开销、线程调度与队列竞争开销、任务合并开销。阈值太大拆出来的任务少并行度利用不充分阈值太小任务对象太多GC和CAS都成了瓶颈。后期我做对比时发现当阈值等于1万时百万数组只拆了大概7层就到叶子了任务是128个符合8核并行能力阈值调到100时任务直接上万GC暂停肉眼可感。尽量让任务数量保持在并行线程数的整数倍附近是比较稳妥的选择。3.4 不只是写代码Fork/Join在JDK里的“隐藏应用”如果你觉得Fork/Join只能用来写求和这类私有工具那就低估它了。你在日常开发里很可能已经间接用过它只是完全没意识到。**Arrays.parallelSort()底层就用了一个叫ForkJoinPool.commonPool()的公共池来并行做排序。Stream.parallel()的默认执行线程池也是这个公共池只不过严格来说它是Java流的并行实现底层主要靠Spliterator拆分任务最终会借助Fork/Join来完成子任务的执行。CompletableFuture**里的异步链路也有Fork/Join的影子。你可以做一个实验用Stream的parallel()对一个百万数组求和再看任务列表里线程名你会发现线程前缀是ForkJoinPool.commonPool-worker-x用的就是公共池。这验证了一件事——标准库并没有给Stream另起炉灶而是复用了Fork/Join的调度体系。这也从侧面说明Fork/Join不是小众玩具而是Java并发工具箱里被官方重用的大件。4. 常见问题与排查技巧实录4.1 性能不升反降问题出在哪Fork/Join写着简单但跑起来“变慢”是新手最常见的事故。我把这种问题总结成三句话第一任务拆得不够粗。这是默认阈值过高导致的。如果你把百万数组求和阈值设成50万那任务只被拆一次一半任务在一个线程上跑最终性能约等于单线程看不出任何加速。第二任务拆得太细GC压力爆炸。当一个任务拆出来的对象数以万计时JVM开始频繁Minor GCGC本身要暂停所有工作线程你并行计算的收益全被暂停耗掉了。这时候性能曲线会掉头向下比你不用Fork/Join还慢。唯一的解法就是调大阈值让任务数回归到可控范围。第三并行度设置不匹配。给你CPU核数为8的机器设并行度为16线程多了切换开销反而上升设成2并行度不够空闲核在叹气。想要精确匹配可以先用Runtime.getRuntime().availableProcessors()获取物理核数再根据任务CPU/IO比例调整。排查这类性能问题时我的顺序是先看阈值然后看并行度再看任务拆分是否合理最后用jvisualvm看GC曲线。大多数“我Fork/Join好慢”的问题最终都会落到“我阈值设置不合理”这五个字上。4.2 任务不执行或线程全部阻塞这个现象很经典程序启动后某些任务一直不跑有的任务卡在join上出不来。通常原因有两个。原因一任务没有使用fork()而是直接互相join()。当线程A执行任务甲甲需要等子任务乙的结果但你只是rightResult right.get()没有先fork——这等于让当前线程空等一个从未提交执行的任务死锁就这样产生了。正确做法是fork之后调用join()或者用invokeAll()让当前线程在join时能反过来执行被等待任务打破相互等待的循环。原因二在自定义的ForkJoinPool里写了阻塞调用把池里所有工作线程都占满了。比如你池里只有8个线程但有16个任务在等待某个外部资源数据库连接、Redis、锁8个工作线程全部处于阻塞状态剩下8个任务永远得不到执行机会。这个问题凡是线程池都会遇到但Fork/Join尤其容易忽视因为它的工作线程数量通常比较固定。我自己的习惯是如果任务里存在不可控的IO阻塞就不要用Fork/Join至少不要用并行度等于CPU核数的默认池。解决这类问题可以先用jstack看线程卡在哪如果看到一片ForkJoinPool管理线程处于WAITING状态基本就能锁定问题类型了。4.3 工作窃取引发的CPU飙高还有一类诡异问题任务全部完成了join()都返回了但CPU占用还是居高不下。有人怀疑是死循环但线程栈里看不到业务线程全是ForkJoinPool的工作线程。这大概率是碰到了公共池线程饥饿的变体。工作线程在处理完队列任务之后会进入awaitWork()阻塞等待新任务唤醒。正常情况下它应该挂起但如果池里设置了commonMaxSpares公共池备用线程上限或合并了大量长尾任务它的唤醒机制可能会让部分线程频繁空转CPU曲线就被“假活”拉高了。排查方法很简单用jstack看那些ForkJoinPool线程如果大量处于RUNNABLE状态且堆栈都指向同一个scan()方法那就是它在空转等待池里塞入新任务。真遇到这种情况不需要太慌张。大多数场景下把自定义池的awaitQuiescence()或shutdown()好好调一调就行如果没有特殊需求直接使用默认公共池让JDK自行管理线程生命周期反而更省心。4.4 避坑清单几个值得牢记的使用习惯总结一下我踩过坑之后沉淀下来的使用原则也算是一份速查表检查项推荐做法反面典型阈值先从大往小调观察性能拐点一开始就设为1任务对象爆炸并行度CPU密集型设为核数IO型调大1-2倍盲目设置64、128任务拆分优先invokeAll或fork一半、本地执行一半把所有子任务都fork出去阻塞操作避免在Fork/Join任务中做外部IO在任务里查数据库异常处理join()可能抛出CancellationException要捕获处理以为get()一定成功公共池直接使用ForkJoinPool.commonPool()就行它是全局的每个模块都new一个池子还有个小技巧Fork/Join的invoke()返回的是第一个被提交的任务结果但如果你调动的是多个独立任务最好还是拆分之后各自维护Future或者用一个汇总任务包住所有子任务免得把不同阶段的逻辑混在一棵树上。另外说个很实际的体会——Fork/Join的调试比普通多线程容易一截因为任务结构天生是树任务栈的父子关系可以画出来。我过去排查一个复杂并行查询问题时就是用日志把每个子任务的fork/join时间打印出来根据时间线树状结构一眼就看出了瓶颈在哪个子分支上。如果你要把这份经验带进团队协作建议把任务拆分时的start和end也打出来调试时能把问题定位到具体的数组片段节省大量翻日志的时间。5. 从实战到进阶这几类任务建议用Fork/Join重写5.1 数据统计与聚合类任务最典型的就是批量日志统计、BI报表的DataFrame聚合、多维指标累加。这类任务规模大、数值计算多、各维度之间天然没有依赖非常适合用Fork/Join切分到几十个子任务并列计算最后再做汇总。我曾经把一个单线程跑3秒的统计逻辑用Fork/Join拆到8线程后压到800毫秒以内效果非常直观。5.2 递归遍历与目录解析文件目录遍历是另一个高频场景统计某个大目录下所有文件夹大小、搜索特定类型文件数量。每个子目录天然就是一个子任务不需要额外设计拆分逻辑RecursiveAction正好派上用场。不过要注意目录遍历里别在任务里直接做打印输出会频繁抢锁打印独占锁的大坑我踩过一次。5.3 分治算法重写归并排序与快速排序归并排序和Fork/Join是天生一对。单线程归并排序对十万级数据已经很快但百万级以上时并行归并的优势会逐渐体现。代码结构本质上就是invokeAll(left, right)然后等待两边排序完成后合并。快速排序也类似每次选定基准把数据一分为二左右分区互不干扰并行化以后的分区操作可以让大数组排序吞吐量提升一截。但这里要给你泼盆冷水别为了一道排序题把整个系统重构成Fork/Join。JDK已经提供了Arrays.parallelSort()底层就是Fork/Join只要你确认自己的数据结构和排序需求满足它的前置条件直接用就好。自己手写并行排序多数情况下只适合教学学习或特种算法定制。6. 写在最后我这个老用户的一点心得Fork/Join框架上手不难难在能不能正确衡量“该不该用它”。我自己用过很多轮之后最大的感受是它解决的是结构化分治任务的并行问题不是所有多线程任务的银弹。你一定要先判断自己的任务是否符合“可递归拆、子任务独立、结果可合并”这三个特征再决定入手。参数调优不要靠猜靠“分而治之”的办法固定其他变量只动阈值跑一组对比再固定阈值只动并行度跑一组对比。每次记录热度和耗时曲线自然告诉你最佳工作点在哪。学习阶段多写几个小Demo比阅读十篇原理文章有用得多像合计求和、数组最大值、递归目录统计每一个都能帮你把fork、join、invokeAll、工作窃取之间的关系揉进手感里。最后再分享一个小技巧写给团队用的通用并行工具时务必把阈值和并行度做成可配置参数而不是常量写死在类里。这样换机器、换数据规模时不用改代码重新编译直接通过配置调整就能快速适配。这个习惯让我在跨平台部署时少走了很多弯路真心建议所有用Fork/Join做组件化开发的人都保留。