有一次帮同事排查线上问题某交易系统的批量数据处理任务突然全部卡住。从页面看任务一直在转圈看日志最后一条日志停在某个父任务开始处理后面的子任务日志一条都没打。线程池用的是最常见的Executors.newFixedThreadPool(8)jstack 一抓8 个工作线程全部是 WAITING栈顶清一色停在FutureTask.get()。后面查清楚问题就出在线程池和父子任务的组合上父任务把子任务提交到同一个线程池再用get()同步等子任务结果。看似很常规的写法在任务量上来之后会形成子任务永远等不到执行资源的死锁式等待。这篇不聊虚的直接从现象、根因、复现、解法到隐藏坑把这个题目拆透适合所有在业务里用线程池做批量拆分、并发聚合或者自己维护过任务调度组件的开发者。1. 现场还原批量任务卡死8 个工作线程全部 WAITING1.1 问题出现的背景那套系统的功能不复杂后台管理页面可以手动触发一批数据处理任务每次触发会把几千个业务单号拆成多个父任务丢进一个固定大小的线程池并行执行。每个父任务内部又要根据单号去查对应的子明细数据查明细这步也丢进了同一个线程池然后父任务用get()等子任务返回结果再继续处理。功能上线初期数据量小一直没出过问题。某天业务量涨了一截运营点了一下触发按钮页面开始转圈日志在半个小时后依然没有新增。查数据库没有大批量的更新操作查应用日志没有异常堆栈进程还在CPU 也不高。典型的假死症状。1.2 jstack 快照里最刺眼的一行先抓线程快照。在服务器上执行jstack pid然后看线程状态分布结果很扎眼线程池里的 8 个工作线程全部是WAITING状态而且栈内容高度相似。pool-2-thread-3 #23 prio5 os_prio0 cpu12.34ms elapsed2456.89s tid0x00007f1234567890 nid0x2abc waiting on condition [0x00007f1234567890] java.lang.Thread.State: WAITING at java.util.concurrent.FutureTask.get(FutureTask.java:206) at java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:617) ...每个线程的栈顶都是FutureTask.get()再往下看调用链全部指向同一个业务方法——父任务里等子任务结果的那一行。更关键的是被等待的FutureTask对应的是线程池队列里的子任务。也就是说线程池里的线程都在等线程池自己队列里的任务来执行。这是一个逻辑上的闭环jstack 默认的死锁检测并不会报出来因为它不是 synchronized 锁的互相等待。1.3 第一轮排查排除常见嫌疑人排查过程里我先排除掉了几个常见原因。没有 OOM日志里没有OutOfMemoryErrorJVM 内存曲线也平稳。不是数据库连接耗尽连接池监控显示连接数正常没有被占满。不是数据库锁数据库侧没有长时间未提交的事务也没有阻塞会话。不是 synchronized 死锁jstack 没有输出 deadlock 检测结果监控也没有发现互持锁。最后才把怀疑焦点放到线程池本身。一查线程池状态队列里有几百个任务排队工作线程全部处于阻塞等待中一个真正干活的都没有。这个场景非常典型线程池看起来满了实际上一个任务都没在执行。2. 根因拆解父任务等子任务在线程池内部形成等待闭环2.1 业务代码里的典型写法我们把代码化简一下你大概率见过甚至写过这种模式ExecutorService pool Executors.newFixedThreadPool(8); // 提交多个父任务每个父任务内部再提交子任务并同步等待 for (int i 1; i 10; i) { final int taskId i; pool.submit(() - { // 父任务先做一部分前置处理 System.out.println(父任务 taskId 开始线程 Thread.currentThread().getName()); // 子任务提交到同一个线程池 FutureInteger childFuture pool.submit(() - { System.out.println(子任务 taskId 开始线程 Thread.currentThread().getName()); Thread.sleep(500); return taskId * 10; }); // 父任务同步等待子任务结果 Integer result childFuture.get(); System.out.println(父任务 taskId 拿到结果 result); }); }这个写法在单个父任务时没有任何问题父任务占一个线程等子任务时线程池还有空闲线程子任务能排上执行。但一旦父任务数量超过线程池的可用线程数事情就变了。2.2 等待闭环怎么一步步形成拿newFixedThreadPool(8)举例它的参数本质是corePoolSize8、maximumPoolSize8工作队列是无界的LinkedBlockingQueue。我们夸大一点假设只提交 8 个父任务第 1~8 个父任务依次被线程池接收8 个工作线程全部被父任务占用。每个父任务执行到一半向同一个线程池提交子任务。由于工作线程都在忙子任务只能进入队列排队。父任务执行到childFuture.get()开始阻塞等待子任务结果。子任务在队列里需要空闲线程才能执行。但 8 个工作线程都被父任务占着且父任务在get()上不释放线程。结果是所有线程都在等队列里的子任务而子任务永远不会被执行。如果get()没有超时这个状态会永久持续下去。死锁式等待的成立条件特别简单阻塞等待的父任务数量 ≥ 线程池可用工作线程数。这也是为什么它前期不被触发——数据量小、父任务少、线程有余量等业务量一涨父任务把线程占满坑就爆了。2.3 为什么加大 maxPoolSize不能根治很多人第一反应是把线程池调大不就行了比如 8 改成 32。这能缓解但治标不治本。如果父任务数量持续增长总有一个时刻父任务能把所有线程占满闭环依然形成。你只是把爆发点推后了。固定线程池用的是无界队列maximumPoolSize在队列没满时根本不生效。Executors.newFixedThreadPool(8)的队列是LinkedBlockingQueue无界意味着maxPoolSize形同虚设。如果换成有界队列队列满了之后线程池会扩容到maxPoolSize再满就会触发拒绝策略。AbortPolicy直接抛RejectedExecutionException任务在父任务里被拒绝表现为某一次提交突然失败CallerRunsPolicy会在父任务自己的线程里同步执行子任务又要重新嵌套一层阻塞。所以加大线程数只是把风险延后真正的解法要从结构上打破父任务同步等待子任务这个循环。后面两章给出两个能落地的方案。3. 解法一换用 ForkJoinPool让 worker 在等待中继续干活3.1 ForkJoinPool 对父子任务友好的原因Java 7 引入的ForkJoinPool在设计上就是为父任务拆子任务、再汇总结果准备的。针对我们遇到的死等场景它有两个关键机制join 的帮忙执行语义worker 线程在调用ForkJoinTask.join()等待子任务时不会像FutureTask.get()那样傻等。它会去自己的队列或者别的 worker 的双端队列里偷任务来执行直到被等待的子任务完成。这段等待时间是有效等待是在干活的不会让线程资源白白闲置。工作窃取队列每个 worker 有自己的任务队列空闲线程可以从其他线程的队列尾部窃取任务天然适合一个任务拆多个子任务的负载均衡场景。另外ForkJoinPool还提供ManagedBlocker机制。如果 worker 不是阻塞在join()上而是阻塞在外部条件比如等一个CountDownLatch可以用ManagedBlocker告诉线程池我这个线程真堵住了请临时补一个线程进来维持并行度。这个机制在深度使用ForkJoinPool时很重要但日常用join()已经能覆盖大部分父子任务场景。3.2 最小改造示例把原来的ExecutorService换成ForkJoinPool改动量很小ForkJoinPool pool new ForkJoinPool(8); Integer finalResult pool.submit(() - { System.out.println(父任务开始线程 Thread.currentThread().getName()); // 子任务提交到同一个 ForkJoinPool ForkJoinTaskInteger childTask pool.submit(() - { System.out.println(子任务开始线程 Thread.currentThread().getName()); Thread.sleep(500); return 42; }); // join 而不是 get等待期间 worker 会帮忙执行其他任务 Integer childResult childTask.join(); System.out.println(子任务结果 childResult); return childResult 1; }).join(); System.out.println(最终结果 finalResult); pool.shutdown();你可以故意连开 8 个父任务每个父任务都往同一个ForkJoinPool里丢子任务并join()亲测不会像固定线程池那样挂死。核心原因就是 worker 在join()期间会继续执行队列里的其他任务包括那些排在后面的子任务。3.3 适用边界不是所有场景都能直接换ForkJoinPool不是万能药我用下来有几个地方需要特别注意。默认线程是守护线程。ForkJoinPool的 worker 线程是 daemon 线程不能像普通线程池那样守护应用存活这里要反过来理解如果你的主线程退出了池里的线程不会阻止 JVM 退出。所以长时间运行的服务端应用没问题但写 Demo、批处理脚本时要注意主线程的生命周期。最适合 CPU 密集的拆分汇总。ForkJoinPool的核心模型是计算型任务拆分比如数组排序、递归计算、大集合分治。如果子任务是 HTTP 调用、数据库查询这类 IO 密集操作worker 在join()期间虽然能帮忙执行其他任务但大量 IO 阻塞还是会让线程不够用。这种场景要么配合ManagedBlocker要么直接看下一章的拆池方案。不要随手用ForkJoinPool.commonPool()。它是整个 JVM 共享的公共池里面如果塞了耗时 IO 任务会污染所有用到公共池的代码。自定义ForkJoinPool更安全还能独立控制线程数。4. 解法二父子任务拆成两个池子从资源上隔离死等4.1 独立线程池的设计思路如果你不想动 ForkJoinPool或者业务本身就是 IO 密集型拆池是最稳妥的做法。核心思路一句话父任务池的线程负责阻塞等待子任务池的线程负责真正干活两个池子的资源互不占用闭环自然就断了。// 父任务池控制同时运行的父任务数量 ThreadPoolExecutor parentPool new ThreadPoolExecutor( 4, 4, 0L, TimeUnit.SECONDS, new LinkedBlockingQueue(500), new ThreadPoolExecutor.CallerRunsPolicy() ); // 子任务池保证子任务有独立执行资源 ThreadPoolExecutor childPool new ThreadPoolExecutor( 8, 16, 60L, TimeUnit.SECONDS, new LinkedBlockingQueue(2000), new ThreadPoolExecutor.AbortPolicy() ); // 父任务内部这样写 FutureInteger childFuture childPool.submit(() - { // 子任务真正执行 return doChild(xxx); }); Integer result childFuture.get(5, TimeUnit.SECONDS);父任务卡在get()上时占的是父任务池的线程不影响子任务池里的 8 个线程去执行子任务。等子任务跑完父任务拿到结果继续走线程释放。这里有两个细节要注意等待必须带超时拒绝策略要有意识地去选后面展开。4.2 两个池子的核心参数怎么估算很多人在拆池时卡在到底开多少线程。我一般按这样的思路估算先明确一个前提线程数不是越大越好真正决定吞吐的是下游资源比如数据库连接数、外部接口的 QPS 上限。池子大小只是把并发度调节到下游能承受的量级。父任务池对应同一时刻最多跑几个大任务。如果一次触发就处理一轮2~4 个通常够用如果页面可能被多人同时触发按并发用户数 × 每个用户同时处理的批次上限估。父任务池开大了没有意义因为父任务的主要工作是等子任务线程再多也是在get()上干等。子任务池对应单个父任务拆出的子任务数 × 期望同时运行的父任务数。IO 密集场景可以按 16~32 起步配合压测看下游的承受能力CPU 密集场景控制在CPU核心数×2以内免得上下文切换拖垮性能。队列长度队列是缓冲用来吸收瞬时洪峰。但是队列越长任务在队列里等待的时间就越久超时失败的几率越大。父任务队列建议给几百子任务队列按单个父任务的子任务量乘以父任务池大小来定够用就行别贪大。表格对比一下两种典型场景的参数场景父任务池子任务池队列说明IO 密集RPC/数据库416父500 / 子2000受下游连接数限制先压测再定CPU 密集计算型2~48~16父200 / 子1000不应超过 CPU 核数的合理倍数4.3 兜底策略超时、拒绝策略、取消传播拆池解决的是永远死等的问题但还要管理等太久和任务多到装不下的情况。等待必须带超时childFuture.get(5, TimeUnit.SECONDS)而不是get()。一旦超时说明下游真的扛不住了没必要继续占着父任务线程。超时后要主动childFuture.cancel(true)把子任务的中断状态传出去避免它继续在池子里白跑。拒绝策略要有意识选AbortPolicy默认会在提交时直接抛异常异常如果没被处理好表现为任务静默失败CallerRunsPolicy会让提交方自己执行被拒绝的任务在拆池方案里它是个不错的兜底——子任务池满了说明流量超预期退化为串行执行至少不会丢任务。我个人倾向在父任务池用CallerRunsPolicy子任务池用AbortPolicy并配合告警。取消传播要显式做Future.cancel(true)只会中断当前被等待的 Future 对应的任务不会自动级联取消子任务再提交的孙任务。如果父子关系有多层嵌套需要设计一个统一的取消标记比如每个任务持有父任务的取消状态逐层传递。5. 牵连出的第二个坑ThreadLocal 在子任务里静默丢失5.1 现象父任务设置的上下文子任务里取出来是 null拆池方案上线后没多久新的问题冒出来了日志里的 traceId 串不起来了。网关层把 traceId 放进ThreadLocal父任务里还能正常取到但父任务提交子任务后子任务里取出来是 null导致一条业务链路的日志断成好几截排查问题要全靠人工对时间。代码简化一下就是这样ThreadLocalString traceId new ThreadLocal(); traceId.set(abc-123); ExecutorService pool Executors.newFixedThreadPool(2); pool.submit(() - { // 父任务里还能取到 System.out.println(父任务 traceId traceId.get()); Future? f pool.submit(() - { // 子任务里大概率是 null System.out.println(子任务 traceId traceId.get()); }); f.get(); });5.2 原因ThreadLocal 是线程私有的线程池线程是复用的ThreadLocal的值存在当前线程自己的ThreadLocalMap里天然是线程私有。父任务跑在线程 A 上设置的 traceId 只在线程 A 上可见。父任务把子任务提交给线程池子任务可能跑在线程 B、线程 C 或其他任何空闲线程上——这些线程的ThreadLocalMap里根本没有 traceId。有人会立刻想到InheritableThreadLocal。它能解决创建线程时继承父线程的值但线程池场景依然不靠谱池里的 worker 在池子初始化时就创建好了之后调用方设置的值不会被继承而且 worker 线程是被多个任务复用的就算继承了某个值也可能是上一个任务留下的脏数据。在池化线程里InheritableThreadLocal只能放大脏数据问题。5.3 解决思路提交时拍快照执行时重建finally 里恢复正确思路是在提交任务的时刻把当前线程的上下文快照下来子任务真正执行时把快照的值临时放进去执行完再恢复原状。下面的包装器是我在实际项目中手写过的最小可用版本public class ContextPropagator { private static final ThreadLocalMapString, String CTX new ThreadLocal(); // 提交时调用把当前线程的上下文快照绑定到任务上 public static T CallableT wrap(CallableT task) { MapString, String snapshot CTX.get(); return () - { MapString, String backup CTX.get(); CTX.set(snapshot); try { return task.call(); } finally { // 恢复到执行前的状态防止上下文串到下一个任务 if (backup null) { CTX.remove(); } else { CTX.set(backup); } } }; } }使用方式也很简单提交子任务前套一层FutureInteger f childPool.submit(ContextPropagator.wrap(() - { // 这里能拿到提交时的 traceId return doChild(); }));这个包装器的三个动作——捕获、重放、恢复——是线程池上下文传递的标准思路。捕获发生在提交线程上所以拿到的一定是提交那一刻的上下文重放发生在执行线程上保证子任务内部可见恢复放在finally里避免上下一位任务串号。如果你的项目里大量代码都是线程池提交手写包装器容易漏而且每次都要记得wrap。开源社区有个TransmittableThreadLocal组件把捕获、重放、恢复都封装好了还支持 Java Agent 增强方式提交任务的时候可以自动传递上下文不用改业务代码。我在项目里权衡后选用了它线上跑了很久一直很稳定。5.4 上下文传递的几个注意点快照里别放大对象只传必要的小字段比如 traceId、用户 ID、租户 ID、语言标识。把整个请求体塞进ThreadLocal内存和性能都受不了。必须保证 finally 清理线程池线程复用是常态不清理的话上下文会串到完全不相干的任务上而且这种 bug 是间歇性的极难定位。wrap要包在最外层如果wrap包在中间层外层可能已经换了线程快照取到的就不是你想要的上下文。提交任务时包装一定要在最接近线程池提交点的位置。6. 实战自检清单再写父子任务代码前我先过这五条6.1 五条自检缺一条都可能埋雷这些年的经验总结下来遇到父子任务场景我在写代码前会按顺序过一遍清单父任务是否可能阻塞等待子任务子任务是否用到了同一个线程池如果是要么换ForkJoinPool要么拆池绝不让父子任务共享有界线程池。这是最核心的一条。等待是否带超时超时后是否取消get()裸等待是大忌至少加个合理超时超时后cancel(true)并记录告警日志。拒绝策略是什么默认的AbortPolicy会把异常抛在提交点如果父任务里没接住排查时只能看到任务莫名消失。选策略要在代码里写清楚别依赖默认值。线程池是否被多处业务共用公共池要特别小心一个业务的父任务卡死会让整个池子的任务全部排队。大流量业务尽量用独立池实例。上下文信息是否需要透传到子任务如果需要提交时快照、执行时重放、finally恢复三件套必须齐全有现成的组件就用现成组件别自己造轮子漏掉边界。6.2 如果再给我一次机会我会优先考虑异步编排除了拆池和 ForkJoinPool还有一种思路是从根上消灭父任务阻塞等待这个动作——用CompletableFuture把父子依赖编排成回调链CompletableFuture.supplyAsync(() - doParent(), parentPool) .thenCompose(x - CompletableFuture.supplyAsync(() - doChild(x), childPool)) .thenAccept(result - System.out.println(最终结果 result));这样设计的好处是任何线程都不会卡在get()上父任务执行完就释放线程子任务的结果通过回调继续处理。代价是代码从顺序同步变成链式异步可读性明显下降团队没有统一的编排规范时容易写出谁也看不懂的嵌套回调。所以这套方案更适合新项目从一开始就定好规范老项目改造还是拆池更稳妥。另外分享一个排查小技巧线上遇到线程池假死jstack 抓到大片WAITING在FutureTask.get()时先数一下阻塞在get()上的线程数量再对比线程池大小。如果前者已经接近或超过后者并且线程池队列里排着子任务基本可以当场确诊是父子任务等待闭环。这套判断方法我在后续排查其他项目时用过多次一抓一个准能省掉大量瞎猜的时间。回头再看那个事故其实根子上就是一句话父任务和子任务共享执行资源却又互相等待资源就成了死结。换池子、换模型、换编排方式本质都是在打破这个死结。下次再看到这种症状希望你能少走我这些弯路。