先看一个真实的调用链
假设你有一个转账功能,调用关系是这样的:
Controller(接收请求)
└─ 调用 Service.transfer()
└─ 调用 AccountDao.deduct() ← 这里抛出了 SQLException
三层代码用最简化的写法:
java
// ========== DAO 层 ==========
public class AccountDao {
public void deduct(Long accountId, BigDecimal amount) throws SQLException {
Connection conn = dataSource.getConnection(); // ← 这里连接超时!
// ... 执行 SQL
}
}
// ========== Service 层 ==========
public class AccountService {
private AccountDao accountDao;
public void transfer(Long from, Long to, BigDecimal amount) {
accountDao.deduct(from, amount); // ← 调用 DAO,可能抛 SQLException
accountDao.credit(to, amount);
}
}
// ========== Controller 层 ==========
public class AccountController {
private AccountService accountService;
public void handleRequest() {
accountService.transfer(1L, 2L, new BigDecimal("100"));
}
}
问题来了:deduct() 声明了 throws SQLException,但 transfer() 不想把数据库细节暴露给 Controller(Controller 不应该知道底层用的是 MySQL 还是 Redis)。于是 Service 层需要把 SQLException 包装成自己的业务异常再往上抛。
没有异常链:根本原因丢了
最粗暴的做法------直接 new 一个新异常,不管原来的:
java
// Service 层
public void transfer(Long from, Long to, BigDecimal amount) {
try {
accountDao.deduct(from, amount);
} catch (SQLException e) {
throw new BizException("转账扣款失败"); // ❌ 没传 e!
}
}
这时 Controller 层 catch 到的 BizException 长这样:
BizException: 转账扣款失败
at AccountService.transfer(AccountService.java:15)
at AccountController.handleRequest(AccountController.java:8)
SQLException 彻底消失了。 你不知道是连接超时、死锁还是语法错误。线上排障只能靠猜。
这就是异常链要解决的问题。
有异常链:每一层的线索都保留
改一行代码------在 BizException 构造时把原始异常传进去:
java
// Service 层
public void transfer(Long from, Long to, BigDecimal amount) {
try {
accountDao.deduct(from, amount);
} catch (SQLException e) {
throw new BizException("转账扣款失败", 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 more
Caused 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 相关:
java
public Throwable(String message, Throwable cause); // 有消息 + 有原因
public Throwable(Throwable cause); // 只有原因,消息为空
你自己的异常类应该把这两个构造器声明出来:
java
public class BizException extends RuntimeException {
// 不带 cause 的版本(也要留着,不是每次包装都需要 cause)
public BizException(String message) {
super(message);
}
// 带 cause 的版本 ------ 这个是异常链的关键
public BizException(String message, Throwable cause) {
super(message, cause); // ← 调父类构造器,cause 就这样存进去了
}
}
用的时候就是一行:
java
throw new BizException("订单创建失败", e);
2. initCause(兜底方案,几乎不用)
java
public synchronized Throwable initCause(Throwable cause);
如果你的异常类是在构造之后才拿到 cause,可以用这个方法后补。但只能调一次------因为一个异常只有一个根因,多次设置没有语义。重复调用会抛 IllegalStateException。
它的真实用途是兼容 JDK 1.3 时代的老代码:那时候 Throwable 还没有带 cause 的构造器,必须先 new 再 initCause。现在写新代码直接用构造器就好。
3. getCause(取值)
java
public Throwable getCause();
从外层异常拿到内层原因:
java
catch (BizException e) {
Throwable cause = e.getCause(); // 拿到 SQLException
Throwable rootCause = getRootCause(e); // 一路追溯到最后(需要自己写循环)
}
Java 标准库没有提供 getRootCause()。自己写很简单:
java
public static Throwable getRootCause(Throwable t) {
Throwable cause = t.getCause();
if (cause == null) {
return t; // 已经是最底层了
}
return getRootCause(cause); // 继续往下追
}
什么时候该用、什么时候不该用
✅ 该用的三种场景
场景一:跨层包装。 上面转账的例子就是。DAO → Service → Controller,每层把下层异常包装成本层语义合适的异常,但通过 cause 保留追溯能力。
SQLException(DAO 层)
└─ cause ─→ BizException(Service 层)
└─ cause ─→ 无(BizException 就是最外层)
一条链上可以有多个环节:
SQLException → DataAccessException → ServiceException → 最终捕获
根因 中间包装 中间包装 最外层
每一层都是 new 本层异常(msg, 下层异常),这样无论从哪一层拆包,都能摸到最终的 SQLException。
场景二:JDK 自己已经帮你链好了。 典型例子------Future.get():
java
ExecutorService pool = Executors.newSingleThreadExecutor();
Future<?> future = pool.submit(() -> {
throw new SQLException("表不存在"); // ← 任务内部抛出的异常
});
try {
future.get(); // ← 这里阻塞等待结果
} catch (ExecutionException e) {
// e 是 ExecutionException,但根本原因在 cause 里
Throwable real = e.getCause(); // → SQLException("表不存在")
}
调用关系:你的代码 → Future.get() → 线程池里的任务。任务抛的异常被 Future 自动包装成 ExecutionException,原始异常放在 cause 里。Spring 的 @Async 也是同一套机制。
场景三:反射调用。 Method.invoke() 也一样:
java
Method method = obj.getClass().getMethod("dangerousMethod");
try {
method.invoke(obj); // ← 反射调用
} catch (InvocationTargetException e) {
Throwable real = e.getCause(); // ← 真正被 dangerousMethod 抛出的异常
}
JDK 用 InvocationTargetException 这一层壳把反射机制和业务异常隔开,cause 就是被调方法的真实异常。
❌ 不该用的三种写法
错误一:链入自己。 这会形成一个环:
java
SQLException e = new SQLException("超时");
throw new BizException(e).initCause(e); // ❌ BizException 的 cause 指向自己
// ↑ 注意:new BizException(Throwable) 已经把 e 设成 cause 了
// initCause(e) 又想设一次,会抛 IllegalStateException
正确的写法是:要么构造器传进去,要么 initCause 设一次,永远不要两个都做、更不要链自己。
错误二:吞掉原始异常。 这就是开头那个反面教材。不传 cause 等于白 catch:
java
catch (SQLException e) {
throw new BizException("数据库出错"); // ❌ e 被扔掉了
}
错误三:把不相干的异常拧在一起。 catch 块里调另一个方法又抛了异常,这个新异常和你正在处理的异常没有因果关系,不该链:
java
catch (IOException e) {
DBLogUtil.recordFailure("文件读失败"); // 这个方法可能也抛异常
throw new BizException("文件处理失败", e); // ❌ 如果 recordFailure 也抛了,它被覆盖了
}
这种情况下,recordFailure 的异常和 IOException 是互不相干的两件事 ,应该各自独立处理,或者用 try-with-resources 的 addSuppressed() 机制(见下文 JDK 7 的部分)。
版本演进:JDK 一路补全的过程
JDK 1.3 及以前 ------ 没有异常链
想包装异常?只能在 message 里手工拼接:
java
catch (SQLException e) {
StringWriter sw = new StringWriter();
e.printStackTrace(new PrintWriter(sw));
throw new RuntimeException("数据库错误: " + sw.toString());
// 把整个堆栈塞进字符串里 ------ 又丑又慢
}
JDK 1.4 ------ 异常链正式诞生
新增 initCause() 方法和带 cause 的构造器。printStackTrace() 开始自动打印 Caused by: 链。不过像 ClassNotFoundException 这种老异常还没来得及补构造器。
JDK 7 ------ 解决"兄弟异常"问题
try-with-resources 引入了一个相邻概念:
java
try (Connection conn = dataSource.getConnection();
PreparedStatement stmt = conn.prepareStatement(sql)) {
stmt.execute(); // ← 假设这里抛了 SQLException
} // 自动关闭 conn 和 stmt 时,close() 也可能抛异常
两个异常同时发生怎么办?------ execute 的异常是"主要的",close 的异常是"顺便记录的"。JDK 7 的做法:
java
// try-with-resources 自动生成的等价代码:
// execute 的 SQLException → 作为主异常抛出
// close 的 SQLException → 通过 addSuppressed() 附加到主异常上
catch (SQLException mainEx) {
for (Throwable suppressed : mainEx.getSuppressed()) {
// suppressed 就是 close 时出的那些异常
}
}
cause 和 suppressed 的区别:
| 机制 | 关系 | 典型场景 |
|---|---|---|
cause |
"因为你,才有了我" | DAO 抛 SQLException → Service 包成 BizException |
suppressed |
"我出事了,走之前你还闹了一下" | 业务 SQL 抛异常 → 关闭连接时 close() 也抛了 |
JDK 8 ------ lambda 让异常链无处不在
Stream 和 lambda 里不能直接抛受检异常,只能用运行时异常包装:
java
List<User> users = ids.stream()
.map(id -> {
try {
return userDao.findById(id); // 抛 SQLException
} catch (SQLException e) {
throw new UncheckedSQLException(e); // ← cause 必须链上
}
})
.collect(toList());
lambda 时代,抛异常 → 包装 → 链 cause,成了肌肉记忆。
总结
三个习惯,从今天开始:
- 自己的异常类,永远写上带 cause 的构造器。 就两行代码,不写等于给未来的自己埋雷。
- catch 之后包装异常,永远把原始异常当 cause 传进去。 不传 cause 和直接吞异常没有本质区别。
- cause 和 suppressed 各管各的。 cause 是纵向追溯"根因",suppressed 是横向记录"顺便发生的副作用"。
一句话:让排查者沿着 getCause() 能一路走到最底层的根因,中间一步也别断。