【Java SE】异常链

先看一个真实的调用链

假设你有一个转账功能,调用关系是这样的:

复制代码
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 的构造器,必须先 newinitCause。现在写新代码直接用构造器就好。

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-resourcesaddSuppressed() 机制(见下文 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 时出的那些异常
    }
}

causesuppressed 的区别:

机制 关系 典型场景
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,成了肌肉记忆。


总结

三个习惯,从今天开始:

  1. 自己的异常类,永远写上带 cause 的构造器。 就两行代码,不写等于给未来的自己埋雷。
  2. catch 之后包装异常,永远把原始异常当 cause 传进去。 不传 cause 和直接吞异常没有本质区别。
  3. cause 和 suppressed 各管各的。 cause 是纵向追溯"根因",suppressed 是横向记录"顺便发生的副作用"。

一句话:让排查者沿着 getCause() 能一路走到最底层的根因,中间一步也别断。