【Java SE】异常链
先看一个真实的调用链
假设你有一个转账功能,调用关系是这样的:
Controller(接收请求) └─ 调用 Service.transfer() └─ 调用 AccountDao.deduct() ← 这里抛出了 SQLException三层代码用最简化的写法:
// ========== DAO 层 ==========publicclassAccountDao{publicvoiddeduct(LongaccountId,BigDecimalamount)throwsSQLException{Connectionconn=dataSource.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()不想把数据库细节暴露给 Controller(Controller 不应该知道底层用的是 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 的版本(也要留着,不是每次包装都需要 cause)publicBizException(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){Throwablecause=e.getCause();// 拿到 SQLExceptionThrowablerootCause=getRootCause(e);// 一路追溯到最后(需要自己写循环)}Java 标准库没有提供getRootCause()。自己写很简单:
publicstaticThrowablegetRootCause(Throwablet){Throwablecause=t.getCause();if(cause==null){returnt;// 已经是最底层了}returngetRootCause(cause);// 继续往下追}什么时候该用、什么时候不该用
✅ 该用的三种场景
场景一:跨层包装。上面转账的例子就是。DAO → Service → Controller,每层把下层异常包装成本层语义合适的异常,但通过 cause 保留追溯能力。
SQLException(DAO 层) └─ cause ─→ BizException(Service 层) └─ cause ─→ 无(BizException 就是最外层)一条链上可以有多个环节:
SQLException → DataAccessException → ServiceException → 最终捕获 根因 中间包装 中间包装 最外层每一层都是new 本层异常(msg, 下层异常),这样无论从哪一层拆包,都能摸到最终的 SQLException。
场景二:JDK 自己已经帮你链好了。典型例子——Future.get():
ExecutorServicepool=Executors.newSingleThreadExecutor();Future<?>future=pool.submit(()->{thrownewSQLException("表不存在");// ← 任务内部抛出的异常});try{future.get();// ← 这里阻塞等待结果}catch(ExecutionExceptione){// e 是 ExecutionException,但根本原因在 cause 里Throwablereal=e.getCause();// → SQLException("表不存在")}调用关系:你的代码 →Future.get()→ 线程池里的任务。任务抛的异常被Future自动包装成ExecutionException,原始异常放在 cause 里。Spring 的@Async也是同一套机制。
场景三:反射调用。Method.invoke()也一样:
Methodmethod=obj.getClass().getMethod("dangerousMethod");try{method.invoke(obj);// ← 反射调用}catch(InvocationTargetExceptione){Throwablereal=e.getCause();// ← 真正被 dangerousMethod 抛出的异常}JDK 用InvocationTargetException这一层壳把反射机制和业务异常隔开,cause 就是被调方法的真实异常。
❌ 不该用的三种写法
错误一:链入自己。这会形成一个环:
SQLExceptione=newSQLException("超时");thrownewBizException(e).initCause(e);// ❌ BizException 的 cause 指向自己// ↑ 注意:new BizException(Throwable) 已经把 e 设成 cause 了// initCause(e) 又想设一次,会抛 IllegalStateException正确的写法是:要么构造器传进去,要么initCause设一次,永远不要两个都做、更不要链自己。
错误二:吞掉原始异常。这就是开头那个反面教材。不传 cause 等于白 catch:
catch(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){StringWritersw=newStringWriter();e.printStackTrace(newPrintWriter(sw));thrownewRuntimeException("数据库错误: "+sw.toString());// 把整个堆栈塞进字符串里 —— 又丑又慢}JDK 1.4 —— 异常链正式诞生
新增initCause()方法和带 cause 的构造器。printStackTrace()开始自动打印Caused by:链。不过像ClassNotFoundException这种老异常还没来得及补构造器。
JDK 7 —— 解决"兄弟异常"问题
try-with-resources引入了一个相邻概念:
try(Connectionconn=dataSource.getConnection();PreparedStatementstmt=conn.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 包成 BizException |
suppressed | “我出事了,走之前你还闹了一下” | 业务 SQL 抛异常 → 关闭连接时 close() 也抛了 |
JDK 8 —— lambda 让异常链无处不在
Stream 和 lambda 里不能直接抛受检异常,只能用运行时异常包装:
List<User>users=ids.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()能一路走到最底层的根因,中间一步也别断。
