资讯动态

ForkJoinPool.commonPool()全解析:并行流与CompletableFuture背后的线程池隐患

发布时间:2026/10/21 19:33:44 来源:尧图企业网站定制
如果你在某个 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 个任务这些任务会被装进一个共享的 BlockingQueue10 个线程排着队往里拿。这种模型的副产品是任务间没什么联系队头任务可能等了很久队尾任务刚进来就能被空闲线程取走未必公平但胜在简单。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核数 - 1java.util.concurrent.ForkJoinPool.common.threadFactory自定义线程工厂默认 InternalError 处理java.util.concurrent.ForkJoinPool.common.exceptionHandler未捕获异常处理器默认日志输出这三个属性里parallelism用的最多通常加上-Djava.util.concurrent.ForkJoinPool.common.parallelism4就能把池子压到 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())底层会经过这样几步ForkJoinPool.execute(ForkJoinTask?)或者execute(Runnable)会把任务包装成ForkJoinTask.RunnableExecuteAction。它将任务提交到当前线程的 workQueue如果提交线程不是池内 worker就会被提交到一个“共用队列”submission queue。ForkJoinPool在初始化时已经安排好了 worker 线程每个 worker 都是一个ForkJoinWorkerThread它们内部有一个无限循环runWorker()不断执行scan()方法寻找可执行任务。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 官方文档也不断提醒如果你的任务会阻塞请务必指定自定义执行器。但现实是大量同学写异步代码时根本不带第二个参数CompletableFutureInteger f1 CompletableFuture.supplyAsync(() - callHttp()); CompletableFutureInteger f2 CompletableFuture.supplyAsync(() - callHttp());两个 HTTP 调用同时发出去每次调用耗时 500ms看起来并行度很完美。然而服务器一忙HTTP 客户端内部线程池耗尽开始重试single 业务线程就会阻塞在 commonPool 里。因为你没指定 ExecutorBC 服务里的七八个异步接口全挤在同一个公共池子里谁也没法独善其身。我在某个支付回调项目里就见过一次“雪崩”一组内部服务调用本来走的是ThreadPoolTaskExecutor后来有一次版本升级某位同学把supplyAsync的第二个参数漏掉了系统切换到了 commonPool初期并发量小没感觉双十一流量一上来公共池的线程全部阻塞在外部接口等待上导致同一 JVM 里所有并行流查询、其他异步任务全部排队最后整链路超时。这个问题的根因不是CompletableFuture本身而是“异步任务没有明确指定执行器”。3.3 JDK 框架层的“便车”从 Timer 到 NIO 回调除了并行流和CompletableFuture还有一些 JDK 内部组件也依赖 commonPool只是不太显眼。比如Arrays.parallelSort()的分治排序默认使用 commonPoolThreadLocalRandom某些路径下触发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.parallelism8强制覆盖如果你不确定先打一张System.out.println(ForkJoinPool.getCommonPoolParallelism())的表看看实际值。4.2 阻塞任务的“幽灵效应”你杀不完的排队另一个容器时代的经典问题是线程饥饿。commonPool 的 worker 是守护线程不会因为 JVM 主线程结束而阻塞 JVM 退出。但运行中的业务逻辑如果阻塞在里面外部看进程还活着实际上线程全挂在老地方。举个例子有一个任务用RestTemplate请求外部接口默认 5 秒超时不指定 Executor。并发 100 请求下去commonPool 只有 7 个线程8核-17 个 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(() - { CompletableFutureInteger f1 CompletableFuture.supplyAsync(...); CompletableFutureInteger 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 密集并行计算用 ForkJoinPoolcommonPool 或自建池都行IO 密集高并发用虚拟线程不要用 commonPool混合负载优先虚拟线程处理 IO计算结果合并时再用 parallelStream 或者自定义 ForkJoinPool。最后再分享一点排查工具层面的小技巧。当你怀疑自己的服务被 commonPool 拖累了别急着改代码先做三件事jstack pid抓线程栈搜ForkJoinPool.commonPool-worker看它们到底卡在什么调用上jstat -gcutil pid 1000观察 GC 是否因为任务堆积而异常打一个日志打印ForkJoinPool.getCommonPoolParallelism()和Thread.activeCount()。我曾经排查过一个诡异问题所有并行流都极慢但池子并行度正常线程也没卡。最后抓jstack发现commonPool 的 worker 全被一个第三方 SDK 的parallelStream内部任务占用了。那个 SDK 在初始化时用并行流加载了一大堆网络配置每个配置都有 300ms 的 RPC 超时。你没法控制别人的代码所以最稳妥的方案仍是在自己在项目里把并行流和CompletableFuture的默认执行器都显式指定一遍。只要有一次“默认”被踩中你花在排查上的时间远超提前指定线程池的那几行代码。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑