Java 异常
Java 异常用于表达方法执行失败,并沿调用链传播失败原因。本文从异常分类和捕获规则入手,再说明在哪一层处理失败。随后介绍资源关闭,以及 finally 和多个异常共同出现时的执行结果。
1. 面试核心
先记住这几条,再读正文- 异常分类。
Error和RuntimeException及其子类属于非受检异常。卡 · 异常体系 - 受检要求。 编译器要求受检异常被捕获或声明,异常能否恢复则取决于具体调用场景。
- 捕获与转换。 当前层有恢复或转换策略时才捕获异常,包装异常时通过
cause保留原始失败原因。卡 · 抛出、声明与捕获 - 资源关闭顺序。 try-with-resources 按资源声明的逆序关闭资源。卡 · try-with-resources 与资源关闭
- 多个失败的保留。 try 主体与资源关闭同时失败时,主体异常向外传播,关闭异常进入它的
suppressed列表。
2. 异常体系
Java 中可以抛出的对象都属于 Throwable 体系。先看继承关系,再判断编译器是否要求调用方处理异常:
Throwable
├── Error
└── Exception
├── RuntimeException
└── 其他受检异常
Error 包含虚拟机、链接及严重资源错误,例如 OutOfMemoryError。这类问题需要针对具体原因决定如何终止或恢复,统一捕获全部 Error 容易掩盖严重故障。
Exception 是另一条常见分支,其中包含 RuntimeException。RuntimeException、Error 及其子类属于非受检异常。其余异常类受编译器检查,包括 Throwable 自身。
void load(Path path) throws IOException { // 受检异常需要声明或捕获
Files.readString(path);
}
int parsePort(String value) {
return Integer.parseInt(value); // 可能抛非受检异常
}
上例的 IOException 是受检异常,因此 load 必须捕获或在签名中声明它。parseInt 抛出的 NumberFormatException 属于非受检异常,所以方法可以省略声明。
设计 API 时,受检异常适合显式提醒调用方采取行动的失败。非受检异常可以表达参数、状态或编程错误,也适合避免调用链每层重复声明的失败。分类依据是继承关系,恢复策略仍取决于场景,而非异常名称或严重程度。
3. 抛出、声明与捕获
失败发生后,方法可以抛出异常,由当前层处理或继续传给调用方。这条传播路径涉及三个关键字:
throw抛出一个具体异常对象。throws在方法签名中声明异常传播范围。catch捕获匹配的异常,让当前层执行恢复或转换逻辑。
Order loadOrder(long id) {
try {
return repository.find(id);
} catch (SQLException cause) {
throw new OrderStoreException("load order " + id, cause);
}
}
这里把数据库异常转换成应用定义的 OrderStoreException,让调用方依赖稳定的失败类型。构造器中的 cause 保存原始异常,排查时仍能看到数据库失败的原因。
捕获异常前,要明确当前层能够执行哪种处理策略:
- 对临时失败重试,并限定次数和总耗时。
- 降级为业务允许的替代结果。
- 清理当前层负责的资源或未完成操作。
- 补充订单编号等信息,转换异常后继续传播。
- 将内部异常映射成调用方需要的错误结果。
当前层缺少处理策略时,应让异常继续传播。捕获后返回 null 或空结果,会让调用方把执行失败误认为没有数据。
3.1 错误边界
错误边界是决定如何结束本次请求或任务的位置。Web 请求、消息消费者和定时任务的入口都可以承担这个职责。
异常到达边界后,入口把它转换为对外错误码、消息重试结果或任务状态。日志同时记录追踪标识,让一次失败能关联回对应请求。
同一个异常不应在每一层重复打印完整堆栈。中间层负责补充语义并传播,边界层负责形成一次可诊断记录。
4. 常见处理规则
异常处理需要保留失败信息,同时让正常分支保持清楚。实现时遵循以下传播与清理规则:
catch中明确执行恢复、转换或传播,空catch会直接隐藏失败。- 转换异常时,把原异常作为
cause保留下来。 - 循环结束或缓存未命中属于预期分支,使用条件判断和正常返回值表达。
finally用于清理资源并正常结束,在其中return会覆盖此前的返回值或异常。- 捕获
InterruptedException后继续传播,或用Thread.currentThread().interrupt()恢复中断标记。
try {
queue.take();
} catch (InterruptedException interrupted) {
Thread.currentThread().interrupt();
return;
}
示例在结束当前操作前恢复中断标记,让外层代码仍能识别取消请求。直接吞掉中断会丢失这个信号。
5. try-with-resources 与资源关闭
try-with-resources 把资源使用和关闭放在同一个语法结构中。资源实现 AutoCloseable 接口后,就提供了可由该语法调用的 close() 方法。控制流因执行完毕、return 或异常离开 try 主体时,都会调用资源的关闭方法。
try (
InputStream input = Files.newInputStream(source);
OutputStream output = Files.newOutputStream(target)
) {
input.transferTo(output);
}
上例打开输入流和输出流,随后复制数据。离开这个代码块时,遵循以下关闭规则:
- 多个资源按声明的逆序关闭,这里先关闭
output,再关闭input。 - 初始化中途失败时,已经成功创建的资源仍会关闭。
- 主体与
close()同时失败时,保留主体异常。关闭异常通过getSuppressed()读取。
这些关闭异常称为抑制异常,保存于主体异常的 suppressed 列表中。它们仍可用于诊断,只是由主体异常代表本次失败向外传播。
try {
run();
} catch (Exception failure) {
for (Throwable closeFailure : failure.getSuppressed()) {
// 记录资源关闭失败
}
}
5.1 资源所有权
关闭资源前,要确定哪个方法负责它的生命周期。当前方法只关闭自己创建或明确接管的资源。
调用方传入的流还可能用于后续操作,借用方应把关闭职责留给所有者。包装流还会按自身约定关闭底层流,因此 API 要写清传入资源是借用还是移交。
6. finally 与返回值
判断 finally 是否影响返回值,要看返回表达式在什么时候求值。执行 return 时,程序先算出表达式的值,再执行 finally,最后完成返回。因此,在 finally 中修改局部变量后,返回的仍是此前求出的基本类型值:
static int result() {
int value = 1;
try {
return value;
} finally {
value = 2;
}
}
System.out.println(result()); // 1
上例在进入 finally 前已经求出返回值 1。把局部变量改为 2 后,最终仍返回 1。
返回引用时,事先求出的是引用值,finally 仍可通过引用修改对象内容。如果 finally 自身执行 return 或抛出异常,就会覆盖此前的返回值或异常。因此,资源清理代码应正常结束,让原结果或原始失败继续传播。
多个 catch 按源码顺序匹配,所以子类异常要放在父类异常前,否则会被前面的分支覆盖。多重捕获 catch (A | B e) 要求 A 与 B 没有子类型关系,因为其中一种已能覆盖另一种时就无需并列。
7. 多个资源的关闭失败
多个资源都关闭失败时,程序需要保留一个向外传播的主异常,同时记录其余失败。try-with-resources 按声明逆序关闭资源。主体正常结束时,最先发生的关闭异常成为主异常,其余关闭异常进入它的 suppressed 列表。
例如,资源先声明 A,再声明 B。退出时先关 B,再关 A。两者都抛异常时,B 的异常先发生,因此成为主异常,A 的异常被附加进去。如果主体已经抛出异常,B 和 A 的关闭异常就都附加到主体异常中。
try-with-resources 在 Java 7 引入。Java 9 起,可以直接引用外部的 final 或有效 final 资源变量。有效 final 指变量虽然省略了修饰符,但赋值后未再被重新赋值。这些用法在 Java 17 和 21 中都可使用。
8. 常见问题
8.1 finally 一定会执行吗?
控制流离开 try/catch 时会执行 finally,包括正常完成、返回和异常传播。进程被强制终止、JVM 崩溃或机器断电时,执行会被打断,因此无法保证完成清理。
8.2 普通业务代码应该捕获 Throwable 吗?
普通业务应捕获自己能够处理的具体类型。catch (Throwable) 同时覆盖 Error 和 Exception,容易把严重系统问题当成可恢复的业务失败。如果框架边界需要统一捕获,就要明确记录哪些信息,以及何时终止执行。
8.3 同一个异常应该在哪一层记录日志?
完整堆栈在拥有请求或任务上下文的错误边界记录一次。中间层负责保留 cause、补充业务信息并继续传播。这样既能定位原始原因,也能避免同一失败产生多份重复堆栈。
9 · 面试题卡片
9.1 Error、Exception、受检异常和运行时异常有什么关系
出现公司:腾讯、京东、阿里云
考察重点
- 说明
Error、Exception和RuntimeException在继承体系中的位置。 - 根据继承关系判断异常是否需要捕获或声明。
相关内容:异常体系。
参考回答
Error 与 RuntimeException 及其子类属于非受检异常,其余异常类属于受检异常。
从继承关系看,Throwable 的常见分支是 Error 和 Exception,而 RuntimeException 继承自 Exception。
受检异常必须捕获或声明,非受检异常没有该编译要求。失败能否恢复,仍由具体调用场景决定。
9.2 项目中应该怎样处理异常
出现公司:美团、顺丰科技
考察重点
- 根据当前层能采取的行动,选择捕获、转换或继续传播。
- 说明怎样保留原始原因、记录日志,并形成稳定的对外错误结果。
参考回答
当前层能够恢复、清理或转换失败时才捕获异常,否则继续向外传播。
例如,临时故障可以有限重试,业务允许时可以降级,未完成操作则由负责它的层清理。基础设施异常进入应用层时,可以补充业务信息并转换类型,同时保留原始 cause。
Web、消息或任务入口统一映射对外结果,并记录一次包含追踪标识的完整日志。失败沿异常路径传播,避免被 null 或空结果隐藏。
9.3 try-with-resources 怎样保证资源关闭
出现公司:蚂蚁金服
考察重点
- 解释
AutoCloseable的作用,以及资源关闭顺序和初始化失败后的处理。 - 说明主体异常与关闭异常怎样同时保留。
相关内容:try-with-resources 与资源关闭。
参考回答
try-with-resources 在控制流离开 try 时,自动按声明逆序调用 AutoCloseable 资源的 close()。
初始化中途失败时,已经成功创建的资源也会关闭。因此,即使输出流创建失败,之前打开的输入流仍能得到清理。
主体与 close 同时失败时,主体异常优先。关闭异常放入 suppressed,调用方可同时诊断两次失败。
9.4 try 返回后 finally 修改局部变量,返回值会改变吗?
出现公司:暂无已核验的公开来源(本站自测)
考察重点:解释返回表达式的求值时机,以及 finally 返回或抛异常怎样改变结果。
相关内容:finally 与返回值。
参考回答
基本类型的返回值在进入 finally 前已经求出,随后修改局部变量不会改变它。
例如,try 准备返回 1,finally 再把局部变量改成 2,仍会返回 1。若 finally 自身返回或抛异常,则会覆盖原返回或异常。返回的是引用时,引用值已确定,但它指向的对象仍可能被 finally 修改。
9.5 主体成功但两个 close 都失败,哪个异常会向外传播?
出现公司:暂无已核验的公开来源(本站自测)
考察重点:按关闭顺序判断哪个异常向外传播,以及其余异常保存在哪里。
相关内容:多个资源的关闭失败。
参考回答
资源逆序关闭,最先发生的关闭异常向外传播。
声明 A、B 时先关闭 B,因此 B 的异常为主。A 的关闭异常进入 B 异常的 suppressed 列表。