什么是异常链?如何自定义异常?异常处理有哪些最佳实践?
简化版
异常链(Exception Chaining) 是「捕获一个异常后,抛出一个新异常,同时把原异常作为『原因(cause)』附上」——这样既能抛出更贴合业务的异常,又不丢失底层异常的堆栈信息,排查时能看到完整的「因果链」。实现靠 new BusinessException("下单失败", e)(把原异常 e 作为构造参数)或 initCause(e)。自定义异常通常继承 RuntimeException(非受检,更常用)或 Exception(受检,强制处理),提供带 message 和 cause 的构造器。异常处理最佳实践:① 别吞异常(空 catch);② 抛异常要带上下文和原始 cause(保留异常链);③ 别用异常控制正常流程(异常开销大);④ 精确捕获(别一律 catch Exception);⑤ 早抛出、晚捕获(在能处理的层统一处理);⑥ 资源用 try-with-resources。
详细版
异常链:保留原因:
// ✗ 差:丢失原始异常,只知道"下单失败",不知道底层为什么失败
try {
dao.insert(order);
} catch (SQLException e) {
throw new BusinessException("下单失败"); // 原始的 SQLException 堆栈丢了!
}
// ✓ 好:把原异常作为 cause 附上,保留完整因果链
try {
dao.insert(order);
} catch (SQLException e) {
throw new BusinessException("下单失败", e); // e 作为 cause,堆栈保留
// 日志里会打印:BusinessException: 下单失败
// Caused by: SQLException: ...(原始异常的完整堆栈)
}
自定义异常:
// 继承 RuntimeException(非受检,更常用)
public class BusinessException extends RuntimeException {
private final int code; // 可加业务错误码
public BusinessException(String message) { super(message); }
public BusinessException(String message, Throwable cause) { // ★ 带 cause 的构造器(异常链)
super(message, cause);
}
public BusinessException(int code, String message) {
super(message);
this.code = code;
}
public int getCode() { return code; }
}
异常处理最佳实践清单:
| 实践 | 反例 | 正确做法 |
|---|---|---|
| 别吞异常 | catch (Exception e) {} 空 catch | 至少 log,或抛出 |
| 保留异常链 | throw new Xxx("msg") 丢 cause | throw new Xxx("msg", e) |
| 别用异常控流程 | 用异常判断循环结束 | 用正常的条件判断 |
| 精确捕获 | 一律 catch Exception | catch 具体异常类型 |
| 别只打 message | log.error(e.getMessage()) | log.error("msg", e) 打完整堆栈 |
| 资源释放 | 手写 finally 关资源 | try-with-resources |
⚠️ 异常链最容易被破坏的地方是「重新抛出异常时忘了传 cause」——
catch (SQLException e) { throw new BusinessException("失败"); }这样写,原始的 SQLException 堆栈就彻底丢失了,线上排查时只看到「失败」却不知道底层是「数据库连接超时」还是「唯一键冲突」。正确做法是始终把捕获的异常作为 cause 传给新异常(new BusinessException("失败", e)),保留完整的「Caused by」链条。
完整版教学
一、异常链解决什么:既转换又不丢信息
实际开发中经常需要「捕获底层异常,转换成业务异常再抛出」——比如 DAO 层抛 SQLException,你想在 Service 层转成 BusinessException(更贴合业务、上层好处理)。但转换时有个矛盾:
矛盾:既想"抛出新的业务异常"(对上层友好),又想"保留原始异常信息"(便于排查)
只抛新异常:上层看到 BusinessException("下单失败") —— 但不知道底层为什么失败
只抛原异常:上层看到 SQLException —— 但暴露了底层细节、不够业务化
异常链的解法:抛新异常 + 把原异常作为"cause(原因)"附上
throw new BusinessException("下单失败", sqlException)
→ 上层看到业务异常 BusinessException(友好)
→ 但异常里带着原始的 SQLException(排查时能看到完整因果)
异常链的价值是「两全其美」——既能抛出「合适层次的异常」(业务化、屏蔽底层细节),又「不丢失原始的堆栈信息」(排查时能追溯到根因)。它通过「新异常持有 cause(原异常)」的方式,把「多层异常」串成一条「因果链」。日志打印时会显示 Caused by: 一层层追溯到最底层的根异常。理解「异常链 = 转换异常时保留原因、两全其美」,就理解了它为什么是异常处理的核心实践——没有它,异常转换就会丢失根因、排查困难。
二、异常链的实现:cause 参数
异常链的实现很简单——Throwable 有一个 cause 字段,通过带 cause 的构造器或 initCause() 设置:
// 方式1:带 cause 的构造器(推荐)
throw new BusinessException("下单失败", sqlException); // sqlException 作为 cause
// 方式2:initCause(当异常没有带 cause 的构造器时)
BusinessException be = new BusinessException("下单失败");
be.initCause(sqlException);
throw be;
// Throwable 的构造器(所有异常都继承自它):
// Throwable(String message, Throwable cause) ← 支持 cause
// Throwable(Throwable cause)
打印时的效果:
BusinessException: 下单失败
at com.xx.OrderService.create(OrderService.java:25)
...
Caused by: java.sql.SQLException: Connection timeout ← 原始根因,完整堆栈
at com.xx.OrderDao.insert(OrderDao.java:40)
...
关键机制:Caused by: 就是异常链的体现——JVM 打印异常时会递归打印 cause,形成「新异常 → Caused by 原异常 → Caused by 更底层异常」的链条,一直追到根因。所以只要「抛新异常时传了 cause」,排查时就能顺着 Caused by 找到最底层的真正原因。理解「异常链靠 cause 字段实现、打印时显示 Caused by」,就掌握了异常链的技术细节——核心就是「别忘了传 cause」。
三、自定义异常:继承哪个、加什么
自定义异常是把「业务错误」表达成一个专门的异常类,关键是继承哪个基类和提供哪些构造器:
public class BusinessException extends RuntimeException { // 继承 RuntimeException
private final int code; // 业务错误码(可选)
public BusinessException(String message) { super(message); }
public BusinessException(String message, Throwable cause) { super(message, cause); } // ★必须
public BusinessException(int code, String message) { super(message); this.code = code; }
public int getCode() { return code; }
}
设计要点:① 继承 RuntimeException 还是 Exception——大多数业务异常继承 RuntimeException(非受检,不强制 try-catch,用起来灵活,Spring 事务默认对 RuntimeException 回滚);只有「调用方必须处理」的才继承 Exception(受检,强制处理)。② 必须提供带 cause 的构造器((String, Throwable))——否则无法建异常链。③ 可加业务字段(如错误码 code、错误详情),配合全局异常处理返回结构化错误。理解「自定义异常一般继承 RuntimeException、必带 cause 构造器、可加错误码」,就能设计出好用的业务异常——它是全局异常处理、统一错误响应的基础(前面 Spring 的 @ControllerAdvice 就是捕获这些自定义异常)。
四、最佳实践一:别吞异常、别只打 message
异常处理最常见的两个错误——吞异常和只打 message:
// ✗ 反模式1:空 catch 吞异常——出了问题完全查不到
try { risky(); } catch (Exception e) { } // 异常被吃掉,无声无息
// ✗ 反模式2:只打 message,丢了堆栈——不知道哪行出的错
catch (Exception e) { log.error("出错: " + e.getMessage()); } // 没堆栈,定位难
// ✓ 正确:带上下文 + 完整异常(堆栈)
catch (Exception e) { log.error("处理订单失败, orderId={}", orderId, e); }
// ↑ 异常对象作最后参数才打堆栈
吞异常(空 catch)是头号反模式——它让异常「无声无息地消失」,程序继续跑但状态可能已经错了,出问题时完全无法追查。只打 e.getMessage() 也是常见错误——message 只是一句话(如「null」),没有堆栈,你不知道是哪行、哪个调用链出的错。正确做法是 log.error("上下文信息", e)(把异常对象作为最后一个参数传给日志方法,才会打印完整堆栈)。理解「绝不空 catch 吞异常、日志要带完整异常堆栈和上下文」,就避开了异常处理最坑的两个错误——线上事故里「异常被吞了查不到」是最让人抓狂的一类。
五、最佳实践二:别用异常控制流程
一个重要的性能和设计原则——异常只用于「异常情况」,不用于「正常的控制流程」:
// ✗ 反模式:用异常控制正常流程
try {
while (true) {
process(iterator.next()); // 用 NoSuchElementException 判断循环结束
}
} catch (NoSuchElementException e) { } // 把"正常结束"当异常处理
// ✓ 正确:用正常的条件判断
while (iterator.hasNext()) {
process(iterator.next());
}
原因:异常的创建开销很大——new 一个异常时要填充堆栈信息(fillInStackTrace),这个操作要遍历整个调用栈、代价高(比正常的方法调用慢几十上百倍)。如果用异常控制正常流程(如循环里频繁抛异常判断结束),会造成严重的性能问题。而且用异常控流程也让代码难读(正常逻辑藏在 catch 里)。所以「异常是给『意外/错误』用的,不是给『正常分支』用的」——正常的判断用 if、循环结束用条件,别用 try-catch 兜。理解「异常创建开销大(填充堆栈)、别用异常控正常流程」,就懂了这个性能+设计的双重原则。(题外:如果确实要频繁抛异常且不需要堆栈,可重写 fillInStackTrace 返回自身来省开销,但这是特殊优化。)
六、最佳实践三:精确捕获、分层处理
还有两条重要实践——精确捕获和分层处理:
精确捕获(catch 具体异常,别一律 catch Exception):
✗ catch (Exception e) ← 太宽,把不该捕获的也捕获了(如 RuntimeException 编程错误)
✓ catch (IOException e) / catch (SQLException e) ← 精确,只处理该处理的
→ 精确捕获让你能针对不同异常做不同处理,也不会误吞编程 bug
分层处理(早抛出、晚捕获——在能处理的层统一处理):
底层(DAO):抛出/转换异常,不自己处理(它不知道怎么处理业务错误)
中层(Service):转换成业务异常(带 cause),或让它继续往上抛
上层(Controller/全局异常处理器):统一捕获、返回友好错误
→ 别在每一层都 try-catch(到处 catch 让代码乱、且底层无法决定怎么处理)
精确捕获:catch 具体异常类型(IOException、SQLException)而非笼统的 Exception——这样能针对不同异常做不同处理,也避免误吞了本该暴露的编程错误(如 NPE)。分层处理(早抛晚捕):异常应该「在能真正处理它的层」被捕获——底层只管抛/转换(它不知道业务该怎么办),到上层(Controller 或全局异常处理器)统一捕获处理(返回错误页/JSON)。别在每一层都 try-catch(既啰嗦又让底层做了不该它做的决定)。这和前面 Spring 的 @ControllerAdvice 全局异常处理是一脉相承的——底层抛、上层统一处理。理解「精确捕获 + 早抛晚捕分层处理」,就掌握了异常处理的架构层面实践。
记忆钩子:「异常链=转换异常时把原异常作 cause 附上(new Xxx(‘msg’, e)),保留完整 Caused by 因果链,别丢 cause;自定义异常一般继承 RuntimeException、必带 (String,Throwable) 构造器、可加错误码;最佳实践:别空 catch 吞异常、日志用 log.error(‘msg’, e) 带堆栈、别用异常控正常流程(创建开销大要填充堆栈)、精确捕获、早抛晚捕分层处理、资源用 try-with-resources」。
七、常见误区与追问
- 误区:转换异常时只
throw new Xxx("msg")就行。 会丢失原始异常堆栈——必须把捕获的异常作为 cause 传入throw new Xxx("msg", e),保留异常链(Caused by),否则排查时找不到根因。 - 误区:空 catch 或只打 e.getMessage() 没关系。 空 catch 吞异常导致问题无声消失、无法追查;只打 message 丢了堆栈定位困难;应
log.error("上下文", e)打完整堆栈。 - 误区:用异常控制正常流程无所谓。 异常创建要填充堆栈(fillInStackTrace)开销大(比正常调用慢几十上百倍),频繁抛异常控流程有严重性能问题,且代码难读;正常判断用 if。
- 误区:自定义异常都该继承 Exception(受检)。 大多数业务异常继承 RuntimeException(非受检、灵活、Spring 事务默认回滚);只有「调用方必须处理」的才用受检 Exception。
- 追问:怎么保留异常链? 抛新异常时把捕获的原异常作为 cause 传入——用带 cause 的构造器
new Xxx("msg", e)或initCause(e);打印时Caused by:会一层层追溯到根因。 - 追问:为什么不能用异常控制正常流程? 异常创建时要填充堆栈信息(fillInStackTrace),遍历调用栈开销大,频繁抛出性能差;异常应只用于「意外/错误」,正常分支用条件判断。
- 追问:自定义异常应该继承 RuntimeException 还是 Exception? 大多数继承 RuntimeException(非受检,不强制 try-catch,用起来灵活,配合全局异常处理,Spring 事务默认对它回滚);只有「必须让调用方处理」的少数场景用受检 Exception。
八、加强记忆
异常链(Exception Chaining) 是「转换异常时把原异常作为 cause(原因) 附上」——throw new BusinessException("下单失败", e),既抛出合适层次的业务异常(友好、屏蔽底层),又保留原始异常的完整堆栈(排查能追溯根因),打印时显示 Caused by: 一层层追到底。最容易犯的错是重新抛异常时忘了传 cause(throw new Xxx("msg")),会彻底丢失根因。自定义异常一般继承 RuntimeException(非受检、灵活、Spring 事务默认回滚,少数「必须处理」的才用受检 Exception),必须提供带 (String, Throwable) 的构造器(否则建不了异常链),可加业务错误码配合全局异常处理。异常处理最佳实践:① 绝不空 catch 吞异常、② 日志用 log.error("上下文", e) 打完整堆栈(别只打 message)、③ 别用异常控制正常流程(创建异常要填充堆栈 fillInStackTrace、开销大,正常判断用 if)、④ 精确捕获具体异常(别一律 catch Exception)、⑤ 早抛晚捕分层处理(底层抛/转换、上层统一捕获,别每层都 try-catch)、⑥ 资源用 try-with-resources。一句话「异常链传 cause 保因果、自定义继承 RuntimeException 带 cause 构造器、别吞异常别只打 message、别用异常控流程、精确捕获早抛晚捕」。