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

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 | Throwable |
第一条分界线在 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 | // 受检异常:不处理就编译不过,编译器强制你写契约 |
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 | // 反例:看似优雅,实则把系统拖进泥潭 |
真正的生产实践是:配置 -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 支持),开启后异常不记录栈轨迹,代价是所有栈信息丢失。
1 | // 一个粗略的量级测量:不要迷信具体数字,重点看「深栈 vs 浅栈」的相对差距 |
2.3 优化手段:关闭栈轨迹填充
Throwable 提供了一个 protected 构造器开关:Throwable(String message, Throwable cause, boolean enableSuppression, boolean writableStackTrace)。把 writableStackTrace 设为 false,就不会调用 fillInStackTrace(),异常对象变成「裸异常」,构造成本降到接近普通对象。
1 | /** |
另一种写法是只重写 fillInStackTrace() 返回 this——这是很多框架(如早期的一些 RPC 框架、Disruptor 的 AlertException)用过的技巧。二者效果接近,构造器开关更彻底(连 stackTrace 字段都不初始化)。
2.4 printStackTrace 为什么不该出现在生产代码里
printStackTrace() 有四个问题,每一个都足以让它退出生产代码:
- 输出到
System.err,绕过日志框架。不进文件、不分级别、不带 traceId、无法被日志采集系统(ELK / Loki)收集。 - 在
System.err上加锁,是串行化的。PrintStream的方法是synchronized的,多线程同时打印异常栈会互相阻塞,在高并发下形成隐蔽的锁竞争热点。 - 只打印不处理。它不会改变控制流,异常被「打完即忘」,很容易演变成事实上的吞异常。
- 栈可能极长。
printStackTrace默认打印全部栈帧,深度嵌套或递归场景下一次能输出几百行,瞬间刷爆磁盘。
正确姿势永远是交给日志框架:log.error("创建订单失败, orderId={}", orderId, e)——注意异常对象作为最后一个参数传入,不要拼进字符串,这样日志框架才会输出完整栈轨迹。
1 | // 错误示范:只打了 message,栈全丢了,等于没法排查 |
三、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 | public class FinallyReturnDemo { |
3.2 字节码视角:Exception Table 与 finally 的复制机制
Java 的异常处理不是靠运行时「扫描 try 块」实现的,而是靠 .class 文件里每个方法的异常表(Exception Table)。用 javap -c -v 可以看到它的真容:
1 | Exception table: |
四个字段的含义是: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重新抛出)。
1 | // 源码 |
1 | // javap -c 反编译后的核心结构(节选,已简化) |
看懂这份字节码,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 | public class FinallyNotRun { |
3.4 try 中局部变量在 finally 中的可见性
一个容易踩的语法细节:在 try 块里声明的变量,作用域只到 try 块结束,finally 里访问不到。想共享就必须把声明提到 try 外面——这也正是 try-with-resources 在 JDK 9 之后支持「引用外部已声明资源」的动因之一。
1 | public static void visibility() { |
这段「声明在外 + 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 | public class MyResource implements AutoCloseable { |
编译器把上面的 try-with-resources 展开成近似下面的形态(这是 javac 的真实语义,不是笔者的简化):
1 | public static void use() { |
注意这个编译产物比 3.4 节的手写模板强在三处:自动判空、关闭异常不会覆盖主异常(走 addSuppressed)、无论正常还是异常路径都关闭。
4.3 关闭顺序:逆序
多个资源用分号分隔时,关闭顺序与声明顺序相反——后声明的先关闭。这是依赖关系的自然要求:缓冲流包着节点流,必须先关外层再关内层(虽然多数 JDK 流的实现会级联关闭,但语义上逆序才是正确的)。
1 | public static void copy(Path src, Path dst) throws IOException { |
4.4 异常抑制机制:suppressed exception
这是 try-with-resources 最精妙也最少被理解的部分。当 try 块抛出异常(主异常)后,close() 又抛出第二个异常时,第二个异常不会覆盖主异常,而是通过 Throwable.addSuppressed() 挂到主异常上,用户调用 getSuppressed() 可以取回。
1 | public class SuppressedDemo { |
Throwable 内部的源码逻辑(JDK 8+ 简化版)如下,值得逐行读一遍:
1 | public class Throwable implements Serializable { |
三个设计细节值得记住:① 自抑制是被显式禁止的(否则 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 | // 错误 1:不判空 → try 第一行就失败时,finally 里 NPE,掩盖原始异常 |
正确但啰嗦的手写写法(JDK 6 时代的唯一解):
1 | Connection c = null; |
4.6 JDK 9+:引用外部已声明的资源
JDK 7 规定资源必须在 try (...) 的圆括号里声明,导致 3.4 节那种「想在 finally/catch 里访问资源」的场景很难受。JDK 9 放宽了限制:只要变量是 final 或 effectively final,就可以直接写进 try (变量)。
1 | public static void jdk9Style(Path path) throws IOException { |
五、自定义异常设计规范
5.1 业务异常基类:错误码 + 消息 + 上下文
一个好用的业务异常,至少要承载三类信息:机器可读的错误码(前端据此分支处理)、人类可读的消息(可直接展示或用于日志)、结构化上下文(订单号、用户 ID 等排障要素)。
1 | /** |
1 | // 错误码枚举:集中管理,避免硬编码散落各处 |
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 | // 抛出时只带错误码和参数 |
5.4 不要拿异常控制流程,保留 cause
用异常做流程控制的代价是双重的:性能(2.2 节说过的栈填充成本)和可读性(控制流跳跃到 catch,阅读代码时需要脑补跳转)。典型反例:用 catch NumberFormatException 来判断字符串是否为数字、用 catch NoSuchElementException 来结束循环、用异常来实现 break 到多层之外。正确做法是先用 if 判断(如正则、hasNext()),异常只留给真正的「意外」。
包装异常时必须保留 cause,这是最容易被违反的一条规范:
1 | // 反例:原始异常彻底丢失,排查时只剩一句"下单失败" |
六、异常处理最佳实践
6.1 早抛出,晚捕获
早抛出(fail fast):在参数进入系统的边界就校验,让错误在最接近源头的地方暴露。一个 null 应该在你自己的方法入口就变成 IllegalArgumentException,而不是传到第十层、被某个 mapper 触发 NPE。
晚捕获:在真正有能力处理(重试、降级、转换、提示用户)的那一层才 catch。中间层不要为了「显得严谨」而 catch 后打日志再原样抛出——那只会让同一条异常在日志里出现十次。
1 | // 反例:中间层无意义地 catch-log-rethrow,日志被同一异常刷屏 |
6.2 不要吞异常,也不要 catch 宽泛的 Exception
空 catch 块是代码里最恶劣的异味之一:它让故障彻底静默。如果确实「预期内可以忽略」(如关闭资源、中断清理),也请写清注释并至少 debug 级记录。
1 | // 恶劣:完全的沉默 |
同时,catch (Exception e) 会把 RuntimeException(NPE、越界、状态错误)一并吃掉,把 bug 伪装成业务失败。总是捕获你能处理的最具体类型;确实需要兜底时,多个 catch 块按子类到父类排列。
6.3 不要在循环里 try-catch
try-catch 本身在现代 JVM 上几乎没有直接开销(异常表是静态的,进入 try 块不需要额外指令),真正的问题是循环体里的异常一旦高频抛出,构造成本会被放大 N 倍;此外把 try 放在循环内会让代码结构混乱。把整个循环包起来,或者在循环内部用 if 预判避免抛出。
1 | // 反例:1 万条数据里 1000 条格式错误 → 1000 次栈填充 |
6.4 异常转译与全局兜底
异常转译(translation):把底层技术异常(SQLException、IOException、FeignException)转换成当前抽象层的业务异常,避免上层依赖下层实现细节。转译时务必带上 cause。
全局兜底:任何系统都需要最后一道防线,防止异常逃逸成用户面前的 500 堆栈页。单体/命令行应用用 Thread.setDefaultUncaughtExceptionHandler;Web 应用用框架级处理器。
1 | // 兜底:捕获所有未处理异常,保证至少留下一条带 traceId 的日志 |
| 层次 | 处理手段 | 输出形态 |
|---|---|---|
| 方法内部 | throw new BizException(errorCode, cause) |
带错误码的业务异常 |
| Service 边界 | 异常转译,切断技术细节外泄 | 领域语义的异常 |
| Controller / RPC 层 | @ControllerAdvice / Filter 统一拦截 |
结构化 JSON:{code, message, traceId} |
| 线程级 | UncaughtExceptionHandler |
完整栈日志 + 告警 |
| JVM 级 | -XX:+HeapDumpOnOutOfMemoryError |
堆转储文件,供事后分析 |
6.5 Spring Boot 统一异常处理实战
1 | /** 统一响应结构:所有接口返回同一形状,前端只认 code 字段 */ |
1 | // = @ControllerAdvice + @ResponseBody |
三个关键设计点:① 错误码要分档(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 条值得单独强调,它是并发代码里最常见的错误:
1 | // 反例:吞掉中断,线程池 shutdownNow 永远无法真正停止该任务 |
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 块。

















