几乎每个 Java 程序员都写过 try-catch,但能把下面几个问题答清楚的人并不多:finally 里写 return 会发生什么?try-with-resources 到底编译成了什么?为什么高并发场景下有人要重写 ?catch 住 之后程序还能活吗?本文从语言规范一路挖到字节码,把这一整套机制讲透。
一、异常体系全景:从 Throwable 说起
1.1 家族树与两条语义分界线
Java 的异常世界只有一个根:java.lang.Throwable。它往下分出两支——Error 与 Exception,Exception 再往下分出一支特殊的 RuntimeException。这棵树的形状非常简单,但它背后暗含了两条完全不同的语义分界线。
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 与函数式接口(函数式接口几乎无法声明受检异常) | 缺少编译期提醒,容易漏处理 |
// 受检异常:不处理就编译不过,编译器强制你写契约
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 也不会生成,问题会被无限期隐藏。
// 反例:看似优雅,实则把系统拖进泥潭
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 会做三件事:
- 填充栈轨迹:调用 native 方法
fillInStackTrace(),从当前栈顶向下遍历所有栈帧,把类名、方法名、文件名、行号记录进StackTraceElement[](JDK 9 之后内部用StackWalker相关机制与backtrace结构优化,对外仍表现为StackTraceElement[])。 - 沿调用栈向上搜索:从当前方法开始,查该方法的异常表(Exception Table),看有没有能匹配该异常类型的 handler;没有就弹出栈帧,到调用者的方法里继续查。
- 找到则跳转执行 handler,并把栈帧截断到那一层;一路找不到,则由当前线程的
UncaughtExceptionHandler处理,最终打印到 stderr 或记录到日志。
「把栈帧截断」这一步很关键:一旦异常被某一层 catch 住,栈轨迹就只到那一层为止,更上层的调用方信息不会出现在栈里。这就是为什么有人建议在跨层转译异常时保留 cause——否则上层调用上下文会彻底丢失。
2.2 fillInStackTrace 的真实开销
栈轨迹填充是整个异常机制里最贵的一步,而且它的成本与当前调用栈深度成正比。栈越深,要遍历的栈帧越多、要解析的符号越多。一次 new Exception() 加深栈填充,耗时可达微秒级到数十微秒量级——相比之下,一次普通的方法调用是纳秒级。差距是三个数量级。
由此可以推出几条实战结论:
- 异常只应用于异常场景。把异常当流程控制,在深栈上频繁抛出,是实打实的性能杀手。
- 栈深度影响异常成本。
printStackTrace与getStackTrace都要先把 native 的 backtrace 解析成StackTraceElement[],这是惰性解析,只有真正访问栈信息时才发生。 - JDK 有
-XX:-StackTraceInThrowable的近似优化(部分厂商 JVM 支持),开启后异常不记录栈轨迹,代价是所有栈信息丢失。
// 一个粗略的量级测量:不要迷信具体数字,重点看「深栈 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(),异常对象变成「裸异常」,构造成本降到接近普通对象。
/**
* 用于高频、可预期、且不需要栈轨迹的业务异常(如参数校验失败、限流拒绝)。
* 关键点: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() 有四个问题,每一个都足以让它退出生产代码:
- 输出到
System.err,绕过日志框架。不进文件、不分级别、不带 traceId、无法被日志采集系统(ELK / Loki)收集。 - 在
System.err上加锁,是串行化的。PrintStream的方法是synchronized的,多线程同时打印异常栈会互相阻塞,在高并发下形成隐蔽的锁竞争热点。 - 只打印不处理。它不会改变控制流,异常被「打完即忘」,很容易演变成事实上的吞异常。
- 栈可能极长。
printStackTrace默认打印全部栈帧,深度嵌套或递归场景下一次能输出几百行,瞬间刷爆磁盘。
正确姿势永远是交给日志框架:log.error("创建订单失败, orderId={}", orderId, e)——注意异常对象作为最后一个参数传入,不要拼进字符串,这样日志框架才会输出完整栈轨迹。
// 错误示范:只打了 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 的值 |
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 可以看到它的真容:
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 在编译期做了两件事:
- 为每个 catch 生成一个
type = 具体异常类的表项; - 把 finally 块的代码复制三份(JDK 6 之前用
jsr/ret子程序调用指令,JDK 7 起已彻底改为代码复制),分别放在:- try 块正常结束之后;
- 每个 catch 块正常结束之后;
- 作为
type = any的 handler,处理所有未被 catch 的异常(执行完 finally 后有一条athrow重新抛出)。
// 源码
static int calc(int a) {
try {
return 10 / a;
} catch (ArithmeticException e) {
return -1;
} finally {
System.out.println("finally");
}
}
// 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 压根没被进入)。
public class FinallyNotRun {
public static void main(String[] args) {
try {
System.out.println("try");
System.exit(0); // JVM 立刻退出
} finally {
System.out.println("finally"); // 永远打印不出来
}
}
}
工程含义:不要用 finally 承载「必须保证发生」的关键业务语义(如释放分布式锁、扣减库存回滚)。真正需要强保证的清理逻辑,要么配合 (),要么依赖外部超时机制(Redis 锁的 TTL),要么做成可重入的补偿任务。
3.4 try 中局部变量在 finally 中的可见性
一个容易踩的语法细节:在 try 块里声明的变量,作用域只到 try 块结束,finally 里访问不到。想共享就必须把声明提到 try 外面——这也正是 try-with-resources 在 JDK 9 之后支持「引用外部已声明资源」的动因之一。
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 语法与编译产物
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 的真实语义,不是笔者的简化):
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 流的实现会级联关闭,但语义上逆序才是正确的)。
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() 可以取回。
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+ 简化版)如下,值得逐行读一遍:
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:不判空 → 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 时代的唯一解):
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 (变量)。
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 等排障要素)。
/**
* 业务异常基类:所有可预期的业务失败都继承它。
* 继承 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); }
}
// 错误码枚举:集中管理,避免硬编码散落各处
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 生成文案,而不是在抛出时就拼好中文消息——后者会让日志、跨语言调用方全部绑死在一种语言上。
// 抛出时只带错误码和参数
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,这是最容易被违反的一条规范:
// 反例:原始异常彻底丢失,排查时只剩一句"下单失败"
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 后打日志再原样抛出——那只会让同一条异常在日志里出现十次。
// 反例:中间层无意义地 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 级记录。
// 恶劣:完全的沉默
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 万条数据里 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 应用用框架级处理器。
// 兜底:捕获所有未处理异常,保证至少留下一条带 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 统一异常处理实战
/** 统一响应结构:所有接口返回同一形状,前端只认 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;
}
}
@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 反模式清单
- finally 中
return:吞掉异常与返回值,是隐蔽 bug 之王。 - 空 catch 块:故障静默,线上永远查不到原因。
- 只打
e.getMessage()不打异常对象:栈轨迹丢失,等于没日志。 printStackTrace()用于生产:绕过日志框架、在System.err上串行加锁。- catch
Throwable或Error:把 OOM、栈溢出一并吃掉,系统进入半死状态。 - 用异常做流程控制:性能差且可读性差,应改用
if预判。 - 循环内 try-catch 高频抛出:栈填充成本被放大 N 倍。
- 包装异常丢失 cause:原始根因永远消失。
throws Exception一把梭:调用方无法针对性处理,等于没有契约。- 资源手写 finally 关闭且未判空 / 未隔离 close 异常:NPE 或覆盖主异常或资源泄漏。
- catch 后抛出一个全新的、无关的异常:语义断裂,如把
IOException转成NullPointerException。 - 吞掉
InterruptedException:不恢复中断状态(Thread.currentThread().interrupt()),导致上层无法感知取消信号。
第 12 条值得单独强调,它是并发代码里最常见的错误:
// 反例:吞掉中断,线程池 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 块。