如果你接到过这样一个需求——把上万条数据拆成多个批次,每个批次丢给线程池并发处理,处理完再统一汇总结果——那你其实已经在写父子任务了。没错,父任务负责拆分和聚合,子任务真正干活,这种结构在很多系统中天天出现。前段时间我为了优化一个批处理服务的耗时,把这种写法用到了极致:任务里面再拆任务,结果白天一切正常,晚上流量一上来,服务大面积超时,线程池队列从几百涨到几十万,所有Worker线程全部WAITING。jstack一看,清一色卡在Future.get()上。
这个坑的阴险之处在于:它不是必现的,压测也很难压出来,流量低的时候一切正常,一旦并发一高就直接假死。而且父子任务搭配线程池时,踩坑不止这一种,还有ThreadLocal串号、异常被吞、部分失败伪装成成功等一长串问题。这篇文章把我实际排查和修复的完整过程写出来,适合所有用过ExecutorService、或者正准备用线程池做任务拆分的后端开发同学。
1. 场景复盘:一个"每天都会写"的任务拆分代码
1.1 场景长什么样
先看一个最基础但每天都在出现的写法:从库里查出n条记录,每条记录独立处理,为了提速丢给线程池并发跑,最后get()等待全部完成。
// 子任务:单个订单处理 Callable<Result> task = () -> process(order); ExecutorService pool = Executors.newFixedThreadPool(8); List<Future<Result>> futures = new ArrayList<>(); for (Order order : orders) { futures.add(pool.submit(() -> process(order))); } // 父线程:等待所有子任务 for (Future<Result> future : futures) { Result r = future.get(); }注意,这种"父线程等待子任务"的模式其实已经是父子任务了。只不过此时父任务是请求线程,不是线程池里的Worker,所以即使并发再高,最多是这个请求线程被阻塞,它不占线程池名额,问题还不算严重。
真正致命的是下一层:子任务本身就在池里的Worker上跑,而Worker内部又往下拆了一层任务,并且也在等待结果。
ExecutorService pool = Executors.newFixedThreadPool(2); // 父任务 public void parentTask() { List<Future<Result>> futures = new ArrayList<>(); for (int i = 0; i < 5; i++) { futures.add(pool.submit(() -> { // 子任务里还要继续拆分“孙任务” List<Future<Result>> innerFutures = new ArrayList<>(); for (int j = 0; j < 3; j++) { innerFutures.add(pool.submit(() -> doChildWork())); } // 子任务同步等孙任务 for (Future<Result> f : innerFutures) { f.get(); } return mergeResult(); })); } // 父任务同步等子任务 for (Future<Result> f : futures) { f.get(); } }这段代码从逻辑上看非常顺畅:外层拆子任务,子任务再拆孙任务,每层各自等待聚合。但凡是把它跑在高并发环境下的系统,基本都会在某一个流量尖峰瞬间全线假死。原因不在这段代码的"业务逻辑",而在线程池的执行模型。
1.2 线程池执行模型回顾
很多人用Executors.newFixedThreadPool,但未必清楚线程池内部那套"四阶段"执行逻辑。我快速过一遍,因为后面解释死锁链路全靠它。
一个ThreadPoolExecutor,核心参数就四个:corePoolSize(核心线程数)、maximumPoolSize(最大线程数)、workQueue(工作队列)、拒绝策略。
- 提交新任务时,如果当前线程数还没达到corePoolSize,直接新建Worker线程执行。
- 如果核心线程已经全部在忙,新任务进入workQueue排队。
- 如果队列也满了,继续创建新线程,直到maximumPoolSize。
- 连最大线程数都用完了,队列也满了,才会走拒绝策略。
换句话说,任务优先用核心线程跑,核心线程满了往队列塞,队列塞满了才扩线程,扩无可扩就拒绝。newFixedThreadPool是个特例:corePoolSize和maximumPoolSize一样大,队列用的是无界队列,所以它永远走不到"扩线程"这一步,任务只会进队列无限排队。
这个模型有一个非常容易被忽略的隐含前提:Worker线程是"长期存活、不断从队列取任务"的。正是这个"不断取任务"的机制,加上父子任务的层级结构,才引发了后面的假死。
1.3 为什么测试环境复现不了
这种坑最让人恼火的地方不是它多深奥,而是测试环境怎么压都压不出来。原因其实很简单:触发死锁需要一个临界条件——同一时刻被线程池处理的所有线程,都被父任务占住。
拿上面的代码来说,newFixedThreadPool(2),意味着池里只有两个Worker。只要同一时刻进入池中的父任务数量小于2,哪怕只有一个,另一个线程仍然可以腾出来执行队列里的子任务,程序最多慢一点,不会卡死。只有父任务数量达到2,两个Worker都被父任务占住,父任务又都在get()等待子任务,而子任务全部在队列里没人执行,才会进入死锁。
压测环境通常并发量低、线程池参数又开得比较大(很多测试环境直接newFixedThreadPool(32)),8个并发任务里有1个父任务,其余7个Worker闲得没事干,子任务秒级执行完,自然看不出问题。线上生产环境一旦流量上来,父任务并发瞬间打满核心线程,假死就开始了。所以遇到"低峰正常、高峰假死"的线程池问题,先别急着怀疑流量,先怀疑是不是任务自己在互相等待。
2. 坑点一:同池互等,线程池假死的完整链路
2.1 死锁形成的四步过程
为了把问题讲透,我们假设线程池是Executors.newFixedThreadPool(2),同一时刻有两个父任务T1、T2被提交进来。
第一步:T1、T2各自占用一个Worker线程,池内线程数达到核心数2,新任务只能进队列。
第二步:T1内部提交子任务C1、C2,T2内部提交子任务C3、C4。这四个子任务被放到同一个线程池的队列里。
第三步:T1执行到future.get(),等待C1、C2完成。T2执行到future.get(),等待C3、C4完成。两个Worker线程全部处于BLOCKED/WAITING状态,等待子任务返回。
第四步:C1、C2、C3、C4在队列里等待空闲线程,但池里唯二的两个线程都被父任务占着,且父任务不结束,线程就不会释放。于是队列里的任务永远等不到执行机会,整个池进入死锁。
用一张表格看更直观:
| 对象 | 状态 | 在等什么 |
|---|---|---|
| Worker线程1 | 执行父任务T1,WAITING | 等待子任务C1、C2的Future结果 |
| Worker线程2 | 执行父任务T2,WAITING | 等待子任务C3、C4的Future结果 |
| 工作队列 | C1、C2、C3、C4排队 | 等待Worker线程空闲 |
| 线程池整体 | 假死 | 无任何线程能执行队列任务 |
这和经典的"哲学家就餐"问题本质上是一回事:每个线程都在等别人释放资源,而资源恰恰被自己占着。只不过在这里,资源不是锁,而是线程池里的Worker线程名额。
2.2 jstack现场长什么样
我那次排障,第一步就是jstack抓线程快照。看到的内容大致长这样:
"pool-3-thread-1" #11 WAITING at sun.misc.Unsafe.park(Native Method) at java.util.concurrent.locks.LockSupport.park at java.util.concurrent.FutureTask.awaitDone(FutureTask.java:400) at java.util.concurrent.FutureTask.get(FutureTask.java:201) at com.example.OrderService.lambda$parentTask$0(OrderService.java:88) ... "pool-3-thread-2" #12 WAITING at sun.misc.Unsafe.park(Native Method) at java.util.concurrent.locks.LockSupport.park at java.util.concurrent.FutureTask.awaitDone(FutureTask.java:400) at java.util.concurrent.FutureTask.get(FutureTask.java:201) at com.example.OrderService.lambda$parentTask$0(OrderService.java:88) ...两个线程全部WAITING,状态码停在FutureTask.get,最终指向同一个业务方法:OrderService.lambda$parentTask$0。这时候线程池的健康指标也很典型:getPoolSize等于corePoolSize,getActiveCount等于corePoolSize,getQueue().size()持续上涨,getCompletedTaskCount几乎不动。
如果你在线上看到这几个指标同时出现,基本可以断定是任务互相等待导致的线程饥饿,而不是简单的容量不够。当然,后续还要通过堆栈确认等待链路是否指向同类线程池,才能真正下结论。
2.3 第一时间想到的"扩容"为什么无效
当时我们的第一反应也是"线程池不够用,扩容"。把maxPoolSize从2调到50,corePoolSize也从2调到10,然后满怀期待地看着监控——队列积压确实缓解了一会儿,但随着父任务并发数压上来,死锁重新出现,只是把临界点往后推了而已。
道理很简单:这种死锁的本质是"线程池Worker在执行父任务时等待同一个池的子任务",只要父任务能把线程占满,无论池里有2个还是50个线程,最终都会出现所有线程都被父任务占住、子任务排队等待的僵局。扩容只是把触发并发数从2变成50,并没有解决"互相等待"的根因。
而且无脑扩容还会带来两个副作用:一是线程数变大后上下文切换成本陡增,系统整体吞吐反而下降;二是无界队列模式下,队列积压的任务会持续占用堆内存,时间一长直接OOM。这也是为什么我后来在团队里立了一条规矩:线程池参数必须是有界队列,且必须监控队列积压量。
2.4 正解:让父任务和子任务不在同一个池里竞争
破局的核心思路只有一句话:不要让持有线程池Worker的线程,去同步等待同一个线程池里的其他任务。顺着这个思路,有三种常用解法。
第一种:父子任务使用不同的线程池。父任务放在父池,或者直接由请求线程执行,子任务放进一个独立的子池。这样父任务等待子任务时,子池里永远有线程可以消费自己的队列。
// 父池:负责“父任务”这种组装/编排逻辑 ExecutorService parentPool = Executors.newFixedThreadPool(4); // 子池:负责真正的业务子任务 ExecutorService childPool = new ThreadPoolExecutor( 8, 16, 60L, TimeUnit.SECONDS, new ArrayBlockingQueue<>(200), r -> new Thread(r, "child-pool"), new ThreadPoolExecutor.AbortPolicy() );第二种:如果任务天然适合"分治+合并"模型,比如一个大数据集要反复拆分、汇总,可以考虑改用ForkJoinPool。它的Worker在调用join等待子任务时,不是傻等,而是会去任务队列里"窃取"别的子任务来执行,这正是为递归拆分场景设计的机制。但要注意,ForkJoinPool更适合CPU密集型任务,如果子任务里全是IO等待,工作窃取的优势会被阻塞抵消,反而是普通线程池加异步化更合适。
第三种:任务结构尽量扁平化。很多"父子任务"其实没必要拆成两层,完全可以先在内存里把任务粒度切好,一次性提交一批子任务,等这一批全部结束后再做下一批。两级变一级,坑自然就没了。
3. 坑点二:Worker线程复用时ThreadLocal串号
3.1 先看"读不到":子线程拿不到父线程的上下文
死锁这个坑大家多少有点意识,但第二个坑就没那么多人知道了:ThreadLocal在线程池场景下的丢失和串号。
很多系统会把当前登录用户、租户ID、traceId放在ThreadLocal里,方便全局取用。在单线程模型下这完全没问题,父线程set,父线程get,永远正确。可一旦涉及子任务,很多同学会发现:父线程设进去的userId,子任务里取出来是null。
这不算bug,是ThreadLocal的基本机制——它是线程私有的,A线程写的数据,B线程默认读不到。子任务是Worker线程执行的,Worker线程从来没有被父线程set过,自然取不到。
3.2 更危险的是"读错":脏数据串号
比"读不到"更可怕的是"读错"。线程池里的Worker是长期存活、反复执行任务的。它执行完任务A之后,ThreadLocal里的数据并不会被自动清掉。如果任务A随手set了一个userId = "张三",任务B恰好复用了这个Worker,任务B又没有主动set,那任务B读到的userId就是张三。
ThreadLocal<String> currentUser = new ThreadLocal<>(); // 任务1:设置用户信息 pool.submit(() -> { currentUser.set("U1"); doSomething(); }); // 任务2:复用同一个Worker线程,不主动set pool.submit(() -> { // 这里很可能打印出 U1 System.out.println(currentUser.get()); });凡是做多租户隔离、做数据权限控制的系统,这种串号一旦发生,就是严重的线上事故:A用户可能看到B用户的数据,A租户的请求可能被路由到B租户的配置。比死锁更隐蔽,因为代码完全正常,测试也大概率测不出来,只有线上多个任务复用同一个线程时才会偶发。
这里还要特别提一下InheritableThreadLocal。它是JDK自带的"子线程继承父线程ThreadLocal"的机制,但线程池场景下依然有坑:子线程必须是在new Thread()创建的那一刻才会继承父线程的值,而线程池的Worker线程早就创建完了,之后即使父线程改了值,Worker也不会更新。更麻烦的是,Worker复用时,InheritableThreadLocal里的旧值依然存在,串号照样发生。所以它并不适合作为线程池父子任务的上下文解决方案。
3.3 一套通用的上下文装饰器写法
正确的思路是:在提交任务的那一刻,把当前线程的上下文快照下来,随任务一起交给Worker;Worker执行前恢复快照,执行后必须清理。
具体做法可以用一个装饰器统一包装Runnable:
public class ContextDecorator { public static Runnable wrap(Runnable task) { // 在提交任务时,快照当前线程的上下文 Map<String, String> snapshot = ContextHolder.snapshot(); return () -> { // 进入子任务前,把快照恢复到当前Worker线程 ContextHolder.restore(snapshot); try { task.run(); } finally { // 执行完毕必须清理,防止脏数据串到下一个任务 ContextHolder.clear(); } }; } }所有提交任务的地方,统一用ContextDecorator.wrap(...)包一层:
pool.submit(ContextDecorator.wrap(() -> process(order)));如果你的项目用的是Spring的ThreadPoolTaskExecutor,它内置了setTaskDecorator方法,直接把装饰器配置进去,所有任务都会自动被包装,不需要在每个submit处手动处理。原生ThreadPoolExecutor没有这个API,那就老老实实封装一个工具方法,保证团队所有线程池提交入口都走同一个包装逻辑。
有两个细节必须强调。第一,finally里的clear绝对不能省,否则当前任务的上下文会污染下一个任务,串号问题依然存在。第二,装饰器只能解决"提交线程→执行线程"的传递,如果子任务又往其他线程池继续提交孙任务,每一层都得包装,中间断一层,链路就断了。实际排查过此类问题的同学应该都明白,多级线程池场景下,上下文传递根本不能靠自觉,必须靠全链路强制化的提交入口。
开源社区倒是有现成的TransmittableThreadLocal工具,配合它的TtlExecutors,一行代码就能包装线程池,底层原理就是上面这套快照和恢复。工具本身很好用,但我还是建议你先理解透这套手写方案,因为线上出了问题,最终能帮你定位的还是底层原理,而不是工具封装。
4. 坑点三:子任务异常被吞,部分失败却显示成功
4.1 execute与submit的异常行为差异
第三个高频坑,是父子任务批量模式下的异常被吞。很多同学对execute()和submit()的异常行为区别不清楚,导致子任务挂了,父任务却以为自己成功了。
execute方式提交的任务,如果抛出RuntimeException,线程池会把这个异常交给UncaughtExceptionHandler,默认行为是打印到System.err,然后这个Worker线程被终结,线程池再新建一个Worker顶替。整个过程对调用方完全透明,代码根本感知不到异常。
submit方式提交的任务,异常会被封装进Future对象里,get()的时候才抛ExecutionException。听起来submit比execute安全一些,但问题出在批量场景:如果父任务批量提交了100个子任务,然后循环get(),循环体里只catch了InterruptedException,漏了ExecutionException,或者干脆在catch块里只打了日志、没有做失败聚合,那异常照样等于被吞掉了。
| 提交方式 | 任务抛异常时发生什么 | 调用方能否感知 |
|---|---|---|
| execute | 异常交给UncaughtExceptionHandler,默认仅打印 | 不能感知,Worker线程重建 |
| submit | 异常保存在Future内部,get()时抛出ExecutionException | 只有get()且正确捕获才能感知 |
| submit但从不get | 异常一直挂在Future里,没有人看它 | 完全无感知,类似吞掉 |
4.2 加工过的"部分成功"案例
我处理过一个典型的线上问题:一个结算服务,父任务生成100个子任务去更新100个账户余额,其中3个子任务因为账户状态异常抛了错。父任务代码是标准的批量get循环,catch了ExecutionException之后只打了一条error日志,然后继续循环。循环结束,没有任何地方把失败数量往上抛,接口直接返回"全部成功"。
结果就是97个账户余额改了,3个账户没改,上游系统收到成功信号开始后续步骤,直到第二天对账才发现账不平。这个问题的根源不在线程池,而在于批量父子任务把"错误距离"拉远了:子任务的失败点离父任务的决策点太远,中间只要处理不当,失败就像沙子掉进大海,无声无息。
正确的做法是在父任务里显式聚合每一个子任务的执行结果,包括成功和失败,最后统一做决策:
List<Future<Result>> futures = new ArrayList<>(); for (Order order : orders) { futures.add(subPool.submit(() -> process(order))); } AtomicInteger failCount = new AtomicInteger(0); List<Result> results = new ArrayList<>(); for (Future<Result> future : futures) { try { Result r = future.get(5, TimeUnit.SECONDS); results.add(r); } catch (ExecutionException e) { // 子任务执行失败:记录原因,继续获取其他任务结果 failCount.incrementAndGet(); log.error("子任务执行失败", e.getCause()); } catch (TimeoutException e) { // 单个任务超时:取消它,并计入失败 future.cancel(true); failCount.incrementAndGet(); } catch (InterruptedException e) { Thread.currentThread().interrupt(); failCount.incrementAndGet(); break; } } if (failCount.get() > 0) { // 明确失败,不要返回成功。该抛出就抛出,该触发补偿就触发补偿 throw new RuntimeException("子任务存在失败,失败数量=" + failCount.get()); }这段代码每次get都带超时,这是父子任务里很容易被忽略的一个点。如果不带超时,某个子任务永久卡死,父任务就会跟着永久卡死,上游不断重试,线程池被占满,又是一个连锁故障。带超时后,至少父任务能做出"放弃本次批次"的决策,把线程让出来。
4.3 CompletableFuture的异常处理别忘了两件小事
现在很多人已经开始用CompletableFuture替代裸Future,异常可以用exceptionally或者handle串接,代码确实优雅不少。但CompletableFuture在父子任务场景有两个同样容易踩的细节。
第一,不指定Executor时,它默认使用ForkJoinPool.commonPool(),这是一个公共线程池,整个JVM里所有不显式指定线程池的CompletableFuture都在这里跑。多个业务模块共用一套公共线程池,高峰期互相挤兑,一个模块卡住可能拖垮其他模块。所以凡是提交给CompletableFuture的异步任务,一律显式传入自己的线程池。
第二,exceptionally只是给某个异步链加了"兜底返回值",它不会自动把异常传播给父任务。如果你在子链路里用了exceptionally返回了一个默认值,父任务看到的就是一个正常结果,业务上可能把"失败但兜底"当成了"成功"。该怎么区分、该不该兜底,要在设计阶段想清楚,不能靠运行时碰运气。
5. 可落地的组合方案:双池隔离、装饰器与监控缺一不可
5.1 一条硬原则和一个推荐结构
把前面三种坑串起来,总结成一条我给团队立过的硬性原则:持有线程池Worker的线程,绝对不要去同步等待同一个线程池里的其他任务。违反这一条的代码,不管当时看起来多合理,都有假死风险。
在这个基础上,推荐的任务结构是:父任务放在请求线程执行(或者单独一个"编排池"),子任务放在独立的"业务池",上下文用装饰器统一传递,结果用带超时的聚合逻辑处理。这样死的死锁坑、脏数据坑、异常吞掉坑,都能被结构性地规避。
5.2 一个可以直接抄的完整实现
下面是一个综合前面所有教训的完整示例,父任务在请求线程执行,直接当作普通方法调用即可:
public class TaskExecutor { // 子任务池:有界队列,拒绝策略选 AbortPolicy,宁可抛异常也不要无限积压 private final ThreadPoolExecutor subPool = new ThreadPoolExecutor( 8, 16, 60L, TimeUnit.SECONDS, new ArrayBlockingQueue<>(200), r -> new Thread(r, "sub-task-pool"), new ThreadPoolExecutor.AbortPolicy() ); public Result executeParentTask(List<Order> orders) throws InterruptedException { int n = orders.size(); CountDownLatch latch = new CountDownLatch(n); List<Result> results = Collections.synchronizedList(new ArrayList<>()); AtomicInteger failCount = new AtomicInteger(0); List<Future<?>> futures = new ArrayList<>(); for (Order order : orders) { Future<?> future = subPool.submit(ContextDecorator.wrap(() -> { try { Result r = process(order); results.add(r); } catch (Throwable t) { failCount.incrementAndGet(); log.error("子任务失败, orderId={}", order.getId(), t); } finally { // 必须放在 finally,保证任何情况都能倒计数 latch.countDown(); } })); futures.add(future); } // 整体等待,必须带超时。超时后取消所有未完成任务并抛出明确错误 if (!latch.await(30, TimeUnit.SECONDS)) { futures.forEach(f -> f.cancel(true)); throw new RuntimeException("子任务整体执行超时,已取消"); } if (failCount.get() > 0) { throw new RuntimeException("子任务存在失败,失败数量=" + failCount.get()); } return merge(results); } private Result process(Order order) { // 真正业务逻辑 return new Result(order.getId(), Constants.SUCCESS); } private Result merge(List<Result> results) { // 聚合逻辑 return Result.merge(results); } }这里用CountDownLatch而不是逐个Future.get,是因为整体等待更容易统一设置超时,也方便在超时后统一取消。失败信息通过failCount聚合,最终决策只有一句:有失败就整体抛错,绝不假装成功。ContextDecorator包装解决ThreadLocal传递,有界队列加AbortPolicy保证不会无限积压。这套组合是我们在多个核心服务里验证过的,至少比赤手空拳写线程安全多了。
5.3 监控和报警:把"看不见的坑"变成指标
最后说监控。父子任务相关的坑,最麻烦的地方是初始阶段"看不见"。线程池假死从发生到被感知,中间可能隔着几分钟甚至几十分钟的队列积压。所以我对线程池一直坚持做指标埋点,至少每30秒采集一次以下数据:
- getActiveCount():活跃线程数,持续等于核心线程数时要注意。
- getQueue().size():队列积压量,持续上涨说明任务消费能力不足。
- getCompletedTaskCount() / getTaskCount():已完成任务占比,用来判断是不是有线程长期卡住。
- 自定义拒绝策略里的rejectedCount:只要出现拒绝,就是系统容量到极限的信号,必须触发告警。
看这些指标要结合起来看,不能单独看某一个。比如队列积压在涨,同时activeCount长期等于core,completedTaskCount半天不动,那大概率不是流量问题,而是任务互相等待。这时候果断jstack抓现场,重点看有多少线程WAITING在FutureTask.get上,基本就能定位。
我在实际系统里见过各种变体:有人把父任务和子任务分到了两个池,但父池的Worker依然在等父池的其他任务;有人用了装饰器却忘了finally清理,脏数据照样串;有人对子任务异常做了捕获吞掉而不是传播。每一条都是线上出过大问题的。如果你正在写多级拆分的代码,建议先拿这几条逐项过一遍:父子任务是否同池提交,上下文是否统一包装,异常是否带超时聚合,线程池是否可监控。这四项做到位,能省下的深夜排查,远比你想象的要多。