默认不指定线程池时,thenApplyAsync() 使用 ForkJoinPool.commonPool(),其线程数少且混跑IO与CPU任务,易导致阻塞、排查困难;必须用双参数重载并显式传入适配任务类型的Executor(如ioExecutor/cpuExecutor)才生效。

thenApplyAsync 必须显式传入 Executor 才能脱离 ForkJoinPool.commonPool(),否则所有未指定线程池的调用都会挤在同一个公共池里,IO 任务一卡,CPU 密集型计算也跟着卡住。
为什么默认不指定线程池会出问题
所有没传 Executor 的 thenApplyAsync() 都走 ForkJoinPool.commonPool(),而这个池子:
- 线程数 =
Runtime.getRuntime().availableProcessors() - 1(通常只有 7 或 15) - 无区分场景:DB 查询、HTTP 调用、JSON 解析全混跑
- 一个慢 SQL 把线程占住 2 秒,后面几十个
thenApplyAsync就排队等 - 日志里看不到线程名,查问题时分不清是风控还是订单模块在阻塞
怎么传 Executor 才真正生效
必须用带两个参数的重载:thenApplyAsync(Function, Executor)。只写一个参数的版本(thenApplyAsync(Function))等于没改。
常见错误写法:
// ❌ 这个依然走 commonPool future.thenApplyAsync(data -> transform(data)); <p>// ✅ 必须显式传第二个参数 future.thenApplyAsync(data -> transform(data), cpuExecutor); </p>
注意点:
-
cpuExecutor和ioExecutor要提前定义好,不能每次 new - 别用
Executors.newCachedThreadPool()—— 它的队列无界 + 线程空闲 60 秒才回收,压测时容易 OOM - Spring 环境下建议用
ThreadPoolTaskExecutor,方便和@Async共享配置
IO 任务和 CPU 任务该用不同线程池
不是“配一个自定义池”就万事大吉,要按任务特性拆分:
- HTTP 请求、JDBC 查询、Redis 调用 → 用
ioExecutor:核心线程数设高(如 16~32),队列用LinkedBlockingQueue(200) - JSON 序列化、DTO 转换、规则计算 → 用
cpuExecutor:核心线程数 ≈ CPU 核心数,拒绝策略建议CallerRunsPolicy - 千万别把
supplyAsync(() -> db.query(), ioExecutor)后面跟thenApplyAsync(..., ioExecutor)—— IO 池里做 CPU 工作,拖垮整个池
典型链路示例:
CompletableFuture.supplyAsync(() -> fetchFromDb(), ioExecutor)
.thenApplyAsync(data -> enrichWithCache(data), ioExecutor) // 仍是 IO
.thenApplyAsync(enriched -> computeScore(enriched), cpuExecutor) // 切到 CPU 池
.thenAcceptAsync(result -> sendToKafka(result), ioExecutor); // 再切回 IO
线程池命名和生命周期容易被忽略
没命名的线程池在堆栈里显示为 pool-1-thread-1,多个模块共用时根本分不清归属。
务必给线程工厂加业务前缀:
new ThreadFactoryBuilder().setNameFormat("order-io-%d").build()
另外,线程池不是创建完就完事了:
- Spring Bean 场景下,必须实现
DisposableBean或用@PreDestroy调用shutdown() - 没 shutdown 的线程池会阻止 JVM 正常退出,本地测试可能不明显,但上线后影响发布流程
- 如果用了动态配置(比如 Nacos),记得监听变更并重新初始化线程池实例,而不是只改参数
最常漏的一点:异步链中某个环节抛了异常,但你没用 handle() 或 exceptionally() 捕获,那后续的 thenApplyAsync 就不会执行,线程也不会归还 —— 表现就是线程数缓慢上涨,直到耗尽。










