【Java SE】异常链 📅 2026/6/28 2:35:34 先看一个真实的调用链假设你有一个转账功能调用关系是这样的Controller接收请求 └─ 调用 Service.transfer() └─ 调用 AccountDao.deduct() ← 这里抛出了 SQLException三层代码用最简化的写法// DAO 层 publicclassAccountDao{publicvoiddeduct(LongaccountId,BigDecimalamount)throwsSQLException{ConnectionconndataSource.getConnection();// ← 这里连接超时// ... 执行 SQL}}// Service 层 publicclassAccountService{privateAccountDaoaccountDao;publicvoidtransfer(Longfrom,Longto,BigDecimalamount){accountDao.deduct(from,amount);// ← 调用 DAO可能抛 SQLExceptionaccountDao.credit(to,amount);}}// Controller 层 publicclassAccountController{privateAccountServiceaccountService;publicvoidhandleRequest(){accountService.transfer(1L,2L,newBigDecimal(100));}}问题来了deduct()声明了throws SQLException但transfer()不想把数据库细节暴露给 ControllerController 不应该知道底层用的是 MySQL 还是 Redis。于是 Service 层需要把 SQLException 包装成自己的业务异常再往上抛。没有异常链根本原因丢了最粗暴的做法——直接 new 一个新异常不管原来的// Service 层publicvoidtransfer(Longfrom,Longto,BigDecimalamount){try{accountDao.deduct(from,amount);}catch(SQLExceptione){thrownewBizException(转账扣款失败);// ❌ 没传 e}}这时 Controller 层 catch 到的BizException长这样BizException: 转账扣款失败 at AccountService.transfer(AccountService.java:15) at AccountController.handleRequest(AccountController.java:8)SQLException 彻底消失了。你不知道是连接超时、死锁还是语法错误。线上排障只能靠猜。这就是异常链要解决的问题。有异常链每一层的线索都保留改一行代码——在BizException构造时把原始异常传进去// Service 层publicvoidtransfer(Longfrom,Longto,BigDecimalamount){try{accountDao.deduct(from,amount);}catch(SQLExceptione){thrownewBizException(转账扣款失败,e);// ✅ e 作为 cause 传入}}同一个错误现在 Controller 拿到的堆栈变成了BizException: 转账扣款失败 at AccountService.transfer(AccountService.java:15) at AccountController.handleRequest(AccountController.java:8) Caused by: java.sql.SQLException: Connection timed out ← 根因在这里 at com.mysql.jdbc.ConnectionImpl.connect(...) at AccountDao.deduct(AccountDao.java:10) at AccountService.transfer(AccountService.java:13) ... 1 moreCaused by:这段就是异常链的产物。它告诉你BizException 之所以发生是因为 SQLException——连接超时了。排查方向立刻明确。注意这个例子只有两层getCause()正好一步到底。如果异常链超过两层——比如中间还夹了一层DataAccessException——那getCause()只能挖到下一层再深的还得继续调getCause()往下追。后面会讲怎么封装getRootCause()一步到位。异常链的工作原理一张图讲清楚Throwable内部有一个cause字段类型就是Throwable自己new BizException(扣款失败, sqlEx) │ │ ▼ ▼ BizException ← cause 字段指向 → SQLException (外层包装异常) (根本原因)方法调用和异常传递的完整流程Controller.handleRequest() │ ├─ 调用 Service.transfer() │ │ │ ├─ 调用 AccountDao.deduct() │ │ │ │ │ └─ 抛出新 SQLException(Connection timed out) │ │ │ │ │ ▼ 异常沿着调用栈向下调用方方向弹出 │ │ │ ├─ catch 捕获到 SQLException │ │ │ │ │ └─ new BizException(扣款失败, sqlEx) ← 把 sqlEx 设为 cause │ │ │ │ │ ▼ 抛出 BizException继续向上弹 │ │ │ └─ 异常已从 Dao 层传到 Service 层并完成包装 │ ├─ catch 捕获到 BizException │ │ │ ├─ e.getMessage() → 转账扣款失败 │ ├─ e.getCause() → 回到那个 SQLException │ └─ e.printStackTrace() → 打印完整链条含 Caused by: │ └─ Controller 拿到全部信息记录日志或返回错误关键点异常是向上弹的但 cause 是向下指的。外层的BizException“果”通过 cause 引用内层的SQLException“因”形成一条从外到内的追溯链。Throwable 怎么实现这个机制三个方法1. 构造器最常用Throwable提供了四个构造器其中两个和 cause 相关publicThrowable(Stringmessage,Throwablecause);// 有消息 有原因publicThrowable(Throwablecause);// 只有原因消息为空你自己的异常类应该把这两个构造器声明出来publicclassBizExceptionextendsRuntimeException{// 不带 cause 的版本也要留着不是每次包装都需要 causepublicBizException(Stringmessage){super(message);}// 带 cause 的版本 —— 这个是异常链的关键publicBizException(Stringmessage,Throwablecause){super(message,cause);// ← 调父类构造器cause 就这样存进去了}}用的时候就是一行thrownewBizException(订单创建失败,e);2. initCause兜底方案几乎不用publicsynchronizedThrowableinitCause(Throwablecause);如果你的异常类是在构造之后才拿到 cause可以用这个方法后补。但只能调一次——因为一个异常只有一个根因多次设置没有语义。重复调用会抛IllegalStateException。它的真实用途是兼容 JDK 1.3 时代的老代码那时候Throwable还没有带 cause 的构造器必须先new再initCause。现在写新代码直接用构造器就好。3. getCause取值publicThrowablegetCause();从外层异常拿到内层原因catch(BizExceptione){Throwablecausee.getCause();// 拿到 SQLExceptionThrowablerootCausegetRootCause(e);// 一路追溯到最后需要自己写循环}Java 标准库没有提供getRootCause()。自己写很简单publicstaticThrowablegetRootCause(Throwablet){Throwablecauset.getCause();if(causenull){returnt;// 已经是最底层了}returngetRootCause(cause);// 继续往下追}什么时候该用、什么时候不该用✅ 该用的三种场景场景一跨层包装。上面转账的例子就是。DAO → Service → Controller每层把下层异常包装成本层语义合适的异常但通过 cause 保留追溯能力。SQLExceptionDAO 层 └─ cause ─→ BizExceptionService 层 └─ cause ─→ 无BizException 就是最外层一条链上可以有多个环节SQLException → DataAccessException → ServiceException → 最终捕获 根因 中间包装 中间包装 最外层每一层都是new 本层异常(msg, 下层异常)这样无论从哪一层拆包都能摸到最终的 SQLException。场景二JDK 自己已经帮你链好了。典型例子——Future.get()ExecutorServicepoolExecutors.newSingleThreadExecutor();Future?futurepool.submit(()-{thrownewSQLException(表不存在);// ← 任务内部抛出的异常});try{future.get();// ← 这里阻塞等待结果}catch(ExecutionExceptione){// e 是 ExecutionException但根本原因在 cause 里Throwablereale.getCause();// → SQLException(表不存在)}调用关系你的代码 →Future.get()→ 线程池里的任务。任务抛的异常被Future自动包装成ExecutionException原始异常放在 cause 里。Spring 的Async也是同一套机制。场景三反射调用。Method.invoke()也一样Methodmethodobj.getClass().getMethod(dangerousMethod);try{method.invoke(obj);// ← 反射调用}catch(InvocationTargetExceptione){Throwablereale.getCause();// ← 真正被 dangerousMethod 抛出的异常}JDK 用InvocationTargetException这一层壳把反射机制和业务异常隔开cause 就是被调方法的真实异常。❌ 不该用的三种写法错误一链入自己。这会形成一个环SQLExceptionenewSQLException(超时);thrownewBizException(e).initCause(e);// ❌ BizException 的 cause 指向自己// ↑ 注意new BizException(Throwable) 已经把 e 设成 cause 了// initCause(e) 又想设一次会抛 IllegalStateException正确的写法是要么构造器传进去要么initCause设一次永远不要两个都做、更不要链自己。错误二吞掉原始异常。这就是开头那个反面教材。不传 cause 等于白 catchcatch(SQLExceptione){thrownewBizException(数据库出错);// ❌ e 被扔掉了}错误三把不相干的异常拧在一起。catch 块里调另一个方法又抛了异常这个新异常和你正在处理的异常没有因果关系不该链catch(IOExceptione){DBLogUtil.recordFailure(文件读失败);// 这个方法可能也抛异常thrownewBizException(文件处理失败,e);// ❌ 如果 recordFailure 也抛了它被覆盖了}这种情况下recordFailure的异常和 IOException 是互不相干的两件事应该各自独立处理或者用try-with-resources的addSuppressed()机制见下文 JDK 7 的部分。版本演进JDK 一路补全的过程JDK 1.3 及以前 —— 没有异常链想包装异常只能在 message 里手工拼接catch(SQLExceptione){StringWriterswnewStringWriter();e.printStackTrace(newPrintWriter(sw));thrownewRuntimeException(数据库错误: sw.toString());// 把整个堆栈塞进字符串里 —— 又丑又慢}JDK 1.4 —— 异常链正式诞生新增initCause()方法和带 cause 的构造器。printStackTrace()开始自动打印Caused by:链。不过像ClassNotFoundException这种老异常还没来得及补构造器。JDK 7 —— 解决兄弟异常问题try-with-resources引入了一个相邻概念try(ConnectionconndataSource.getConnection();PreparedStatementstmtconn.prepareStatement(sql)){stmt.execute();// ← 假设这里抛了 SQLException}// 自动关闭 conn 和 stmt 时close() 也可能抛异常两个异常同时发生怎么办—— execute 的异常是主要的close 的异常是顺便记录的。JDK 7 的做法// try-with-resources 自动生成的等价代码// execute 的 SQLException → 作为主异常抛出// close 的 SQLException → 通过 addSuppressed() 附加到主异常上catch(SQLExceptionmainEx){for(Throwablesuppressed:mainEx.getSuppressed()){// suppressed 就是 close 时出的那些异常}}cause和suppressed的区别机制关系典型场景cause“因为你才有了我”DAO 抛 SQLException → Service 包成 BizExceptionsuppressed“我出事了走之前你还闹了一下”业务 SQL 抛异常 → 关闭连接时 close() 也抛了JDK 8 —— lambda 让异常链无处不在Stream 和 lambda 里不能直接抛受检异常只能用运行时异常包装ListUserusersids.stream().map(id-{try{returnuserDao.findById(id);// 抛 SQLException}catch(SQLExceptione){thrownewUncheckedSQLException(e);// ← cause 必须链上}}).collect(toList());lambda 时代抛异常 → 包装 → 链 cause成了肌肉记忆。总结三个习惯从今天开始自己的异常类永远写上带 cause 的构造器。就两行代码不写等于给未来的自己埋雷。catch 之后包装异常永远把原始异常当 cause 传进去。不传 cause 和直接吞异常没有本质区别。cause 和 suppressed 各管各的。cause 是纵向追溯根因suppressed 是横向记录顺便发生的副作用。一句话让排查者沿着getCause()能一路走到最底层的根因中间一步也别断。