ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Java多线程子线程异常捕获:try-catch失效后的三种可靠方案

Java多线程子线程异常捕获:try-catch失效后的三种可靠方案 刚接手一个老项目的维护任务时我碰到过一个特别经典的问题一个定时任务线程池隔三岔五就会出现一批任务静默消失日志里干干净净上游却在投诉数据没入库。查了几天后才确认子线程抛出的运行时异常根本没传到任何我可以观察到的位置——主线程压根不知道子线程异常了。这个问题的本质就是本文要聊的核心在主线程层面有哪些可靠方案能捕获子线程的异常或者说至少让我们在子线程异常时能及时感知、记录和补偿。这个话题对两类人特别有用一类是刚接触Java多线程对try-catch为啥抓不住子线程异常感到困惑的初学者另一类是正在做异步任务、批处理、消息消费的工程同学需要在生产环境里兜住线程池中所有不可控异常。下面的内容我按方案的真实可靠度来展开附带我实际踩过的细节坑。1. 先搞明白一个扎心问题主线程的try-catch为什么拦不住子线程异常很多初学者第一次写多线程代码时会理所当然地写出下面这种代码以为在主线程包一层try-catch就能把子线程的异常接住public class TryCatchTest { public static void main(String[] args) { try { new Thread(() - { throw new RuntimeException(子线程炸了); }).start(); } catch (RuntimeException e) { System.out.println(捕获到异常 e.getMessage()); } } }运行一下就会发现主线程的catch块根本没执行控制台直接打印了一大段异常栈程序也没退出。为什么因为线程之间的异常传播模型和你在同一个方法里写try-catch是完全不同的。1.1 线程的执行入口是run()异常不会“顺着调用链”回到创建者在Java里每创建一个ThreadJVM都会为它分配独立的虚拟机栈。主线程调用start()启动子线程start()方法本身很快就返回了接下来主线程继续走自己的代码路径。子线程则进入run()方法在它自己的栈帧里执行任务。当子线程的run()方法抛出异常时JVM会做两件事先把异常栈保存在子线程的上下文里然后终止该子线程接着把异常交给线程的dispatchUncaughtException机制而不是返回到主线程的调用栈上。你可以把主线程和子线程想象成两个独立的办公室主线程负责人main打电话叫隔壁办公室的同事子线程去跑腿然后自己继续处理手头的事。隔壁同事路上摔了一跤他只能自己处理或者打电话给后勤UncaughtExceptionHandler而不会直接跑回来把摔跤这件事塞到负责人正在看的文件上。这就是为什么try-catch写在哪里决定了你能捕获到哪个“地点”的异常。1.2 异常真正的去处dispatchUncaughtException链路当子线程的run()方法向上抛出未捕获异常时JVM的线程实现会调用Thread.dispatchUncaughtException(Throwable e)方法它的逻辑是先查当前线程实例是否有通过setUncaughtExceptionHandler设置的处理器如果没有则去线程所属的ThreadGroup调用ThreadGroup.uncaughtExceptionThreadGroup.uncaughtException默认行为是如果存在默认异常处理器Thread.setDefaultUncaughtExceptionHandler设置的则交给它否则就把异常栈打印到System.err。换句话说异常最后是被“分发”给专门的异常处理者而不是被“抛回”给主线程。这条链路上所有处理逻辑都发生在子线程自己的生命周期里。所以想让主线程感知子线程异常正确的思路不是加大try-catch范围而是把异常通过某种机制“传递”回主线程或者干脆在子线程内部就把它处理掉。1.3 为什么这个误解能存活这么多年因为有些例外会让人产生错觉。比如你用Future.get()去获取Callable的执行结果时子线程里的异常会被包装成ExecutionException抛给等待结果的主线程——这个场景看起来就像“主线程捕获了子线程异常”。实际上这只是ExecutorService.submit()把异常塞进了返回的Future对象里而不是异常自己跳回了主线程。搞懂这个区别你就能理解为什么网上很多方案要分开来讲。2. 方案一给子线程装一部“急救电话”——UncaughtExceptionHandler第一种可靠方案是使用Thread.setUncaughtExceptionHandler它解决的是“子线程只管执行不需要与主线程做结果交互但我需要知道它什么时候异常了”的场景。这种方法最直接、侵入最小适合日志记录、监控告警和资源清理。2.1 最基本的用法只挂在一个线程上Thread worker new Thread(() - { throw new IllegalArgumentException(worker执行出错); }); worker.setUncaughtExceptionHandler((t, e) - { System.err.println(线程 t.getName() 发生异常: e.getMessage()); e.printStackTrace(); }); worker.start();这里的UncaughtExceptionHandler是一个函数式接口参数是线程引用和异常对象。当子线程run()抛出未捕获异常时这段逻辑会被执行。注意这个处理器的执行发生在子线程自己的栈里所以你不能在里面调用主线程的东西做一些强依赖主线程状态的操作否则可能因为并发问题带来二次故障。2.2 全局兜底Thread.setDefaultUncaughtExceptionHandler生产环境中线程池内部的线程数量很多你不可能给每个线程单独set一次handler。更实用的做法是在应用启动阶段设置全局默认处理器Thread.setDefaultUncaughtExceptionHandler((t, e) - { log.error(Global uncaught exception in thread [{}], t.getName(), e); // 这里可以接入告警平台、指标上报等 });设置了全局默认处理器之后所有线程包括线程池里的Worker在抛出未捕获异常时只要线程本身没有单独设置handler、且所在ThreadGroup也没有覆盖特殊行为异常就会进入这个统一的处理方法。我在维护一个金融类后台系统时就是用这个办法给所有异步线程加了一道兜底网任何线程猝死都能在日志平台里看到完整堆栈。2.3 容易被忽略的优先级ThreadGroup会先横插一手这里有个细节要特别说明。ThreadGroup本身也实现了UncaughtExceptionHandler接口而dispatchUncaughtException的查找顺序是线程自身的handler 线程所属ThreadGroup的uncaughtException() 全局默认handler。如果你在代码里继承ThreadGroup并覆盖了uncaughtException方法就会发生拦截即使没有调用super.uncaughtException()全局默认处理器也不会生效。实际操作中绝大多数应用不会自定义ThreadGroup所以不会碰到这个问题。但如果你在像Tomcat、Netty这样内嵌了自定义线程池框架的环境里就要留意框架是否自带了一级异常兜底。比如一些框架的Worker线程本身就设置了handler你只设置全局默认处理器可能被覆盖。方案一的适用边界也很清楚它只负责“感知并记录”主线程不会因此得到通知也无法基于异常调整后续逻辑。如果你想拿到异常去做进一步的处理比如重试、中断、回滚事务那就得看后面的方案。3. 方案二Callable Future把异常封装成“回执”拿回主线程如果说方案一是“被动接听”那方案二就是“主动索取结果”。核心思路很简单不直接new Thread().start()而是把任务交给线程池的submit()方法返回一个Future对象然后调用future.get()获取结果。如果子任务抛异常这个异常会被包装成ExecutionException在get()时抛给调用方。这样异常就从子线程“回到”了主线程的调用栈上。3.1 submit和execute的差别是很多人踩坑的起点使用ExecutorService的时候execute(Runnable)和submit(Runnable)行为上有本质差异execute()任务异常会直接抛给线程池的Worker线程由UncaughtExceptionHandler机制处理调用方拿不到任何反馈。submit()任务被包装成FutureTask异常被捕获并存放到Future的字段里线程本身不会崩溃。调用方可以通过future.get()获取异常。很多人在生产代码中用了execute()然后困惑为什么子线程一异常日志里没消息排查半天才发现方向错了。所以如果你希望“拿回异常”第一原则就是用submit()而不是execute()。下面是一个典型用法ExecutorService pool Executors.newFixedThreadPool(2); FutureString future pool.submit(() - { if (System.currentTimeMillis() % 2 0) { throw new RuntimeException(业务数据校验失败); } return ok; }); try { String result future.get(5, TimeUnit.SECONDS); System.out.println(子线程返回结果 result); } catch (InterruptedException e) { System.err.println(主线程等待结果时被中断); Thread.currentThread().interrupt(); } catch (ExecutionException e) { Throwable realCause e.getCause(); System.err.println(子线程真正异常原因 realCause.getMessage()); } catch (TimeoutException e) { future.cancel(true); System.err.println(子线程执行超时已取消); }这里要知道ExecutionException是包装层真实的业务异常要通过e.getCause()获取。我见过有人直接把ExecutionException打日志结果真正的关键信息比如NPE发生在哪一行被埋进了Cause链里排查效率大打折扣。3.2 get()是阻塞的别用它实现“异步优雅”方案二最大的“代价”是future.get()是阻塞的。如果你在业务方法里直接get()其实等于把异步任务重新同步化了。比如一个接口同时调用多个子任务如果每个都用future.get()串行等待整体耗时就会退化成所有子任务耗时之和。正确的姿态是先提交所有子任务再统一get()。甚至可以在等待期间做点不依赖子任务结果的事情。比如像这样FutureString f1 pool.submit(task1); FutureString f2 pool.submit(task2); // 先做一些本地准备逻辑 String localData loadLocalConfig(); // 最后再等待任务结果 String r1 f1.get(); String r2 f2.get();如果任务数量多建议给get()加超时上限比如设置一个整体超时timeout防止某个子线程卡死导致主线程一直挂起。超时后调用future.cancel(true)尝试中断任务并记录告警。3.3 项目里的进一步封装提交后立即在回调里记录异常直接在调用处写try-catchExecutionException虽然可用但如果项目里到处都是这种代码会非常散乱。我习惯封装一个工具方法public class FutureUtils { public static T T getQuietly(FutureT future, long timeout, TimeUnit unit) { try { return future.get(timeout, unit); } catch (InterruptedException e) { Thread.currentThread().interrupt(); throw new BusinessException(任务等待被中断, e); } catch (ExecutionException e) { throw new BusinessException(子任务执行异常, e.getCause()); } catch (TimeoutException e) { future.cancel(true); throw new BusinessException(子任务超时, e); } } }这样业务代码只需要调用FutureUtils.getQuietly(future, 5, TimeUnit.SECONDS)异常信息会保留原始原因同时统一处理了中断和超时。方案二适合那种“主流程依赖子任务结果、必须拿到结果才能继续”的场景它在语义上最符合“主线程捕获子线程异常”的直觉。4. 方案三CompletableFuture与线程池包装异步链路里的异常也能兜住方案二的缺点在于把异步变成了同步等待。如果你的需求是“子线程继续异步跑异常发生时自己有兜底逻辑不需要主线程阻塞等待”那方案三更合适。最理想的做法是用CompletableFuture还能配合自定义线程池把异常处理内嵌到异步链路里。4.1 CompletableFuture的异常回调是天然的兜底通道CompletableFuture.supplyAsync()接收一个Supplier返回一个CompletableFuture对象。子线程异常时CompletableFuture不会直接把异常抛给调用方而是进入completeExceptionally状态。你可以在后续链式方法里使用exceptionally、handle、whenComplete来统一处理ExecutorService pool Executors.newFixedThreadPool(4); CompletableFutureString future CompletableFuture .supplyAsync(() - { if (Math.random() 0.5) { throw new RuntimeException(模拟下游服务异常); } return success; }, pool) .exceptionally(ex - { log.error(异步任务执行失败: {}, ex.getMessage(), ex); return fallback; }); System.out.println(future.join());注意.exceptionally返回一个新的CompletableFuture它会把异常变成正常值这个例子里的fallback。这样主线流程获得的是一个确定的结果不会因为子线程异常而跟着崩掉。4.2 一个关键提醒别默认用ForkJoinPool.commonPool如果supplyAsync不传线程池它会走ForkJoinPool.commonPool()这个池的并行度等于CPU核心数-1。这在开发环境看着没问题到了高并发的生产环境一旦你的异步任务在公共池里排队其他使用commonPool的框架代码也会被拖累。所以凡是公司项目几乎都应该显式传入线程池。而且线程池最好自己new不要用Executors.newFixedThreadPool那种无界队列否则线上会出内存问题。ExecutorService bizPool new ThreadPoolExecutor( 4, 8, 60L, TimeUnit.SECONDS, new ArrayBlockingQueue(200), r - new Thread(r, biz-async- r.hashCode()), new ThreadPoolExecutor.CallerRunsPolicy() );4.3 链式链路中的异常陷阱末尾没人看等于白写使用CompletableFuture时有一个隐形的坑如果一条异步链路很长你在中间某一步抛了异常但这条链路的末端既没有exceptionally也没有handle来兜住那么异常并不会打日志只是一直存续在这个CompletableFuture对象里直到有人调用join()或get()才会暴露。如果那个调用点很晚甚至不存在异常就等于被吞了。更危险的是你写了一段链式调用CompletableFuture.supplyAsync(() - { throw new RuntimeException(boom); }, pool) .thenApply(data - data processed) .thenAccept(System.out::println);这段代码不会抛异常也不会打任何日志任务就这样“静默失败”了。所以我的建议是每个CompletableFuture链路的最后一个节点必须挂一个兜底回调哪怕是whenComplete((r, ex) - { if (ex ! null) log.error(...); })。这一条看着简单能救你很多次。4.4 用包装任务统一线程池异常处理是生产环境的最终手段除了CompletableFuture本身的异常回调我还会在线程池这一层给所有的任务加上一个“统一异常拦截包装器”。思路是实现自己的Runnable包装类然后在beforeExecute、afterExecute或者ThreadFactory里做手脚。其中实用性最强的是在afterExecute里补获异常。但这里有一个细节问题afterExecute拿到的Runnable r如果是通过submit()进来的会被包装成FutureTask真正的任务异常存在FutureTask内部afterExecute里是拿不到的。因为FutureTask.run()会捕获异常而不是让异常穿透出来。为了解决这个问题我的做法是装饰Runnable和Callable在执行前记录任务标识在执行后立即检查是否出现异常或者干脆用ThreadPoolExecutor的子类覆盖afterExecute针对提交进来的Callable做处理。下面是一个相对完整的自定义线程池包装示例public class ExceptionAwareThreadPoolExecutor extends ThreadPoolExecutor { public ExceptionAwareThreadPoolExecutor(int core, int max, long keepAlive, TimeUnit unit, BlockingQueueRunnable workQueue) { super(core, max, keepAlive, unit, workQueue); } Override protected void afterExecute(Runnable r, Throwable t) { super.afterExecute(r, t); // 如果使用 execute 提交运行时异常会出现在 t 中 if (t ! null) { log.error(Task executed with exception, task{}, r, t); return; } // 如果使用 submit 提交异常被 FutureTask 吞掉需取出 if (r instanceof FutureTask?) { try { ((FutureTask?) r).get(); // 这里能拿到 ExecutionException } catch (CancellationException ce) { // 任务被取消业务上通常忽略 } catch (ExecutionException ee) { log.error(Task submitted by submit() failed, ee.getCause()); } catch (InterruptedException ie) { Thread.currentThread().interrupt(); } } } }写这个类的过程中我踩过一个坑在afterExecute里调用FutureTask.get()会阻塞当前线程因为正常情况下此时任务已经执行完所以get()能立即返回不会真的阻塞。但如果你在任务里递归提交了新的任务就可能出现一些意外。所以真实项目里我也建议给afterExecute里的get()加一个极短的超时比如100毫秒。这套组合下来线程池里的所有异常就有了三层保障全局默认UncaughtExceptionHandler兜底、CompletableFuture回调兜底、线程池afterExecute兜底。其中afterExecute这种包装方式最贴合生产环境便于统一做指标埋点、日志记录和告警上报。5. 异常被静默吞掉的隐蔽场景以及我总结的排查技巧聊到这里主线程捕获子线程异常的三类核心方案都讲完了。但我想额外花一整节来说明“异常是怎么被吞掉的”因为很多人即使用了上述方案还是会在某些特殊场景下丢失异常。5.1 execute(Runnable)配合默认UncaughtExceptionHandler的盲区如果你用的是ExecutorService.execute(Runnable)任务异常会交给线程所属的ThreadGroup.uncaughtException。如果在开发环境没有单独设置uncaughtExceptionHandler日志会打到控制台或者stderr生产环境一旦日志采集没接住stderr看起来就是“啥也没发生”。更麻烦的是如果你使用某些框架的线程池线程对象可能已经被框架替换全局handler不一定会生效。排查建议不要依赖“控制台有没有报错”去查线程池的afterExecute和线程工厂。5.2 lambda表达式里的异常被catch住后假装无事发生另一个非常常见的吞异常场景是开发者自己在子线程内部用了catch(Exception e) { }空catch块。这种代码在代码评审里几乎查不出来但它会让一切外层机制失效——包括UncaughtExceptionHandler和Future.get()因为异常根本没朝外抛。我在老项目里见过一批这样的代码配合大量log.warn打了前缀但没传异常对象导致线上问题完全无法定位。排查建议代码扫描工具直接禁用空catch日志规范里强制要求catch时必须打完整堆栈。5.3 子线程状态与主线程状态之间的可见性问题严格说这不是“吞异常”但是会让人误解。子线程异常后如果你在主线程里轮询某个共享变量来判断成功与否由于没有做volatile修饰或加锁主线程可能看到旧值误以为子线程还在运行。这种问题在线程池任务里特别隐蔽子线程抛异常退出后共享状态没有更新主线程一直在等超时才报错。其实异常处理链路是通的只是“状态传递”这环断了。排查建议不要手工维护共享状态来表示线程执行结果用Future、CountDownLatch或CompletableFuture这类现成组件。5.4 日志与TraceId的贯穿最后一个生产经验主线程捕获异常后打日志往往会丢失原线程的TraceId链路追踪ID。因为子线程是新的执行单元主线程的MDC上下文不会自动带过去。解决办法是在提交任务时把父线程的TraceId传入子线程或者在ThreadFactory装饰器里设置MDC。这个细节我吃过亏没有TraceId异步任务出异常后想从日志平台反查整个调用链几乎是不可能的事。我的做法是在项目里封装一个通用的异步任务提交入口内部统一做四件事设置线程名、传递MDC、统计执行时长、捕获异常并上报指标。这样业务代码只需要调用这个入口就不会再出现“异常不知道去哪了”的情况。public class AsyncTaskRunner { public static void submit(ExecutorService pool, String taskName, Runnable task) { MapString, String context MDC.getCopyOfContextMap(); pool.submit(() - { if (context ! null) { MDC.setContextMap(context); } long start System.currentTimeMillis(); try { task.run(); } catch (Throwable t) { log.error(Async task [{}] failed, cost{}ms, taskName, System.currentTimeMillis() - start, t); // 这里可以接入告警或监控计数 } finally { MDC.clear(); } }); } }这个类看上去简单但它解决了异步异常场景里80%的痛点命名、链路、日志、监控一次全做了。如果你现在还没把多线程异常治理收口到统一入口建议从改造这一类工具开始。多线程的异常处理本质上不是“能不能捕获”的问题而是“你希望什么时候、在哪一层感知到异常并用什么方式处理它”。方案一的UncaughtExceptionHandler适合纯记录型任务方案二的Future适合需要结果回传的同步等待场景方案三的CompletableFuture和线程池包装适合完全异步且需要自我兜底的链路。把握好这三条思路再来写业务代码心里会踏实很多。
返回列表