递归任务绝不能与父任务共用同一固定大小线程池,否则因父任务占线阻塞等待、子任务无空闲线程执行,导致饥饿死锁。

直接说结论:递归任务用线程池时,父子任务绝不能共用同一个固定大小的线程池,否则极易触发饥饿死锁——这不是锁竞争问题,而是线程资源被“自己人”占满后,新任务彻底无处执行。
为什么递归+同池=高危组合
递归任务天然存在执行层级依赖。一旦父任务提交子任务到同一池,并同步等待(如 get()、join()、allOf().get()),就形成“自己等自己”的闭环:
- 父任务占着一个线程阻塞等待子任务完成
- 子任务因线程池已满,只能排队,无法被调度执行
实战中怎么快速定位是不是它
别猜,抓三份 jstack 快照(间隔10秒),重点查:
-
搜 “join” 或 “get”:看大量线程是否卡在
CompletableFuture.join()、Future.get()、CountDownLatch.await() -
搜线程池名:确认卡住的线程是否属于你自定义的业务池(如
business-pool-*)或ForkJoinPool.commonPool() -
看线程状态:如果一堆
WAITING线程堆在业务代码里调用get(),而它们等待的任务又提交到了同一个池——基本坐实
真正管用的修复方式
核心就一条:执行层级必须隔离,不能让父任务和它派生的子任务抢同一组线程。
- 给递归/分治类计算任务单独配池:比如用
ForkJoinPool.commonPool()或newCachedThreadPool()处理子任务,主流程走业务线程池 - 彻底放弃池内同步等待:把
future.get()改成thenCompose、whenComplete等异步回调链 - 若逻辑强依赖返回值且无法改异步,至少确保池有冗余:最小线程数 ≥ 最大递归深度 × 单层并发子任务数(仅作临时兜底,不推荐长期使用)
- 务必自定义 ThreadFactory 给线程命名,否则排查时根本分不清谁是谁
顺手防住衍生问题
饥饿死锁常不是孤立发生的,还会拖垮其他资源:
-
内存泄漏:大量未完成的
CompletableFuture持有上下文引用,GC 不掉 → 加监控看堆内CompletableFuture实例数趋势 - 连接泄漏:子任务本该释放的 DB 连接、HTTP 客户端连接,在阻塞等待中一直不归还 → 检查连接池活跃连接数是否随请求量线性上涨
-
ThreadLocal 泄漏:父任务设的变量没清理,被复用线程执行子任务时误读 → 所有业务池线程执行完务必
remove()关键 ThreadLocal










