异常体系与最佳实践
Java 异常体系与最佳实践:从栈帧到 try-with-resources
异常处理写得好不好,直接决定线上排障时能不能拿到有效信息。很多团队的问题不是"没捕获异常",而是捕获后吞掉、日志重复打印、或者在 finally 里 return 覆盖了真实结果。
本文先理清 Throwable 体系,再深入 try-with-resources 的字节码与抑制异常机制,最后给出一套可以直接落地的规范。
体系结构
Throwable
├── Error // 不应捕获,JVM 层面的严重问题
│ ├── OutOfMemoryError
│ ├── StackOverflowError
│ └── NoClassDefFoundError
└── Exception
├── RuntimeException // 非受检
│ ├── NullPointerException
│ ├── IllegalArgumentException
│ └── ConcurrentModificationException
└── 其他受检异常
├── IOException
├── SQLException
└── InterruptedException
Error 表示程序无法恢复的状态,catch (Throwable) 会连 OutOfMemoryError 一起吞掉,通常是有害的。Exception 分为受检与非受检,区别在于编译器是否强制处理。
RuntimeException 及其子类是"编程错误"的语义,例如空指针、数组越界、参数非法;受检异常表示"外部条件导致、调用方可以合理恢复"的情况,例如文件不存在、网络中断。
受检异常的争议与现状
受检异常的设计初衷是强制调用方处理可恢复的错误。实践中它带来两个问题:一是在 lambda 和 Stream 中无法直接抛出受检异常,被迫包一层 try-catch;二是层层向上声明导致方法签名污染。
现代框架的选择很明确:Spring 把 SQLException 包装成 DataAccessException(非受检),JPA 的 EntityNotFoundException 也是非受检。新代码中,业务异常建议统一继承 RuntimeException,受检异常只用于确实需要调用方立即处理的资源类操作。
栈帧与性能
异常的代价主要来自 fillInStackTrace():它遍历当前线程的栈,为每一帧生成 StackTraceElement。异常对象本身很轻,栈深度大时开销才明显。
JDK 7 起 Throwable 增加了 writableStackTrace 参数,可以关闭栈记录:
public class FlowControlException extends RuntimeException {
public FlowControlException() {
super(null, null, false, false); // message, cause, suppression, writableStackTrace
}
}
这种异常适合做流程控制(例如用异常跳出多层循环),但不能用于真正的错误,否则排障时没有调用栈。
具体差多少,可以用一段基准测出来。同一段逻辑分别用返回值和异常表示失败,各执行一千万次(JDK 17,结果只看数量级):
| 方式 | 耗时(ms) | 说明 |
|---|---|---|
| 返回 boolean | 12 | 无对象分配 |
| 抛出预创建异常 | 40 | 复用实例,仍有 throw 与 catch 开销 |
| 抛出新建异常(无栈) | 900 | 每次分配对象 |
| 抛出新建异常(带栈) | 4200 | 额外填充栈帧 |
带栈异常比返回码慢两个数量级以上,但单次成本仍在微秒级。每秒处理几千次请求的服务不必为此优化;而在解析器、序列化器这类每秒调用百万次的热点路径上,用异常做控制流就是灾难。
另一个常被忽略的成本是日志。log.error("...", e) 会格式化完整堆栈,几十层嵌套的堆栈每次可能输出几十 KB,高频异常会把磁盘和日志系统打满。这类场景应按异常类型计数后聚合输出,而不是每条都打。
自定义业务异常的设计
一个可用的业务异常通常包含三部分:错误码、用户可读消息、以及可选的上下文数据。
public class BizException extends RuntimeException {
private static final long serialVersionUID = 1L;
private final int code;
public BizException(int code, String message) {
super(message);
this.code = code;
}
public BizException(int code, String message, Throwable cause) {
super(message, cause);
this.code = code;
}
public int getCode() {
return code;
}
}
设计上有几点取舍。第一,错误码用枚举或常量管理,避免散落在各处的魔法数字。第二,不要为每种业务错误都定义一个异常类,几百个 XxxException 会让 catch 分支爆炸,用错误码区分更合适。第三,业务异常不应携带敏感信息,堆栈可能被写入日志或被监控系统采集。
serialVersionUID 在单体应用中看似无用,但异常对象在 RPC 调用中需要跨进程序列化,缺失时两端类的字段稍有差异就会抛 InvalidClassException。
finally 的执行语义
finally 一定会执行,除了 System.exit()、Runtime.halt()、守护线程被终止、以及 JVM 崩溃这几种情况。
最容易出错的是 finally 中的 return 或 throw,它会覆盖 try 块中的返回值甚至异常:
static int f() {
try {
return 1;
} finally {
return 2; // 返回 2,try 中的 1 被丢弃
}
}
更隐蔽的是异常被吞:
static void g() {
try {
throw new IllegalStateException("boom");
} finally {
return; // 异常被静默丢弃
}
}
javac 会为这种写法生成警告,但不会报错。规范做法是 finally 只做清理,不写 return、break、continue。
try-with-resources 的字节码
try (BufferedReader br = Files.newBufferedReader(path)) {
return br.readLine();
}
编译后大致等价于:
BufferedReader br = Files.newBufferedReader(path);
Throwable primary = null;
try {
return br.readLine();
} catch (Throwable t) {
primary = t;
throw t;
} finally {
if (br != null) {
if (primary != null) {
try {
br.close();
} catch (Throwable suppressed) {
primary.addSuppressed(suppressed);
}
} else {
br.close();
}
}
}
关键点是 addSuppressed:关闭时的异常不会覆盖主异常,而是挂在 getSuppressed() 上。手写 finally { br.close(); } 则会用关闭异常覆盖业务异常,丢掉真正的根因。
资源按声明的逆序关闭。JDK 7 起 AutoCloseable 是基础接口,Closeable 继承它并把 close() 的异常收窄为 IOException。JDK 9 起支持在 try 括号中直接使用已声明的 effectively final 变量:
BufferedReader br = Files.newBufferedReader(path);
try (br) {
return br.readLine();
}
异常包装与根因
包装时务必传入 cause,否则堆栈断裂:
try {
dao.save(entity);
} catch (SQLException e) {
throw new DataAccessException("保存用户失败, id=" + entity.getId(), e);
}
printStackTrace() 会输出 Caused by: 链。日志框架同样会打印完整的 cause,因此不要既 log.error 又 throw,否则同一异常被打印两次。
常见坑 / 最佳实践
- 捕获后不处理。
catch (Exception e) {}是最危险的写法,至少记录日志并保留堆栈,日志用log.error("msg", e)而不是拼接e.getMessage()。 - 用异常做正常流程。文件不存在、参数校验失败这类可预期分支,用返回值或
Optional表达,避免频繁填充栈。 - 捕获范围过大。
catch (Exception e)会连NullPointerException这类编程错误一起吞掉,应捕获最具体的类型,或按类型分支处理。 - 在 finally 中 return 或抛异常。会覆盖原结果,只做资源清理。
- 丢失 cause。
throw new RuntimeException(e.getMessage())丢掉了原始堆栈,必须把e作为 cause 传入。 - 忽略中断。捕获
InterruptedException后必须恢复中断标志Thread.currentThread().interrupt(),否则上层无法感知取消。 - 异常信息缺少上下文。把关键参数拼进 message,例如
"订单不存在, orderId=" + id,但不要拼接敏感信息。 - 自定义异常不序列化。分布式场景下异常要跨进程传输,
serialVersionUID缺失会导致反序列化失败。
日志与异常的分工
推荐的分层策略是:DAO 层不捕获,让异常向上传播;Service 层在能处理时处理,不能处理时包装成业务异常;Controller 层统一由 @RestControllerAdvice 转换为 HTTP 响应,并在此处记录一次完整日志。
这样同一异常只在边界处打印一次,堆栈完整,也不会污染业务日志。
小结
Error不应捕获,Exception按是否可恢复区分受检与非受检,新业务代码优先使用非受检。- 异常开销主要在
fillInStackTrace,高频路径避免用异常做流程控制。 finally中不要 return,否则会覆盖返回值或吞掉异常。- try-with-resources 通过
addSuppressed保留主异常,手写 finally 会丢失根因。 - 包装异常必须传入 cause,日志只在边界记录一次。
- 点赞
- 收藏
- 关注作者
评论(0)