☰
ForkJoinPool.commonPool()全解析:并行流与CompletableFuture背后的线程池隐患
2026/10/21 19:33:38 网站建设 项目流程

如果你在某个 Web 应用里做过这样的操作——用CompletableFuture并行发起几个 HTTP 调用,结果高峰期某些接口的耗时突然飙升,而 CPU 明明还有大量空闲——那么大概率你撞上了 Java 里那个最“公共”的线程池:ForkJoinPool.commonPool()。

很多同学在提到并发时能熟练说出Executors.newFixedThreadPool(),甚至能背出《阿里Java开发手册》里“不允许使用 Executors 创建线程池”的禁令,但对ForkJoinPool.commonPool()几乎是一无所知。它是 JDK 8 之后被默默渗透到所有角落的一个全局共享线程池:并行流用它,CompletableFuture用它,某些框架的异步回调也用它。它不收钱、不用关闭、不用管理,看起来就像一个“永不出错的公共厕所”,但恰恰因为它太公共了,一旦有人往里丢了一个不该丢的任务,全 JVM 的并行代码都会跟着遭殃。

这篇文章我会从源码讲起,结合真实的踩坑经历,把这个池子的脾气彻底说清楚:它为什么存在、它内部怎么运转、谁在偷偷用它、以及容器环境下它会给你挖哪些坑,最后再聊聊什么时候该自己造一个池子。每一条经验都是我实际动手验证过的,不是从文档里抄来的理论。

1. commonPool 的诞生逻辑:为什么 JDK 要给自己留一个“共享池子”

1.1 从并行流说起:没有公共池之前的世界

JDK 7 引入了ForkJoinPool,当时它只是一个需要你自己 new 的框架,主要服务于分治算法。Java 8 之后情况变了:lambda 表达式让并行运算的书写成本断崖式下降,Collection.parallelStream()成了路边摊级别的 API。如果每一次stream.parallel()都要求开发者手动创建一个ForkJoinPool,那绝大多数人会直接用串行流,因为“太麻烦了”。

但parallelStream()又不能没有池子。于是 JSR 166 专家组做了一个决定:给 JVM 提供一个全局共享的ForkJoinPool实例,所有需要“并行执行但没有明确指定执行器”的任务默认丢进这个池子。这个全局实例就是ForkJoinPool.commonPool()。

第一次调用它时,JVM 会执行一个延迟初始化逻辑,依据是宿主机的 CPU 核数,默认并行度等于Runtime.getRuntime().availableProcessors() - 1。为什么减一?因为调度者(也就是调用并行流的那个线程)本身也会参与任务执行。如果你机器有 8 核,减去 1 之后池子里有 7 个 worker 线程,加上调用线程一共 8 个,正好能把 CPU 吃满,同时又不会触发线程上下文切换的额外损耗。

1.2 池子的“公共”定位:和普通线程池的本质区别

一个普通线程池,比如Executors.newFixedThreadPool(10),你往里丢 1000 个任务,这些任务会被装进一个共享的 BlockingQueue,10 个线程排着队往里拿。这种模型的副产品是:任务间没什么联系,队头任务可能等了很久,队尾任务刚进来就能被空闲线程取走,未必公平,但胜在简单。

ForkJoinPool走的是另一条路:每个工作线程都有自己的工作队列,是一个双端队列(Deque)。线程自己生产的小任务压到队头,采用 LIFO 顺序执行;当自己的队列空了,它会去偷别的线程队列“队尾”的任务来干,这就是著名的 work-stealing 算法。这套设计的核心场景是“父任务拆分出子任务,子任务再拆分出孙子任务”的分治模型——每个线程在自己本地队列里消费自己刚产生的小任务,避免了全局锁的争用,偷窃操作也大多发生在空闲线程之间,冲突概率很低。

而commonPool就是这样一个池子。它不提供背压、不提供任务队列上限、不区分业务来源。任何代码路径,只要最终没有指定线程池,就可能往这个共享池里丢任务。

1.3 为什么有人不喜欢它:“不可控”是原罪

从业多年的工程师通常对commonPool有复杂情绪。喜欢它,是因为它让CompletableFuture.supplyAsync()这种写法开箱即用;讨厌它,是因为它无法隔离:A 模块丢进去一组任务,B 模块的并行流就得等着;C 模块犯了个错把一个 sleep(30秒) 丢进去,整个 JVM 的并行能力直接瘫痪。

这种“人人都往里扔垃圾”的特性源自一个设计取舍——JSR 166 的作者在注释里写得很明白:他们宁愿让这个池子默认适用于绝大多数场景,也不愿意提供一堆配置项让人们去调整。所以commonPool只能通过三个系统属性做微调,通常没人会碰它们。我们后面会细说这三个属性和它们的实际效果。

2. 源码切片:从入口方法到 work-stealing 的全链路

2.1 入口代码:静态内部类 Holder 与延迟初始化

先看ForkJoinPool.commonPool()的源码,JDK 8 到 JDK 17 的实现几乎没变过:

public static ForkJoinPool commonPool() { return common; } private static ForkJoinPool common;

common在类初始化的阶段就被赋值了,但它不是new ForkJoinPool()直接完成的,而是由一个内部静态类触发:

static final ForkJoinPool common = commonPool(); // 实际初始化逻辑在静态方法里

ForkJoinPool的类初始化阶段有一个非常长的makeCommonPool()静态方法,它会读取三个系统属性:

系统属性作用默认值
java.util.concurrent.ForkJoinPool.common.parallelism并行度CPU核数 - 1
java.util.concurrent.ForkJoinPool.common.threadFactory自定义线程工厂默认 InternalError 处理
java.util.concurrent.ForkJoinPool.common.exceptionHandler未捕获异常处理器默认日志输出

这三个属性里,parallelism用的最多,通常加上-Djava.util.concurrent.ForkJoinPool.common.parallelism=4就能把池子压到 4 个 worker 线程。但注意,这只是“建议值”,假如你的 JDK 在容器里错误地拿到了宿主机核数,这个值照样会变大,具体后面容器那节再展开。

2.2 asyncMode 与队列模式:FIFO 还是 LIFO 的区别

ForkJoinPool构造函数的第四个参数asyncMode在前几个 JDK 版本里被广泛讨论。它对任务执行顺序的影响是这样的:

  • asyncMode = true:每个线程的工作队列按 FIFO 顺序消费。适用于“事件型任务”或“异步任务”,意思是任务之间没有父子依赖,谁先来谁先执行。ForkJoinPool.commonPool()默认就是这个模式。
  • asyncMode = false:每个线程的队列按 LIFO 顺序消费,后进先出。这是分治算法的理想模式,因为最新拆出来的子任务往往更小,先执行可以减少任务积压。ForkJoinPool自己new出来如果不指定,默认是false。

这个差异很多人没注意到。如果你用new ForkJoinPool()去执行一个类似分治的任务,那是没问题的;但如果你用它去处理一批完全无关的小任务,每个任务都fork()出来,由于默认 LIFO,先fork的任务反而最后执行,这可能会让你的结果顺序完全颠倒。commonPool选 FIFO 是为了尽可能减少这种“意外”,毕竟它要服务于各种各样的调用方,谁也不想因为执行顺序不确定而踩坑。

2.3 一个任务从提交到执行的过程

假设你调用commonPool().execute(() -> doSomething()),底层会经过这样几步:

  1. ForkJoinPool.execute(ForkJoinTask<?>)或者execute(Runnable)会把任务包装成ForkJoinTask.RunnableExecuteAction。
  2. 它将任务提交到当前线程的 workQueue,如果提交线程不是池内 worker,就会被提交到一个“共用队列”(submission queue)。
  3. ForkJoinPool在初始化时已经安排好了 worker 线程,每个 worker 都是一个ForkJoinWorkerThread,它们内部有一个无限循环runWorker(),不断执行scan()方法寻找可执行任务。
  4. scan()会先检查自己的队列,如果为空,就按照随机起始索引扫描其他线程的队列(work-stealing);如果所有队列都空了,线程会进入阻塞等待(通过park或条件等待),但不会直接退出,因为它是守护线程,JVM 退出时不会被它拦住。

这段流程看起来流畅,但有一个隐藏问题:当一个任务在线程里执行时,如果它内部又join()等待另一个任务,ForkJoinPool的机制会尝试补偿(compensate):如果等待的线程很久没有结果,池子会临时新建一个 worker 线程来帮忙完成任务,这个临时线程叫“补偿线程”。补偿机制保障了分治任务不会因为等待而卡死,但它也让“池内线程数”变得不可预估,极端情况下池子可能会“限时”创建很多临时线程。这也是为什么不要往commonPool里丢阻塞任务的原因之一——补偿机制无法解决一个纯阻塞任务,它只会不断创建新线程来填坑,最终把系统拖垮。

3. 谁在偷偷用 commonPool:表面上你只调了一个 API,实际上你在排队

3.1 并行流:Stream.parallel() 的默认归属

这是最广为人知的依赖路径。执行以下代码,所有计算都会发生在ForkJoinPool.commonPool()的 worker 线程上:

long sum = IntStream.range(1, 1_000_000) .parallel() .sum();

在这个例子里,主线程会把流拆分成若干子任务,提交到 commonPool,然后自己也参与执行。另一个潜在问题是:如果你在 commonPool 的 worker 线程里再次调用.parallel(),会造成 work-stealing 的嵌套,但是一般不会出大问题。

问题出在并行流的拆块逻辑上:它默认按数据量拆,数据量小的时候并行度发挥不出来;数据量大的时候,一个任务拆成很多小任务后,ForkJoinPool 的窃取队列会产生大量临时对象,GC 压力增加。所以并行流适合“每个元素计算时间较为均匀、计算量本身不小”的场景。如果你的元素执行时间差异巨大,比如有的元素 1 毫秒算完,有的要 5 秒,那并行流就成了木桶效应的放大器,commonPool 里所有线程都会被那个 5 秒的元素拖住。

3.2 CompletableFuture 的默认执行器

再看CompletableFuture。JDK 8 的CompletableFuture.supplyAsync(supplier)如果没有传第二个 Executor 参数,默认用的就是ForkJoinPool.commonPool()。这个决策在当初就有争议,JDK 官方文档也不断提醒:如果你的任务会阻塞,请务必指定自定义执行器。

但现实是,大量同学写异步代码时根本不带第二个参数:

CompletableFuture<Integer> f1 = CompletableFuture.supplyAsync(() -> callHttp()); CompletableFuture<Integer> f2 = CompletableFuture.supplyAsync(() -> callHttp());

两个 HTTP 调用同时发出去,每次调用耗时 500ms,看起来并行度很完美。然而服务器一忙,HTTP 客户端内部线程池耗尽开始重试,single 业务线程就会阻塞在 commonPool 里。因为你没指定 Executor,BC 服务里的七八个异步接口全挤在同一个公共池子里,谁也没法独善其身。

我在某个支付回调项目里就见过一次“雪崩”:一组内部服务调用本来走的是ThreadPoolTaskExecutor,后来有一次版本升级,某位同学把supplyAsync的第二个参数漏掉了,系统切换到了 commonPool,初期并发量小没感觉,双十一流量一上来,公共池的线程全部阻塞在外部接口等待上,导致同一 JVM 里所有并行流查询、其他异步任务全部排队,最后整链路超时。这个问题的根因不是CompletableFuture本身,而是“异步任务没有明确指定执行器”。

3.3 JDK 框架层的“便车”:从 Timer 到 NIO 回调

除了并行流和CompletableFuture,还有一些 JDK 内部组件也依赖 commonPool,只是不太显眼。比如:

  • Arrays.parallelSort()的分治排序,默认使用 commonPool;
  • ThreadLocalRandom某些路径下触发ForkJoinPool相关随机数生成;
  • 一些基于 NIO 的异步事件框架在回调里执行计算任务时,如果不显式指定线程池,也可能把计算任务抛给 commonPool;
  • 某些 ORM 或缓存框架的异步批量加载实现,默认也会使用 commonPool。

最典型的间接依赖例子是:你在 Netty 的 eventLoop 回调里调用了list.parallelStream().forEach(...),这会让代价高昂的 parallelStream 直接跑在 Netty 的 IO 线程上,同时又把任务塞给了 commonPool。表面看起来资源好像没冲突,实际是两个线程池都受到了影响——eventLoop 线程被并行流的阻塞尾调用拖住,commonPool 的 worker 又被你丢进去的 CPU 密集任务占满。遇到这种情况,最好显式切池。

4. 容器时代最典型的坑:CPU 配额、线程饥饿与幽灵任务

4.1 最大陷阱:availableProcessors 在容器里并不知道上限

JDK 8 早年版本在 Docker 容器里有一个众所周知的行为——Runtime.getRuntime().availableProcessors()返回的是宿主机的 CPU 核数,而不是容器被分配的 CPU 配额。这会导致一个问题:宿主机明明只有 32 核,容器限了 4 核,但 JVM 觉得有 32 个处理器,于是 commonPool 的默认并行度变为 31。31 个 worker 线程在 4 个 CPU 上疯狂争抢,线程切换开销远大于并行收益,反而比串行还慢。

后来的 JDK 版本引入了容器感知机制,大多数云环境下的availableProcessors()能返回正确的配额核数。但这里还有一个更深层的坑:availableProcessors()返回的是“CPU 时间片资源”不是“可用的并发线程数”。当容器配置里 CPU 上限是 2000m(相当于两个完整核心)时,availableProcessors()可能返回 2,于是 commonPool 并行度是 1,并行流退化成接近串行执行。很多同学看到“并行流怎么这么慢”时去查parallelism,发现只有 1 或者很小,其实问题不在代码,而在容器 CPU 配额。

处理方式很简单:如果你确认自己的服务适合高并行度,用-Djava.util.concurrent.ForkJoinPool.common.parallelism=8强制覆盖;如果你不确定,先打一张System.out.println(ForkJoinPool.getCommonPoolParallelism())的表看看实际值。

4.2 阻塞任务的“幽灵效应”:你杀不完的排队

另一个容器时代的经典问题是线程饥饿。commonPool 的 worker 是守护线程,不会因为 JVM 主线程结束而阻塞 JVM 退出。但运行中的业务逻辑如果阻塞在里面,外部看进程还活着,实际上线程全挂在老地方。

举个例子,有一个任务用RestTemplate请求外部接口,默认 5 秒超时,不指定 Executor。并发 100 请求下去,commonPool 只有 7 个线程(8核-1),7 个 worker 全被网络阻塞占满,第 8 个请求开始就只能排队。这时你会发现 CPU 利用率只有 10% 甚至更低,但吞吐量骤降为零。jstack打印后看到一堆ForkJoinPool.commonPool-worker-*线程全部WAITING或RUNNABLE卡在 socketRead。

这种问题永远不会触发RejectedExecutionException,因为 ForkJoinPool 的队列没有上限,它只会无限排队。所以很多排查工具(比如线程池监控)根本感知不到异常——你看到的是“一切正常,只是慢”。

我的经验:如果业务里有明显的阻塞操作,比如 HTTP 调用、等待锁、Thread.sleep、数据库同步查询,永远不要把它们丢给 commonPool。要么自己建一个线程池,要么至少给CompletableFuture指定一个带名字、带拒绝策略的ThreadPoolExecutor。

4.3 隔离远比省心重要:哪些任务真的适合 commonPool

那 commonPool 用来做什么合适?我的判断标准是:它只适合消耗 CPU 且没有阻塞的并行分治计算任务。

可以交给它的任务画像大致是这样:

  • 纯内存计算,比如大量数据的过滤、映射、归约;
  • 算法类任务,比如排序、矩阵运算、爬取结果合并;
  • 一次性的异步发散,比如启动后需要并行初始化十几个缓存模块,每个模块最多 200ms 而且不需要互相等待;
  • 任务量小、执行时间短的批量校验。

这里特别提醒:不要因为某个任务“看起来很快”就放松警惕。有一次我把一个本来应该 50ms 完成的任务丢进 commonPool,结果生产环境那个任务偶尔要等一个 1 秒全局锁,最终导致所有并行流集体变慢。任务执行时间的波动是不可控的,只要可能变慢,就必须隔离。

5. 什么时候该自己造一个池子:自定义 ForkJoinPool 的取舍与实践

5.1 自定义 ForkJoinPool 的参数选择

如果你决定走自定义路线,直接new ForkJoinPool()之前,先把这几个参数想明白:

  • parallelism:通常设置为 CPU 核数减一或者等于核数。对于纯 CPU 密集计算任务,设置为核数减 1 是最合适的,因为调用线程还会参与执行;对于混合型任务,宁可设置偏小也别偏大,偏大只会增加线程切换。
  • threadFactory:务必给线程起一个能识别的名字,比如custom-forkjoin-pool-worker-1。生产事故排查时,jstack输出里能看到名字,比纠结半天是谁创建的这个线程有效得多。
  • exceptionHandler:自定义一个UncaughtExceptionHandler,把异常打日志打出来,比默认的只输出到控制台可靠。
  • asyncMode:如果是分治任务,用false(默认);如果是普通异步小任务,用true会更公平。

代码示例:

ForkJoinPool customPool = new ForkJoinPool( Runtime.getRuntime().availableProcessors() - 1, new ForkJoinPool.ForkJoinWorkerThreadFactory() { @Override public ForkJoinWorkerThread newThread(ForkJoinPool pool) { final ForkJoinWorkerThread worker = new ForkJoinWorkerThread(pool) {}; worker.setName("biz-forkjoin-worker-" + worker.getPoolIndex()); worker.setDaemon(false); return worker; } }, (thread, throwable) -> log.error("Worker thread {} threw exception", thread.getName(), throwable), false );

注意setDaemon(false)这行,如果设为非守护线程,应用关闭时 JVM 会等待任务完成才退出,这在服务场景是合理的。但如果你使用的地方是自助脚本,建议还是守护线程,免得一个死循环任务卡住整个进程退不出去。

5.2 CompletableFuture 用自定义池的正确姿势

CompletableFuture的第一个参数重载:

Executor executor = ...; // 自定义 pool CompletableFuture.supplyAsync(() -> doSomething(), executor) .thenApplyAsync(result -> process(result), executor) .thenAcceptAsync(System.out::println, executor);

如果整条链路都用同一个 executor,可以简写为先把任务提交到池子里,再用join()串起来:

ForkJoinPool customPool = new ForkJoinPool(4); customPool.submit(() -> { CompletableFuture<Integer> f1 = CompletableFuture.supplyAsync(...); CompletableFuture<Integer> f2 = CompletableFuture.supplyAsync(...); // 后续所有 thenXX 都运行在 customPool 内,因为当前线程就是 pool worker return f1.thenCombine(f2, Integer::sum).join(); });

这种写法的好处在于,整条异步链都是在同一个池子里执行的,不会因为遗漏executor参数而偷偷回到 commonPool。但注意:只要thenApply之类的依赖里你调用了supplyAsync而没有传 executor,它依然会跳回 commonPool。所以我的习惯是:凡是自定义池,就自定义到底,每个async变体都显式传参,不偷懒。

5.3 虚拟线程时代还需要 commonPool 吗

JDK 21 引入虚拟线程后,很多同学问我是不是可以全面抛弃 ForkJoinPool 了。我个人的意见是分场景。

虚拟线程最适合的是“高并发、高阻塞、低计算”的任务:每个请求一个虚拟线程,遇到 IO 阻塞时自动让出 JVM 的 carrier 线程,可以同时开几万个虚拟线程等响应。

而 ForkJoinPool(包括 commonPool)的核心优势是“线程与计算任务亲密配合”——它的 work-stealing 机制在处理 CPU 分治任务时非常高效,这是虚拟线程没法替代的。虚拟线程不会帮你把一个大型数组排序拆成多个小任务并行计算。所以我的建议是:

  • CPU 密集并行计算:用 ForkJoinPool(commonPool 或自建池都行);
  • IO 密集高并发:用虚拟线程,不要用 commonPool;
  • 混合负载:优先虚拟线程处理 IO,计算结果合并时再用 parallelStream 或者自定义 ForkJoinPool。

最后再分享一点排查工具层面的小技巧。当你怀疑自己的服务被 commonPool 拖累了,别急着改代码,先做三件事:

  1. jstack <pid>抓线程栈,搜ForkJoinPool.commonPool-worker,看它们到底卡在什么调用上;
  2. jstat -gcutil <pid> 1000观察 GC 是否因为任务堆积而异常;
  3. 打一个日志,打印ForkJoinPool.getCommonPoolParallelism()和Thread.activeCount()。

我曾经排查过一个诡异问题:所有并行流都极慢,但池子并行度正常,线程也没卡。最后抓jstack发现,commonPool 的 worker 全被一个第三方 SDK 的parallelStream内部任务占用了。那个 SDK 在初始化时用并行流加载了一大堆网络配置,每个配置都有 300ms 的 RPC 超时。你没法控制别人的代码,所以最稳妥的方案仍是在自己在项目里把并行流和CompletableFuture的默认执行器都显式指定一遍。只要有一次“默认”被踩中,你花在排查上的时间远超提前指定线程池的那几行代码。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询