Java 从入门到精通(六):异常处理机制——异常体系、try-with-resources 与最佳实践

几乎每个 Java 程序员都写过 try-catch,但能把下面几个问题答清楚的人并不多:finally 里写 return 会发生什么?try-with-resources 到底编译成了什么?为什么高并发场景下有人要重写 fillInStackTrace?catch 住 OutOfMemoryError 之后程序还能活吗?本文从语言规范一路挖到字节码,把这一整套机制讲透。

一、异常体系全景:从 Throwable 说起

1.1 家族树与两条语义分界线

Java 的异常世界只有一个根:java.lang.Throwable。它往下分出两支——Error 与 Exception,Exception 再往下分出一支特殊的 RuntimeException。这棵树的形状非常简单,但它背后暗含了两条完全不同的语义分界线。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
Throwable
├── Error (虚拟机级故障,不可恢复)
│ ├── OutOfMemoryError
│ ├── StackOverflowError
│ └── NoClassDefFoundError
└── Exception
├── RuntimeException (非受检:编程错误 / 不可预期)
│ ├── NullPointerException
│ ├── IllegalArgumentException
│ └── IndexOutOfBoundsException
└── 其它受检异常 (受检:可预期的外部环境问题)
├── IOException
├── SQLException
└── ClassNotFoundException

第一条分界线在 Throwable 之下:Error 表示「JVM 自己都快撑不住了」,比如堆内存耗尽、栈帧溢出、类加载链路断裂;Exception 表示「程序逻辑或外部环境出了问题,但 JVM 本身是健康的」。

第二条分界线在 Exception 之下:RuntimeException 及其子类属于非受检异常(unchecked),编译器不强制你处理;其余的 Exception 子类属于受检异常(checked),方法签名上必须 throws 声明,调用方必须 catch 或继续向上抛,否则编译不过。

这里有个很多人忽略的细节:Error 也是非受检的。也就是说 catch (OutOfMemoryError e) 不仅能通过编译,而且没人拦着你写——但正如 1.4 节要说的,这几乎总是一个错误。

1.2 受检与非受检:编译期强制 vs 设计意图

受检异常是 Java 相对于 C++、C#、Python 等语言一个非常有争议的设计。它的初衷是:把「可预期的失败」写进方法契约里,逼调用方正视它。比如 FileInputStream 的构造器读文件,文件可能不存在、可能没权限,这是外部环境决定的、调用方理应处理的情况,所以设计成受检异常。

而 NullPointerException 是典型的非受检异常:它说明你的代码里有个引用没判空,这是 bug,应该去改代码,而不是在运行时 catch 住假装处理。

维度 受检异常(Checked) 非受检异常(Unchecked)
代表类型 IOException、SQLException、InterruptedException NullPointerException、IllegalArgumentException
编译器态度 必须 throws 声明或 catch,否则编译失败 完全不检查
语义 可预期的外部环境问题,调用方有能力恢复 编程错误或不可恢复状态,应修正代码
处理策略 重试、降级、转译、告知用户 让它快速失败(fail fast),或统一兜底
成本 污染方法签名,层层 throws 传递 零签名成本,但容易被忽略
典型争议 破坏 lambda 与函数式接口(函数式接口几乎无法声明受检异常) 缺少编译期提醒,容易漏处理
1
2
3
4
5
6
7
8
9
10
11
// 受检异常:不处理就编译不过,编译器强制你写契约
public static String readFirstLine(Path path) throws IOException {
try (BufferedReader reader = Files.newBufferedReader(path, StandardCharsets.UTF_8)) {
return reader.readLine();
}
}

// 非受检异常:编译期完全静默,上线后才炸
public static int divide(int a, int b) {
return a / b; // b == 0 时抛 ArithmeticException,没人提醒你
}

判断一个异常该不该受检,有一个非常好用的标准:「调用方能不能做点有意义的事?」 能重试、能降级、能换路径、能提示用户 → 受检;只能打日志然后等着崩溃 → 非受检。

1.3 常见 Error 与 RuntimeException 速查表

Error 家族虽然不该被捕获,但你必须能在日志里一眼认出它们,并知道大致成因。

Error 触发场景 常见根因
OutOfMemoryError 堆/元空间/直接内存/线程栈耗尽 内存泄漏、堆参数过小、一次性加载超大数据集、无界线程池
StackOverflowError 线程栈深度超过 -Xss 限制 递归无终止条件、循环依赖的对象 toString/equals、深度嵌套 JSON 解析
NoClassDefFoundError 编译期存在、运行期找不到类定义 依赖冲突、静态初始化失败后再次加载、jar 被误删
ExceptionInInitializerError 静态代码块或静态字段初始化抛异常 静态资源加载失败、配置读取异常
UnsupportedClassVersionError class 文件版本高于当前 JVM 用 JDK 17 编译却跑在 JDK 8 上

RuntimeException 家族则要熟悉到能条件反射的程度:

RuntimeException 典型触发点
NullPointerException 对 null 引用调用方法或访问字段(JDK 14+ 的友好 NPE 信息能直接指出哪个变量为 null)
IllegalArgumentException 入参非法,如 Enum.valueOf 找不到枚举项
IllegalStateException 对象状态不满足前置条件,如 iterator.remove() 前未 next()
IndexOutOfBoundsException / ArrayIndexOutOfBoundsException 数组或 List 越界
ClassCastException 类型强转失败,泛型擦除后的经典坑
ConcurrentModificationException 迭代过程中结构性修改集合(fail-fast)
NumberFormatException Integer.parseInt("abc")
UnsupportedOperationException Arrays.asList(...) 返回的定长 List 调用 add

1.4 为什么不应该 catch Error

理由有三层,缺一不可。

第一层:你 catch 了也救不回来。 OutOfMemoryError 发生时,堆已经无法分配对象——而 new 一个异常对象、拼一条日志字符串、往 Logback 的环形缓冲里塞事件,全都需要分配内存。你在 catch 块里做的任何事都可能再次触发 OOM,陷入死循环。

第二层:catch 住会让程序进入「半死不活」状态。 一个 OOM 之后,可能有一批对象的构造函数中途失败、一批集合处于不一致状态、一批锁没有释放。此时继续运行,数据正确性没有保障,比直接崩溃更危险。

第三层:掩盖了真正的故障信号。 现代运维体系依赖进程崩溃后的自动重启与堆转储(-XX:+HeapDumpOnOutOfMemoryError)。如果你 catch 住并继续跑,监控系统看不到崩溃,dump 也不会生成,问题会被无限期隐藏。

1
2
3
4
5
6
7
8
9
10
// 反例:看似优雅,实则把系统拖进泥潭
try {
byte[] huge = new byte[Integer.MAX_VALUE];
} catch (OutOfMemoryError e) {
log.error("内存不足", e); // 这一行本身就可能再抛 OOM
return Collections.emptyList(); // 返回空结果,业务被静默污染
}

// 正解:要么不捕获让它崩溃并生成 dump,要么只在极少数可控场景做「优雅退出」
// JVM 参数:-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/data/dump

真正的生产实践是:配置 -XX:+HeapDumpOnOutOfMemoryError,让 JVM 在 OOM 时生成堆转储并退出,然后用 MAT 或 JProfiler 分析泄漏点。唯一值得捕获 Error 的场景是框架级的「保护性捕获 + 立即重新抛出」,例如线程池在执行任务时兜住 Throwable 以保证线程不静默消亡,随后仍应把错误传播出去。

1.5 Error 与 Exception 的边界并不绝对

值得注意的是,Error 与 Exception 的划分在真实世界里偶尔会模糊。比如 ThreadDeath(已废弃)、AssertionError(assert 失败)——后者严格来说是一种「程序不该到达这里」的断言失败,与 OOM 这种资源耗尽完全不同。再比如不少第三方库会自定义 XXXError,那只是命名习惯,不代表它真的是虚拟机级故障。判断依据永远是成因是否可恢复,而不是类名后缀。

二、异常的传播与栈轨迹

2.1 抛出之后发生了什么

throw 一个异常对象,JVM 会做三件事:

  1. 填充栈轨迹:调用 native 方法 fillInStackTrace(),从当前栈顶向下遍历所有栈帧,把类名、方法名、文件名、行号记录进 StackTraceElement[](JDK 9 之后内部用 StackWalker 相关机制与 backtrace 结构优化,对外仍表现为 StackTraceElement[])。
  2. 沿调用栈向上搜索:从当前方法开始,查该方法的异常表(Exception Table),看有没有能匹配该异常类型的 handler;没有就弹出栈帧,到调用者的方法里继续查。
  3. 找到则跳转执行 handler,并把栈帧截断到那一层;一路找不到,则由当前线程的 UncaughtExceptionHandler 处理,最终打印到 stderr 或记录到日志。

「把栈帧截断」这一步很关键:一旦异常被某一层 catch 住,栈轨迹就只到那一层为止,更上层的调用方信息不会出现在栈里。这就是为什么有人建议在跨层转译异常时保留 cause——否则上层调用上下文会彻底丢失。

2.2 fillInStackTrace 的真实开销

栈轨迹填充是整个异常机制里最贵的一步,而且它的成本与当前调用栈深度成正比。栈越深,要遍历的栈帧越多、要解析的符号越多。一次 new Exception() 加深栈填充,耗时可达微秒级到数十微秒量级——相比之下,一次普通的方法调用是纳秒级。差距是三个数量级。

由此可以推出几条实战结论:

  • 异常只应用于异常场景。把异常当流程控制,在深栈上频繁抛出,是实打实的性能杀手。
  • 栈深度影响异常成本。printStackTrace 与 getStackTrace 都要先把 native 的 backtrace 解析成 StackTraceElement[],这是惰性解析,只有真正访问栈信息时才发生。
  • JDK 有 -XX:-StackTraceInThrowable 的近似优化(部分厂商 JVM 支持),开启后异常不记录栈轨迹,代价是所有栈信息丢失。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 一个粗略的量级测量:不要迷信具体数字,重点看「深栈 vs 浅栈」的相对差距
public class StackTraceCost {
static final int LOOP = 1_000_000;

static Exception shallow() { return new Exception("boom"); }

static Exception deep(int n) { // 人为制造 200 层调用栈
return n == 0 ? new Exception("boom") : deep(n - 1);
}

public static void main(String[] args) {
long t0 = System.nanoTime();
for (int i = 0; i < LOOP; i++) { shallow(); }
long t1 = System.nanoTime();
for (int i = 0; i < LOOP; i++) { deep(200); }
long t2 = System.nanoTime();

System.out.printf("浅栈构造 100 万次:%d ms%n", (t1 - t0) / 1_000_000);
System.out.printf("深栈构造 100 万次:%d ms%n", (t2 - t1) / 1_000_000);
}
}

2.3 优化手段:关闭栈轨迹填充

Throwable 提供了一个 protected 构造器开关:Throwable(String message, Throwable cause, boolean enableSuppression, boolean writableStackTrace)。把 writableStackTrace 设为 false,就不会调用 fillInStackTrace(),异常对象变成「裸异常」,构造成本降到接近普通对象。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
/**
* 用于高频、可预期、且不需要栈轨迹的业务异常(如参数校验失败、限流拒绝)。
* 关键点:writableStackTrace = false,跳过 fillInStackTrace 的栈遍历。
*/
public class FastBusinessException extends RuntimeException {
private final int code;

public FastBusinessException(int code, String message) {
// 第四个参数 false:不填充栈轨迹
super(message, null, true, false);
this.code = code;
}

public int getCode() { return code; }

// 双保险:即便被别人用反射/序列化路径构造,也不再填充
@Override
public synchronized Throwable fillInStackTrace() {
return this; // 直接返回 this,什么都不做
}
}

另一种写法是只重写 fillInStackTrace() 返回 this——这是很多框架(如早期的一些 RPC 框架、Disruptor 的 AlertException)用过的技巧。二者效果接近,构造器开关更彻底(连 stackTrace 字段都不初始化)。

什么时候该关栈轨迹? 只有同时满足三点才做:① 异常在热路径上被高频抛出;② 异常是「可预期的业务分支」而非 bug;③ 你百分之百确定排查时不需要知道它在哪一行抛出。绝大多数业务系统都不满足,别为了微优化丢掉排障能力。

2.4 printStackTrace 为什么不该出现在生产代码里

printStackTrace() 有四个问题,每一个都足以让它退出生产代码:

  1. 输出到 System.err,绕过日志框架。不进文件、不分级别、不带 traceId、无法被日志采集系统(ELK / Loki)收集。
  2. 在 System.err 上加锁,是串行化的。PrintStream 的方法是 synchronized 的,多线程同时打印异常栈会互相阻塞,在高并发下形成隐蔽的锁竞争热点。
  3. 只打印不处理。它不会改变控制流,异常被「打完即忘」,很容易演变成事实上的吞异常。
  4. 栈可能极长。printStackTrace 默认打印全部栈帧,深度嵌套或递归场景下一次能输出几百行,瞬间刷爆磁盘。

正确姿势永远是交给日志框架:log.error("创建订单失败, orderId={}", orderId, e)——注意异常对象作为最后一个参数传入,不要拼进字符串,这样日志框架才会输出完整栈轨迹。

1
2
3
4
5
6
7
8
9
10
11
12
13
// 错误示范:只打了 message,栈全丢了,等于没法排查
log.error("创建订单失败:" + e.getMessage());

// 错误示范:字符串拼接异常对象,输出的是 e.toString(),栈依然丢失
log.error("创建订单失败:" + e);

// 正确:占位符 + 异常对象作为最后一个实参
log.error("创建订单失败, orderId={}, userId={}", orderId, userId, e);

// 需要把栈转成字符串(例如塞进数据库字段)时这样做
StringWriter sw = new StringWriter();
e.printStackTrace(new PrintWriter(sw));
String stack = sw.toString();

三、try-catch-finally 深度剖析

3.1 执行顺序与 return 的纠缠

这是面试里被问烂、但在代码 review 中依然高频出问题的一节。先给结论表,再用字节码解释原因。

场景 最终返回值 / 行为
try 中 return a,finally 无 return 返回 try 里的值,finally 在 return 之前执行
try 中 return,finally 修改了要返回的基本类型变量 返回值不变(值已被暂存到局部变量槽)
try 中 return 对象引用,finally 修改了该对象字段 返回值对象的字段被改了(引用不变,对象内容变)
try 中 return,finally 中也有 return finally 的 return 覆盖 try 的返回值,且 try 中若抛异常会被吞掉
try 抛异常,catch 中 return,finally 无 return 正常返回 catch 的值,异常被消化
try 抛异常,catch 中 return,finally 中 return 返回 finally 的值,原异常彻底消失
try 抛异常,catch 中再次抛出,finally 无 return 抛出 catch 中的异常
try 抛异常,catch 中再次抛出,finally 中 return 异常被吞掉,方法正常返回 finally 的值
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
public class FinallyReturnDemo {

// 1) finally 修改基本类型返回值:无效
static int primitive() {
int x = 1;
try {
return x; // 返回值在此刻被"暂存"为 1
} finally {
x = 100; // 改的是变量,不是已暂存的值
}
}

// 2) finally 修改对象字段:有效
static String objectField() {
StringBuilder sb = new StringBuilder("a");
try {
return sb.toString(); // 已经生成了字符串 "a",后续修改 sb 无影响
} finally {
sb.append("b");
}
}

static List<String> objectRef() {
List<String> list = new ArrayList<>();
try {
return list; // 返回的是引用
} finally {
list.add("finally"); // 同一个对象被修改 → 调用方看得到
}
}

// 3) finally 中 return:吞掉异常(极其危险)
static int swallow() {
try {
int i = 1 / 0; // ArithmeticException
return i;
} finally {
return -1; // 异常被丢弃,方法返回 -1,日志里什么都没有
}
}

public static void main(String[] args) {
System.out.println(primitive()); // 1
System.out.println(objectField()); // a
System.out.println(objectRef()); // [finally]
System.out.println(swallow()); // -1,没有任何异常抛出!
}
}

3.2 字节码视角:Exception Table 与 finally 的复制机制

Java 的异常处理不是靠运行时「扫描 try 块」实现的,而是靠 .class 文件里每个方法的异常表(Exception Table)。用 javap -c -v 可以看到它的真容:

1
2
3
4
5
6
Exception table:
from to target type
0 5 8 Class java/lang/ArithmeticException
0 5 18 any // finally 的兜底:任何异常
8 13 18 any // catch 块自身也在 finally 保护范围内
18 24 18 any // finally 自己抛异常时(理论上)也走这里

四个字段的含义是:from(含)到 to(不含)这段字节码指令区间内,如果抛出了 type 类型(或子类)的异常,就跳转到 target 处执行 handler。type 为 any 表示匹配所有异常,这正是 finally 的实现方式。

关键点在于:字节码里根本没有 finally 这个概念。javac 在编译期做了两件事:

  1. 为每个 catch 生成一个 type = 具体异常类 的表项;
  2. 把 finally 块的代码复制三份(JDK 6 之前用 jsr/ret 子程序调用指令,JDK 7 起已彻底改为代码复制),分别放在:
    • try 块正常结束之后;
    • 每个 catch 块正常结束之后;
    • 作为 type = any 的 handler,处理所有未被 catch 的异常(执行完 finally 后有一条 athrow 重新抛出)。
1
2
3
4
5
6
7
8
9
10
// 源码
static int calc(int a) {
try {
return 10 / a;
} catch (ArithmeticException e) {
return -1;
} finally {
System.out.println("finally");
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// javap -c 反编译后的核心结构(节选,已简化)
0: bipush 10
2: iload_0
3: idiv
4: istore_1
5: getstatic # println // ← finally 第 1 份副本(try 正常路径)
8: iload_1
9: ireturn
10: astore_1 // catch handler 入口
11: iconst_m1
12: istore_2
13: getstatic # println // ← finally 第 2 份副本(catch 正常路径)
16: iload_2
17: ireturn
18: astore_3 // type=any 的 handler 入口
19: getstatic # println // ← finally 第 3 份副本(异常路径)
22: aload_3
23: athrow // 重新抛出异常,不吞

看懂这份字节码,3.1 节那张表就不用背了:

  • 「返回值被暂存」是因为 ireturn 之前,值已经 istore 到另一个局部变量槽;
  • 「finally 里 return 会吞异常」是因为编译器生成的 athrow 被你的 return 指令直接顶掉了——异常对象还躺在局部变量槽里,但方法已经正常返回了;
  • 「finally 一定执行」的保证,本质上来自 type = any 那条表项,它能兜住任何 Throwable。

3.3 finally 一定会执行吗?三个反例

规范只保证「try 块开始执行后,控制权会以某种方式转移到 finally」。以下三种情况 finally 不会执行:

反例 说明
System.exit(0) / Runtime.halt() JVM 直接终止,所有线程立即消亡,finally 没有机会运行
JVM 崩溃 / 进程被 kill -9 操作系统强制回收进程,不给你任何执行机会
守护线程被 JVM 终止 所有非守护线程结束时,守护线程会被直接掐断,栈上的 finally 不执行

此外还有几个「技术上 finally 存在但行为诡异」的边界:try 块中执行无限循环或死锁,finally 永远不会到达;try 之前的代码就抛异常(finally 压根没被进入)。

1
2
3
4
5
6
7
8
9
10
public class FinallyNotRun {
public static void main(String[] args) {
try {
System.out.println("try");
System.exit(0); // JVM 立刻退出
} finally {
System.out.println("finally"); // 永远打印不出来
}
}
}

工程含义:不要用 finally 承载「必须保证发生」的关键业务语义(如释放分布式锁、扣减库存回滚)。真正需要强保证的清理逻辑,要么配合 ShutdownHook(Runtime.getRuntime().addShutdownHook),要么依赖外部超时机制(Redis 锁的 TTL),要么做成可重入的补偿任务。

3.4 try 中局部变量在 finally 中的可见性

一个容易踩的语法细节:在 try 块里声明的变量,作用域只到 try 块结束,finally 里访问不到。想共享就必须把声明提到 try 外面——这也正是 try-with-resources 在 JDK 9 之后支持「引用外部已声明资源」的动因之一。

1
2
3
4
5
6
7
8
9
10
11
12
13
public static void visibility() {
Connection conn = null; // 必须提到外面,finally 才能访问
try {
conn = DriverManager.getConnection("jdbc:mysql://localhost:3306/test");
conn.setAutoCommit(false);
} catch (SQLException e) {
log.error("获取连接失败", e);
} finally {
if (conn != null) { // 还得判空,因为 try 可能第一行就失败
try { conn.close(); } catch (SQLException ignored) { }
}
}
}

这段「声明在外 + finally 判空 + 嵌套 try-catch 忽略关闭异常」的模板代码,就是 try-with-resources 要消灭的对象——下一节我们看它如何被编译器自动补全。

四、try-with-resources:把资源关闭交给编译器

4.1 AutoCloseable 与 Closeable 的区别

对比项 AutoCloseable(java.lang) Closeable(java.io)
来源 JDK 7 引入,为 try-with-resources 而生 JDK 1.5 就有,面向 IO 流
继承关系 顶层接口 Closeable extends AutoCloseable
close() 签名 void close() throws Exception void close() throws IOException
幂等要求 规范建议幂等,但不强制 规范明确要求幂等(多次 close 无副作用)
适用范围 任意资源:连接、锁、事务、ExecutorService 主要是 IO 流

实践建议:自定义资源时实现 AutoCloseable 即可,并把 close() 的受检异常收窄(比如不抛,或只抛 IOException),同时保证幂等。不要在 close() 里抛受检异常去折磨调用方——AutoCloseable.close() 声明的 throws Exception 是历史包袱,子类完全可以收窄。

4.2 语法与编译产物

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public class MyResource implements AutoCloseable {
private final String name;
public MyResource(String name) {
this.name = name;
System.out.println("open " + name);
}
public void work() { System.out.println("work with " + name); }
@Override
public void close() {
System.out.println("close " + name);
// 幂等:已关闭则直接返回,不抛异常
}
}

// 使用方
public static void use() {
try (MyResource r = new MyResource("A")) {
r.work();
} // 编译器自动插入 close()
}

编译器把上面的 try-with-resources 展开成近似下面的形态(这是 javac 的真实语义,不是笔者的简化):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public static void use() {
MyResource r = new MyResource("A");
Throwable primary = null; // 主异常
try {
r.work();
} catch (Throwable t) {
primary = t;
throw t;
} finally {
if (r != null) {
if (primary != null) {
try {
r.close();
} catch (Throwable suppressed) {
primary.addSuppressed(suppressed); // 挂到主异常上
}
} else {
r.close(); // 正常路径:异常直接向外抛
}
}
}
}

注意这个编译产物比 3.4 节的手写模板强在三处:自动判空、关闭异常不会覆盖主异常(走 addSuppressed)、无论正常还是异常路径都关闭。

4.3 关闭顺序:逆序

多个资源用分号分隔时,关闭顺序与声明顺序相反——后声明的先关闭。这是依赖关系的自然要求:缓冲流包着节点流,必须先关外层再关内层(虽然多数 JDK 流的实现会级联关闭,但语义上逆序才是正确的)。

1
2
3
4
5
6
7
public static void copy(Path src, Path dst) throws IOException {
// 声明顺序:in → out;关闭顺序:out → in
try (InputStream in = Files.newInputStream(src);
OutputStream out = Files.newOutputStream(dst)) {
in.transferTo(out);
}
}

4.4 异常抑制机制:suppressed exception

这是 try-with-resources 最精妙也最少被理解的部分。当 try 块抛出异常(主异常)后,close() 又抛出第二个异常时,第二个异常不会覆盖主异常,而是通过 Throwable.addSuppressed() 挂到主异常上,用户调用 getSuppressed() 可以取回。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public class SuppressedDemo {
static class Boom implements AutoCloseable {
private final String id;
Boom(String id) { this.id = id; }
@Override public void close() { throw new RuntimeException("close " + id); }
}

public static void main(String[] args) {
try (Boom a = new Boom("A"); Boom b = new Boom("B")) {
throw new IllegalStateException("业务主异常");
} catch (IllegalStateException e) {
System.out.println("主异常: " + e.getMessage()); // 业务主异常
for (Throwable t : e.getSuppressed()) {
System.out.println("被抑制: " + t.getMessage()); // close B / close A(逆序)
}
}
}
}

Throwable 内部的源码逻辑(JDK 8+ 简化版)如下,值得逐行读一遍:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public class Throwable implements Serializable {
// 哨兵值:区分"还没记录过"与"禁止记录"
private static final List<Throwable> SUPPRESSED_SENTINEL =
Collections.emptyList();
private List<Throwable> suppressedExceptions = SUPPRESSED_SENTINEL;

public final synchronized void addSuppressed(Throwable exception) {
if (exception == this) // ① 不能把自己加给自己
throw new IllegalArgumentException("Self-suppression not permitted", this);
if (exception == null)
throw new NullPointerException("Cannot suppress a null exception");
if (suppressedExceptions == null) // ② 关闭了 suppression 功能,直接忽略
return;
if (suppressedExceptions == SUPPRESSED_SENTINEL) // ③ 首次使用才创建列表(懒分配)
suppressedExceptions = new ArrayList<>(1);
suppressedExceptions.add(exception); // ④ 追加
}

public final synchronized Throwable[] getSuppressed() {
if (suppressedExceptions == null || suppressedExceptions == SUPPRESSED_SENTINEL)
return EMPTY_THROWABLE_ARRAY;
return suppressedExceptions.toArray(EMPTY_THROWABLE_ARRAY);
}
}

三个设计细节值得记住:① 自抑制是被显式禁止的(否则 try (r) { throw e } 且 r.close() 抛同一个 e 时会形成自引用);② 列表是懒分配的,绝大多数异常一生都不会用上它,一个空的 ArrayList 都不浪费;③ 一旦 enableSuppression 为 false,suppressedExceptions 会被置为 null,此后所有 addSuppressed 静默失效——这正是 writableStackTrace=false 那个构造器开关的连带效果。

日志框架(Logback、Log4j2)打印异常时会自动输出 Suppressed: 段落,所以生产日志里看到 Suppressed: java.io.IOException ... 不要懵,那是关闭资源时的次生异常。

4.5 手写 finally 关闭资源的三个经典错误

对照 try-with-resources 的编译产物,手写版本几乎必踩以下坑:

1
2
3
4
5
6
7
8
9
10
11
12
// 错误 1:不判空 → try 第一行就失败时,finally 里 NPE,掩盖原始异常
Connection c = null;
try { c = getConn(); ... } finally { c.close(); } // c 可能为 null

// 错误 2:close() 的异常覆盖了主异常 → 真正的失败原因永远消失
try { doSomething(); } finally { resource.close(); } // close 抛异常,业务异常被顶掉

// 错误 3:多个资源串行关闭,前一个 close 抛异常导致后一个资源泄漏
try { ... } finally {
a.close(); // 抛异常 → 下一行根本不执行
b.close(); // b 泄漏!
}

正确但啰嗦的手写写法(JDK 6 时代的唯一解):

1
2
3
4
5
6
7
8
9
10
11
Connection c = null;
Statement s = null;
try {
c = getConn();
s = c.createStatement();
s.execute("SELECT 1");
} finally {
// 每个资源独立 try-catch,保证互不影响;异常只记录不抛出,避免覆盖主异常
if (s != null) { try { s.close(); } catch (SQLException e) { log.warn("关闭 Statement 失败", e); } }
if (c != null) { try { c.close(); } catch (SQLException e) { log.warn("关闭 Connection 失败", e); } }
}

4.6 JDK 9+:引用外部已声明的资源

JDK 7 规定资源必须在 try (...) 的圆括号里声明,导致 3.4 节那种「想在 finally/catch 里访问资源」的场景很难受。JDK 9 放宽了限制:只要变量是 final 或 effectively final,就可以直接写进 try (变量)。

1
2
3
4
5
6
public static void jdk9Style(Path path) throws IOException {
BufferedReader reader = Files.newBufferedReader(path); // 在外面声明
try (reader) { // JDK 9+ 合法:引用已有变量
System.out.println(reader.readLine());
} // 依然是自动 close,且关闭后变量不可再用
}

五、自定义异常设计规范

5.1 业务异常基类:错误码 + 消息 + 上下文

一个好用的业务异常,至少要承载三类信息:机器可读的错误码(前端据此分支处理)、人类可读的消息(可直接展示或用于日志)、结构化上下文(订单号、用户 ID 等排障要素)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
/**
* 业务异常基类:所有可预期的业务失败都继承它。
* 继承 RuntimeException:不污染方法签名,由全局异常处理器统一兜底。
*/
public class BizException extends RuntimeException {
private final int code;
private final Map<String, Object> context = new HashMap<>();

public BizException(ErrorCode errorCode) {
super(errorCode.getMessage());
this.code = errorCode.getCode();
}

public BizException(ErrorCode errorCode, Throwable cause) {
super(errorCode.getMessage(), cause); // 保留 cause,绝不丢
this.code = errorCode.getCode();
}

/** 追加上下文:BizException.of(ORDER_NOT_FOUND).with("orderId", id) */
public BizException with(String key, Object value) {
context.put(key, value);
return this;
}
public int getCode() { return code; }
public Map<String, Object> getContext() { return Collections.unmodifiableMap(context); }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
// 错误码枚举:集中管理,避免硬编码散落各处
public enum ErrorCode {
PARAM_INVALID(40001, "参数不合法"),
ORDER_NOT_FOUND(40401, "订单不存在"),
STOCK_NOT_ENOUGH(40901, "库存不足"),
SYSTEM_BUSY(50000, "系统繁忙,请稍后重试");

private final int code;
private final String message;
ErrorCode(int code, String message) { this.code = code; this.message = message; }
public int getCode() { return code; }
public String getMessage() { return message; }
}

5.2 运行时还是受检?决策表

场景 建议 理由
用户输入校验失败 运行时(BizException) 调用方无法「修复」输入,只能提示用户;统一兜底即可
库存不足 / 余额不足 运行时 + 明确错误码 业务分支,但处理方式统一,不需要逐层声明
网络 IO / 文件读写 受检(IOException) 调用方可能重试、换路径,编译器强制提醒有价值
数据库访问 受检(SQLException)或框架封装为运行时 现代框架(Spring DataAccessException)统一走运行时
框架/库对外 API 倾向运行时 受检异常会传染所有使用者的签名,且难以用于 lambda
内部模块间的可恢复失败 受检 团队规模小、调用链短时,编译期提醒收益大于成本

一句话总结:库/框架层用受检异常表达契约,应用/业务层用运行时异常 + 全局兜底。这也是 Spring 把 SQLException、IOException 统统包装成运行时 DataAccessException 的原因。

5.3 异常继承层次与国际化

层次不要太深。推荐两到三层:BizException(通用业务异常)→ OrderException / PaymentException(领域异常)。超过三层后,catch 的匹配顺序会变得极易出错(catch 必须按「子类在前、父类在后」排列,否则编译器直接报错:已捕获到该异常)。

国际化(i18n)的正确做法是:异常里存错误码与参数,展示层根据 Locale 查 MessageSource 生成文案,而不是在抛出时就拼好中文消息——后者会让日志、跨语言调用方全部绑死在一种语言上。

1
2
3
4
5
// 抛出时只带错误码和参数
throw new BizException(ErrorCode.STOCK_NOT_ENOUGH).with("skuId", skuId).with("need", 5);

// 展示层(Spring)再按 Locale 渲染
String msg = messageSource.getMessage("error.40901", new Object[]{skuId}, locale);

5.4 不要拿异常控制流程,保留 cause

用异常做流程控制的代价是双重的:性能(2.2 节说过的栈填充成本)和可读性(控制流跳跃到 catch,阅读代码时需要脑补跳转)。典型反例:用 catch NumberFormatException 来判断字符串是否为数字、用 catch NoSuchElementException 来结束循环、用异常来实现 break 到多层之外。正确做法是先用 if 判断(如正则、hasNext()),异常只留给真正的「意外」。

包装异常时必须保留 cause,这是最容易被违反的一条规范:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 反例:原始异常彻底丢失,排查时只剩一句"下单失败"
catch (SQLException e) {
throw new BizException(ErrorCode.SYSTEM_BUSY);
}

// 正解 1:构造器传入 cause
catch (SQLException e) {
throw new BizException(ErrorCode.SYSTEM_BUSY, e);
}

// 正解 2:没有对应构造器时用 initCause(注意:只能调用一次,且构造时未传 cause)
BizException ex = new BizException(ErrorCode.SYSTEM_BUSY);
ex.initCause(e); // 若已通过构造器设置了 cause,再调用会抛 IllegalStateException
throw ex;

六、异常处理最佳实践

6.1 早抛出,晚捕获

早抛出(fail fast):在参数进入系统的边界就校验,让错误在最接近源头的地方暴露。一个 null 应该在你自己的方法入口就变成 IllegalArgumentException,而不是传到第十层、被某个 mapper 触发 NPE。

晚捕获:在真正有能力处理(重试、降级、转换、提示用户)的那一层才 catch。中间层不要为了「显得严谨」而 catch 后打日志再原样抛出——那只会让同一条异常在日志里出现十次。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 反例:中间层无意义地 catch-log-rethrow,日志被同一异常刷屏
public OrderDTO getOrder(Long id) {
try {
return orderMapper.selectById(id);
} catch (Exception e) {
log.error("查询订单失败", e);
throw e; // 什么都没做,只是多打一条日志
}
}

// 正解:要么不 catch(让全局处理器兜底),要么转译成业务语义
public OrderDTO getOrder(Long id) {
return Optional.ofNullable(orderMapper.selectById(id))
.orElseThrow(() -> new BizException(ErrorCode.ORDER_NOT_FOUND)
.with("orderId", id));
}

6.2 不要吞异常,也不要 catch 宽泛的 Exception

空 catch 块是代码里最恶劣的异味之一:它让故障彻底静默。如果确实「预期内可以忽略」(如关闭资源、中断清理),也请写清注释并至少 debug 级记录。

1
2
3
4
5
6
7
8
9
10
// 恶劣:完全的沉默
try { doSomething(); } catch (Exception e) { }

// 可接受的「有理由的忽略」
try {
lock.unlock();
} catch (IllegalMonitorStateException ignored) {
// 当前线程本就不持有该锁,解锁失败是预期的,无需处理
log.debug("重复解锁被忽略", ignored);
}

同时,catch (Exception e) 会把 RuntimeException(NPE、越界、状态错误)一并吃掉,把 bug 伪装成业务失败。总是捕获你能处理的最具体类型;确实需要兜底时,多个 catch 块按子类到父类排列。

6.3 不要在循环里 try-catch

try-catch 本身在现代 JVM 上几乎没有直接开销(异常表是静态的,进入 try 块不需要额外指令),真正的问题是循环体里的异常一旦高频抛出,构造成本会被放大 N 倍;此外把 try 放在循环内会让代码结构混乱。把整个循环包起来,或者在循环内部用 if 预判避免抛出。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 反例:1 万条数据里 1000 条格式错误 → 1000 次栈填充
for (String s : list) {
try {
result.add(Integer.parseInt(s));
} catch (NumberFormatException e) {
log.warn("格式错误: {}", s);
}
}

// 正解:先过滤,再批量转换;异常留给真正的意外
for (String s : list) {
if (s != null && s.matches("-?\\d+")) { // 预判替代抛出
result.add(Integer.parseInt(s));
} else {
log.warn("格式错误: {}", s);
}
}

6.4 异常转译与全局兜底

异常转译(translation):把底层技术异常(SQLException、IOException、FeignException)转换成当前抽象层的业务异常,避免上层依赖下层实现细节。转译时务必带上 cause。

全局兜底:任何系统都需要最后一道防线,防止异常逃逸成用户面前的 500 堆栈页。单体/命令行应用用 Thread.setDefaultUncaughtExceptionHandler;Web 应用用框架级处理器。

1
2
3
4
5
6
7
8
9
10
// 兜底:捕获所有未处理异常,保证至少留下一条带 traceId 的日志
public class GlobalExceptionHandler implements Thread.UncaughtExceptionHandler {
@Override
public void uncaughtException(Thread t, Throwable e) {
log.error("线程[{}]发生未捕获异常, traceId={}", t.getName(), MDC.get("traceId"), e);
// 可选:上报监控 / 触发告警
}
}
// 安装:Thread.setDefaultUncaughtExceptionHandler(new GlobalExceptionHandler());
// 线程池场景:ThreadFactory 里为每个线程 setUncaughtExceptionHandler
层次 处理手段 输出形态
方法内部 throw new BizException(errorCode, cause) 带错误码的业务异常
Service 边界 异常转译,切断技术细节外泄 领域语义的异常
Controller / RPC 层 @ControllerAdvice / Filter 统一拦截 结构化 JSON:{code, message, traceId}
线程级 UncaughtExceptionHandler 完整栈日志 + 告警
JVM 级 -XX:+HeapDumpOnOutOfMemoryError 堆转储文件,供事后分析

6.5 Spring Boot 统一异常处理实战

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
/** 统一响应结构:所有接口返回同一形状,前端只认 code 字段 */
@Data
public class ApiResult<T> {
private int code; // 0 表示成功,非 0 为业务错误码
private String message; // 可直接展示给用户的文案
private String traceId; // 链路追踪 ID,排障唯一钥匙
private T data;

public static <T> ApiResult<T> ok(T data) {
ApiResult<T> r = new ApiResult<>();
r.code = 0; r.message = "ok"; r.data = data;
r.traceId = MDC.get("traceId");
return r;
}
public static <T> ApiResult<T> fail(int code, String message) {
ApiResult<T> r = new ApiResult<>();
r.code = code; r.message = message;
r.traceId = MDC.get("traceId");
return r;
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
@RestControllerAdvice          // = @ControllerAdvice + @ResponseBody
@Slf4j
public class GlobalExceptionAdvice {

/** 1) 业务异常:可预期,warn 级别,不打完整栈(或只打简栈) */
@ExceptionHandler(BizException.class)
public ApiResult<Void> handleBiz(BizException e) {
log.warn("业务异常, code={}, context={}", e.getCode(), e.getContext());
return ApiResult.fail(e.getCode(), e.getMessage());
}

/** 2) 参数校验异常:MethodArgumentNotValidException,提取字段级错误 */
@ExceptionHandler(MethodArgumentNotValidException.class)
public ApiResult<Void> handleValid(MethodArgumentNotValidException e) {
String msg = e.getBindingResult().getFieldErrors().stream()
.map(f -> f.getField() + ":" + f.getDefaultMessage())
.collect(Collectors.joining(", "));
return ApiResult.fail(ErrorCode.PARAM_INVALID.getCode(), msg);
}

/** 3) 兜底:未知异常,error 级别 + 完整栈,对外只暴露通用文案 */
@ExceptionHandler(Exception.class)
public ApiResult<Void> handleUnknown(Exception e) {
log.error("未处理异常", e); // 完整栈必须留下
return ApiResult.fail(ErrorCode.SYSTEM_BUSY.getCode(),
ErrorCode.SYSTEM_BUSY.getMessage());
}
}

三个关键设计点:① 错误码要分档(4xxxx 客户端问题、5xxxx 服务端问题),前端据此决定是否重试;② 未知异常绝不把 e.getMessage() 返回给前端(可能泄露 SQL、内网地址、堆栈),只返回通用文案 + traceId;③ 日志级别分层,业务异常用 warn(不需要告警),未知异常用 error(触发告警)。

七、反模式清单与高频面试题

7.1 反模式清单

  1. finally 中 return:吞掉异常与返回值,是隐蔽 bug 之王。
  2. 空 catch 块:故障静默,线上永远查不到原因。
  3. 只打 e.getMessage() 不打异常对象:栈轨迹丢失,等于没日志。
  4. printStackTrace() 用于生产:绕过日志框架、在 System.err 上串行加锁。
  5. catch Throwable 或 Error:把 OOM、栈溢出一并吃掉,系统进入半死状态。
  6. 用异常做流程控制:性能差且可读性差,应改用 if 预判。
  7. 循环内 try-catch 高频抛出:栈填充成本被放大 N 倍。
  8. 包装异常丢失 cause:原始根因永远消失。
  9. throws Exception 一把梭:调用方无法针对性处理,等于没有契约。
  10. 资源手写 finally 关闭且未判空 / 未隔离 close 异常:NPE 或覆盖主异常或资源泄漏。
  11. catch 后抛出一个全新的、无关的异常:语义断裂,如把 IOException 转成 NullPointerException。
  12. 吞掉 InterruptedException:不恢复中断状态(Thread.currentThread().interrupt()),导致上层无法感知取消信号。

第 12 条值得单独强调,它是并发代码里最常见的错误:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 反例:吞掉中断,线程池 shutdownNow 永远无法真正停止该任务
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
// 什么都不做
}

// 正解:恢复中断标志,或把异常继续抛出
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
Thread.currentThread().interrupt(); // 恢复中断状态
throw new BizException(ErrorCode.SYSTEM_BUSY, e);
}

7.2 高频面试题十问

Q1:finally 里的代码一定执行吗?
不一定。System.exit()、JVM 崩溃、进程被 kill -9、守护线程被强制终止时都不会执行。正常路径、异常路径、return 路径都会执行。

Q2:try 里有 return,finally 里也有 return,返回哪个?
返回 finally 的。而且 try 中若有异常,会被这个 return 彻底吞掉——因为编译器生成的 athrow 被 return 指令顶替了。

Q3:为什么 finally 修改 int 返回值无效,但修改对象字段有效?
return 时基本类型的值已被 istore 到局部变量槽,finally 改的是原变量;对象返回的是引用,finally 通过该引用修改的是同一个对象实例。

Q4:字节码里是怎么实现异常捕获的?
靠方法属性中的 Exception Table,每条记录含 from/to/target/type。from(含)到 to(不含)区间内抛出 type 类型(或其子类)异常时,跳转到 target 执行。

Q5:finally 在字节码层面是怎么实现的?
javac 把 finally 块代码复制多份,分别放在 try 正常结束处、每个 catch 正常结束处,以及一个 type = any 的 handler 中(该 handler 末尾有 athrow 重新抛出)。JDK 6 之前使用 jsr/ret 子程序指令,现已废弃。

Q6:受检异常是好的设计吗?
有争议。优点是编译期强制契约、提醒可恢复失败;缺点是污染方法签名、层层 throws 传染、与 lambda/函数式接口严重冲突。C#、Kotlin、Python 都没有受检异常。业界趋势是应用层使用运行时异常 + 全局兜底。

Q7:异常处理的性能开销到底有多大?
try 块本身几乎零开销(异常表是静态结构)。开销集中在抛出时:fillInStackTrace() 的栈遍历(与栈深度成正比,微秒级)、StackTraceElement[] 的解析与分配、printStackTrace 的同步输出。所以「不要用异常控制流程」指的是不要频繁抛出,而不是不要用 try。

Q8:OutOfMemoryError 可以被恢复吗?
理论上堆压力缓解后可能继续运行,但实践中不应这么做:对象可能处于不一致状态、catch 块自身的内存分配可能再次 OOM、故障信号被掩盖、堆转储无法生成。正确做法是让它崩溃、生成 dump、事后分析泄漏。

Q9:try-with-resources 多个资源的关闭顺序?
逆序:后声明的先关闭。且若 try 块与 close() 都抛异常,主异常保留,close() 的异常通过 addSuppressed 挂在主异常上,可由 getSuppressed() 取出。

Q10:AutoCloseable 和 Closeable 怎么选?
自定义资源实现 AutoCloseable,把 close() 的受检异常收窄并保证幂等;Closeable 专用于 IO 流,其 close() 抛 IOException 且规范要求幂等。

Q11:e.printStackTrace() 和 log.error(msg, e) 的区别?
前者输出到 System.err、无级别无上下文、在 PrintStream 上同步串行、无法被采集;后者进入日志框架、带级别与 MDC(traceId)、可被 ELK/Loki 收集。生产一律用后者。

Q12:什么时候该重写 fillInStackTrace?
仅在「异常在热路径高频抛出 + 是预期业务分支 + 确定不需要栈信息」三者同时成立时,用构造器 writableStackTrace=false 或重写返回 this。否则不要为了微优化牺牲排障能力。

八、小结:异常处理的心理模型

回到最根本的问题:每次写 catch 之前,先问自己一句——「这是不可恢复的 bug,还是可预期的业务分支?」

  • 如果是不可恢复的 bug(NPE、越界、状态错误、OOM),正确答案是「让它快速失败」。不要 catch,不要降级,让它带着完整栈轨迹冲到全局兜底处理器,打 error 日志、触发告警、暴露 traceId,然后去修代码。捕获这类异常只会把 bug 埋得更深。
  • 如果是可预期的业务分支(库存不足、参数非法、余额不够、对方接口超时),正确答案是「用带错误码的业务异常显式表达」,在合适的层次转译成用户能理解的文案,用 warn 级别记录,不触发告警。
  • 如果是可恢复的外部故障(网络抖动、连接超时、锁冲突),正确答案是「在具备重试能力的那一层处理」——受检异常在这里最有价值,因为它强迫你正视「这件事可能失败」。

把这三句话刻进肌肉记忆,剩下的所有规范(不要吞异常、保留 cause、具体捕获、统一兜底、try-with-resources)都只是它们的自然推论。异常处理从来不是语法问题,而是你对失败的建模方式——你如何看待失败,就会写出什么样的 catch 块。