读完这篇,你能做什么
能在合适层处理异常,并保证资源在成功与失败路径都被关闭。
学习理由、准备要求与知识点
为什么学它
错误传播与资源释放决定程序失败时是否仍保持可排查和可恢复。
先读这些内容
知识点清单
- Throwable 分类
- checked
/unchecked约束 - 局部恢复与上抛
- 保留 cause
- try-with-resources
- 资源逆序关闭
- suppressed 异常
- 日志与对外错误边界
运行前先确认Java 17;源码在本机 Microsoft OpenJDK 17.0.20.1 src.zip 核验,在线链接为上游 jdk-17.0.20-ga。深入模块可在掌握基础后回读。
01先弄懂它是什么
异常:告诉调用者,这次没有正常完成
读取文件失败、输入不合法时,程序需要把失败说清楚,这就是异常的用途。
| 类别 | 调用者需要知道什么 |
|---|---|
| 受检异常 | 编译器要求捕获它,或在方法声明中说明可能抛出 |
RuntimeException 及其子类 | 没有相同的编译要求 |
Error | 通常表示不适合按普通业务失败恢复的严重问题 |
异常属于哪一类,不能直接决定是否忽略或重试。 还要看当前这层代码是否知道怎样处理。
捕获以后,要有明确的处理动作
根据当前场景,可以请用户修正输入、转换为接口错误、补充现场信息,或结束本次操作。
- 不要只打印错误,却仍然返回成功。 这会把真实失败藏起来。
- 包装异常时保留
cause,才能沿着异常链找到最初的问题。 - 清理文件、连接等资源时,优先使用
try-with-resources。 - 避免在
finally中加入不必要的return或再次抛出异常,以免盖住原先的结果。
cause 是引发当前异常的直接原因;suppressed 则用于记录主异常之外顺带发生的异常。例如,处理文件已经失败,关闭文件时又失败,这两个问题都值得保留。
资源怎样打开,就要约定怎样关闭
AutoCloseable 表示资源提供了 close() 方法。把实现它的资源放进 try-with-resources,Java 就会负责调用关闭方法。
- 资源按声明顺序创建。
- 资源按相反顺序关闭。
- 如果后面的资源创建失败,前面已经成功创建的资源也会被关闭。
Java 17 还可以使用符合要求的既有 final 或“事实上的 final”变量。后者指没有写 final,但初始化后没有重新赋值的变量。
资源所有权说的是谁负责最后关闭资源。约定清楚,才能避免没人关闭,也避免资源还要使用时就被别人关闭。
关闭连接,不等于提交事务,也不代表已经完成的外部操作会被撤销。 连接关闭、事务处理,以及失败后如何补救已经发生的业务操作,需要分别考虑。
文中这几个词,先认一认
- cause
- 引发当前异常的直接原因;保留它可以沿异常链查到最初发生的问题。
- suppressed
- 主异常以外顺带记录的异常,例如处理文件已失败,关闭文件时又失败。
- 受检异常
- 编译器要求调用方捕获,或在方法声明中说明可能抛出的异常。
- 资源所有权
- 约定谁负责最后关闭资源,避免没人关,也避免还要使用时被别人关掉。
02看看它怎么工作
当业务体抛异常而 close 也抛异常时,try-with-resources 通常保留业务异常作为主异常,把关闭失败附加到 suppressed。若业务体成功而关闭失败,关闭异常本身成为主异常;多个资源则按逆序关闭,后续关闭失败附加在已有主异常上。只读取 getMessage 而不看异常链会遗漏清理阶段的重要诊断。
cause 和 suppressed 记录的不是同一件事:cause 说明当前异常由什么引起,suppressed 保存那些没有作为主异常抛出的其他失败。因此,一份异常记录不一定只是从头到尾的一条链;同一个异常对象可能被多处引用,人为构造时甚至可以间接指回自己。日志工具遍历这些记录时,要记住已经访问过的异常对象,避免重复展开。不能只按错误文字去重,因为文字相同也可能是两次不同的故障。
try-with-resources 的语法减少手工 finally 的错误,但不会替资源实现保证 close 总成功。AutoCloseable 的关闭是否可重复调用、会抛什么异常,要看具体类型约定;Closeable 有更具体的关闭契约。图强调主失败与次要失败共同保留,清理失败既不能覆盖业务失败,也不能完全丢失。
正在加载流程图…
查看流程图源码(Mermaid)
flowchart TD
A["依次创建资源"] --> B["执行业务体"]
B --> C["逆序关闭已创建资源"]
B --> D["可能产生主异常"]
C --> E["可能产生关闭异常"]
D --> F["保留业务失败为主异常"]
E --> F
F --> G["关闭失败记录到 suppressed"]
C --> H["若无主异常,首个关闭失败成为主异常"]按顺序看,每一步做了什么
- 确定当前层能否恢复或需要传播。
- 包装异常时保留 cause。
- 让资源所有权与 close 责任明确。
- 使用 try-with-resources 管理有限生命周期。
- 同时检查主异常、cause 和 suppressed。
03对照实现代码
Microsoft OpenJDK 17.0.20.1;java.base/java/lang/Throwable.java · java.lang.Throwable#addSuppressed(Throwable)
以下为原创 Java 17 教学实现,不是原样 JDK 源码;只保留文中明确讨论的机制,省略优化、兼容及内部接口。
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
public final class SourceModel {
private final Throwable owner;
private final List<Throwable> secondary = new ArrayList<>();
public SourceModel(Throwable owner) { this.owner = Objects.requireNonNull(owner); }
public void add(Throwable failure) {
if (failure == owner) throw new IllegalArgumentException("self suppression");
Objects.requireNonNull(failure, "failure");
secondary.add(failure); // 保留顺序和身份,不按消息文字合并。
}
public List<Throwable> snapshot() { return List.copyOf(secondary); }
}真实 Throwable.addSuppressed 拒绝将异常自身附加给自己,也拒绝 null,然后根据内部是否启用 suppression 决定记录行为。记录列表在需要时建立,方法本身带同步。模型把 owner 与列表分开,只展示身份校验和顺序记录,不模拟禁用 suppression、惰性分配和同步保证。
禁止直接自我抑制并不意味着整张异常关系图绝对无环。两个异常仍可能间接互相引用,cause 关系也可能与 suppressed 共享节点。因此诊断遍历不应只检查当前节点是否等于自己,而应记录已经访问的对象身份,避免无限展开。
附加异常不会替主异常执行资源关闭,也不会使关闭操作自动重试。它只是保存诊断关系,通常由 try-with-resources 的语言展开逻辑在合适路径调用。将异常记录机制与实际资源管理动作分开理解,可以避免看到 suppressed 就推断资源已经成功释放。
模型复制了异常列表这个容器,但里面仍是原来的 Throwable 对象。这能防止别人改动列表位置,却不是把整张异常引用关系都冻结下来。真实 getSuppressed 也用独立数组返回记录。诊断工具需要考虑哪里被复制、哪里仍共享,以及并发变化,不要反射读取私有列表。
代码先调用谁,再调用谁
- 业务或关闭路径形成主异常
- try-with-resources 关闭已成功创建的资源
- 关闭再次失败时调用主异常
addSuppressed - 诊断通过
getSuppressed读取次要失败
04动手写一个例子
有界遍历异常原因与次要异常图
从一个 Throwable 开始广度优先读取消息,先加入 cause 再加入 suppressed,按对象身份去重。最多输出 limit 个节点,若还有未输出节点则标记 truncated;null 根视为没有异常。
- 输入 1
body 抑制 close,limit=10- 输出 1
[body,close],未截断
- 输入 2
a→b→a,limit=10- 输出 2
[a,b],未截断
- 输入 3
a→b,limit=1- 输出 3
[a],已截断
异常关系可能共享或成环,因此用基于身份的已访问集合和队列。每次取出节点后先判断是否已经访问,只有首次访问才输出并加入相邻异常。不同实例即使消息完全相同也应分别记录,同一实例经两条边抵达则只出现一次。
上限控制的是输出节点数量。遇到一个尚未访问的新节点而输出已满,才确认截断并返回;如果队列剩下的只是已访问节点,就不应错误报告截断。这个检查次序使环和共享关系不会被误当成遗漏的新故障。
本题只展示消息列表,真实诊断还应保留异常类型、节点标识、关系边和经过筛选的栈信息。getSuppressed 会提供数组快照,因此输出上限并不自动限制单个节点邻接数组的分配成本;复杂度按实际访问节点与关系边计算,不能只根据 limit 宣称绝对内存安全。
getCause 可以被子类覆写,因此每节点只读取一次并保存局部引用,避免判空与入队之间再次调用得到不同值;这仍不是整张关系图的并发一致性快照。
为什么这样写是对的seen 按身份保存已处理节点,输出顺序是 cause 优先的广度优先首次访问顺序,输出数量不超过 limit。
每个新节点至多展开一次,每条展开边至多入队一次;重复节点会跳过。邻接数组和队列可能大于最终输出数量。
保存为 Main.java,运行 javac --release 17 -encoding UTF-8 Main.java,再运行 java Main。程序包含固定输入自测,断言失败时会抛出错误。
展开收起代码 · Main.java · 完整可运行程序
长行可在代码区域内左右滚动,按 Tab 可把焦点移入代码区。
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.Collections;
import java.util.IdentityHashMap;
import java.util.List;
import java.util.Set;
public class Main {
record Trace(List<String> messages, boolean truncated) {}
static Trace trace(Throwable root, int limit) {
if (limit < 0) throw new IllegalArgumentException("limit");
if (root == null) return new Trace(List.of(),false);
Set<Throwable> seen = Collections.newSetFromMap(new IdentityHashMap<>());
ArrayDeque<Throwable> queue = new ArrayDeque<>();
List<String> output = new ArrayList<>();
queue.add(root);
while (!queue.isEmpty()) {
Throwable current = queue.remove();
if (!seen.add(current)) continue; // 共享或环只按对象身份处理一次。
if (output.size() == limit) return new Trace(List.copyOf(output),true);
output.add(String.valueOf(current.getMessage()));
// cause 先入队,随后按原记录顺序加入关闭等次要失败。
Throwable cause = current.getCause(); // 单次读取,避免可覆写访问器两次结果不同。
if (cause != null) queue.add(cause);
for (Throwable other : current.getSuppressed()) queue.add(other);
}
return new Trace(List.copyOf(output),false);
}
static void require(boolean condition) {
if (!condition) throw new AssertionError();
}
public static void main(String[] args) {
require(trace(null,0).equals(new Trace(List.of(),false)));
Exception a = new Exception("a"), b = new Exception("b");
a.initCause(b); b.initCause(a); // 间接环用于检验身份去重。
require(trace(a,10).equals(new Trace(List.of("a","b"),false)));
require(trace(a,1).equals(new Trace(List.of("a"),true)));
Exception shared = new Exception("same");
Exception top = new Exception("top",shared); top.addSuppressed(shared);
require(trace(top,10).messages().equals(List.of("top","same")));
Exception twins = new Exception("root");
twins.addSuppressed(new Exception("same")); twins.addSuppressed(new Exception("same"));
require(trace(twins,10).messages().equals(List.of("root","same","same")));
// 真实 try-with-resources 验证业务失败与关闭失败同时被保留。
try (AutoCloseable resource = () -> { throw new Exception("close"); }) {
throw new Exception("body");
} catch (Exception e) {
require(trace(e,10).equals(new Trace(List.of("body","close"),false)));
}
Throwable changing = new Exception("changing") {
private int reads;
@Override public synchronized Throwable getCause() {
return reads++ == 0 ? new Exception("child") : null;
}
};
require(trace(changing,10).equals(new Trace(List.of("changing","child"),false)));
System.out.println("PASS 7");
}
}这些特殊情况也要试一试
null根返回空而不是异常。- 零上限遇到非空根会标记截断。
- 间接环与共享引用不会无限输出。
- 相同消息的不同实例仍分别保留。
- 可覆写的 cause 访问器每节点只调用一次;遍历不提供并发一致性快照。
进阶选读 · 可选深入:结构化故障诊断与脱敏
场景多个服务包装异常,需要保留根因且控制日志大小。
做法为节点建立身份编号,记录 cause 与 suppressed 边,限制节点、栈帧和文本长度;筛除敏感字段并按请求关联,明确截断标记。
复杂度存储与输出随保留节点、边和文本总量增长。
好处与代价截断减少成本但可能丢失线索,应保留结构提示,不能伪装成完整故障图。
05项目里怎么用
处理异常要看当前层能做什么。用户格式错误可以变成输入提示;临时网络失败只有符合重复请求规则时才考虑重试;程序内部必须始终成立的规则被破坏时,应保留诊断并停止本次操作。不要捕获 Exception 后一律重试,也不要只记一句错误后仍返回成功。
资源关闭责任必须唯一明确。方法创建并独占的流通常由方法关闭,调用者传入的共享流则要按接口约定处理;关闭 System.out 或共享连接包装对象可能影响其它功能。事务提交与连接关闭不是同一动作,发生异常后是否回滚以及连接归还池的行为要按具体框架契约核对。
生产日志应保留异常链与必要上下文,同时控制重复记录和敏感数据。每层都打印同一异常会产生大量噪声,过度包装又会遮住真正业务位置。可以在边界统一记录,在中间层补充有用上下文并保留 cause;对 suppressed 也应保留入口,尤其是资源清理失败可能影响后续稳定性。
容易踩的坑
- finally 中 return 或抛新异常遮蔽原失败。
- 只看
getMessage,漏掉 cause 与 suppressed。 - 把关闭资源等同于提交或回滚事务。
- 捕获所有异常后返回成功或默认零。
几种做法,怎么选
表格有四列,窄屏可左右滑动查看代价和适用条件。
| 方案 | 优势 | 代价与限制 | 适用条件 |
|---|---|---|---|
| 受检异常 | 调用者必须面对声明的失败路径 | 签名传播与包装成本较高 | 可预期且调用者能处理的失败 |
| 运行时异常 | 适合不变量和编程错误传播 | 调用者容易忽略契约 | 非法参数与无法局部恢复的问题 |
| 显式结果类型 | 正常业务分支易于组合 | 需要设计结果与错误字段 | 预期拒绝、校验和可恢复业务结果 |
06检验是否读懂了
先自己想、自己写,再展开答案核对。场景题是假设的练习情境;遇到还没学过的内容,可以做完基础后再回来。
先用自己的话解释
01主异常与 suppressed 有什么关系?
展开参考答案与推导
主异常代表最终传播的主要失败,suppressed 保存已有主失败时发生的其它异常,常见于关闭阶段。它们不是把所有异常简单串成 cause 链,诊断时应分别读取关系。
02try-with-resources 资源按什么顺序关闭?
展开参考答案与推导
按成功创建的逆序关闭。后一个资源创建失败时,前面已创建资源仍会被关闭;关闭阶段的异常优先级按语言规则处理,不能假设只有业务体可能失败。
03为什么诊断去重按身份而非消息?
展开参考答案与推导
不同故障可能使用相同文字,同一个异常实例也可能被多个关系引用。身份集合能保留不同实例并阻止共享或环重复展开,按文字去重会丢失真实节点。
换个情况,还会处理吗
01业务体和 close 都抛异常,只看到业务消息怎么办?
展开参考答案与推导
检查 suppressed,而不是仅检查 cause 或 getMessage。try-with-resources 会保留关闭失败的关联,日志格式若未展示它,应调整诊断输出,避免忽略清理未完成的风险。
02方法收到调用者的输入流,应自动关闭吗?
展开参考答案与推导
先遵守资源所有权约定。若只是借用流读取一部分,关闭可能破坏后续调用;若明确转移所有权才负责关闭。方法签名和文档应让调用者知道生命周期责任。
03错误重试造成重复扣费,异常处理哪里不足?
展开参考答案与推导
重试需要业务幂等和明确的失败阶段证据,网络异常不证明远端未执行。不能只按异常类决定重复调用,应结合幂等键、结果查询或事务记录判断下一步。
写代码试一试
01增加零上限测试
- 输入 / 条件
单个异常a,limit=0- 输出 / 预期
空消息且 truncated=true
提示 根存在但没有输出容量。
展开参考答案与推导
断言 trace(new Exception("a"),0) 等于空列表加 true。它与 null 根的空列表加 false 不同,分别表示存在未输出内容与确实没有异常。
02输出异常类型和消息
- 输入 / 条件
IllegalArgumentException,bad- 输出 / 预期
类型与bad都保留
提示 用记录表示节点。
展开参考答案与推导
把字符串结果改成记录,保存类名和消息,并保持身份去重。不要依赖拼接后的文字作为去重键,否则两项同类同消息的不同异常仍会被错误合并。
03验证只有 close 失败的主异常
- 输入 / 条件
业务体成功,close抛close- 输出 / 预期
主消息close
提示 使用真实资源语句。
展开参考答案与推导
创建一个关闭时抛异常的 AutoCloseable,业务体正常结束,捕获后断言主消息为 close 且没有额外 suppressed。它与业务体也失败的样本共同验证异常优先级。
读完之后,独立完成
课后作业:自己做一遍
本篇共 10 道作业,先完成 8 道基础与综合任务,再按前置知识挑战进阶题。其中包含 1 道思维拓展,试着换个条件,看看原来的办法是否还管用。不附参考答案。
能在合适层处理异常,并保证资源在成功与失败路径都被关闭。
本篇重点加练
重点加练恢复范围与错误对外边界,检查失败处理是否仍保留真实业务状态。
- 局部恢复与上抛
- 日志与对外错误边界
重点内容会多练几次。先说清楚怎么做,再试试空数据、出错或条件改变时会发生什么。
先自己做,卡住时记下问题,再回来查。问答题写清“为什么、什么时候适用”;动手题留下输入、输出和运行步骤,方便下次重做。
基础必做
- 作业 01原理问答
为文件找不到、业务余额不足、数组下标错误和运行环境严重故障建立异常分类表,分别说明调用者在编译和运行阶段承担什么责任;可提出不同领域建模但需说明依据。
- 提交内容
- 分类表与调用方处理合同。
- 怎样算完成
- 区分类别与业务可恢复性
- 编译约束可由最小代码验证
练到的知识点Throwable 分类 · checked/unchecked 约束
- 作业 02设计评审
导入程序分为读文件、解析行、保存记录三层。规定坏行可报告、文件不可读终止、保存失败不得伪装成功,设计各层如何传递异常及保留原始原因(cause)。
- 提交内容
- 层次图、异常类型和失败报告草案。
- 怎样算完成
- 恢复范围具体
- 原始原因可追溯
练到的知识点局部恢复与上抛 · 保留 cause
综合应用
- 作业 03排错实验
用两个自定义
AutoCloseable资源记录 open/use/close事件,分别注入获取第二资源失败、业务体失败以及一个或两个 close 失败;逐种运行并完整记录异常树。- 提交内容
- 可运行 Java 实验、自写断言、事件与异常记录。
- 怎样算完成
- 覆盖资源未全部创建情况
- 区分主异常与附属异常
- 验证清理次序与执行次数
练到的知识点try-with-resources · 资源逆序关闭 · suppressed 异常
- 作业 04面试场景
支付接口发生包含银行卡片段和内部路径的异常。为客户端、客服检索和内部诊断分别设计错误信息,并说明如何关联同一失败而不复制敏感原文。
- 提交内容
- 三类错误示例模板和关联字段设计。
- 怎样算完成
- 客户端信息足以处理失败
- 内部证据保留且敏感字段受控
练到的知识点日志与对外错误边界
- 作业 05编程实践
编写批量数字导入器:输入 ["12","bad","9",""],收集行号与失败类别,限制最多保存十条错误,成功记录与失败记录分别返回;不得捕获所有 Throwable 后继续。
- 提交内容
- 完整 Java 实现、自写边界测试与运行证据。
- 怎样算完成
- 失败行定位准确
- 超过错误预算的行为有合同
- 输入错误与程序故障分开
练到的知识点局部恢复与上抛 · 保留 cause · suppressed 异常 · 日志与对外错误边界
- 作业 06编程实践
编写三步本地导出模拟器:读取记录、格式化、交付结果,各步可注入失败;调用方只接收稳定错误类别与关联编号,内部保留原因链。
- 提交内容
- Java 实现、自写各阶段失败及恢复测试、运行证据。
- 怎样算完成
- 恢复只发生于声明的阶段
- 外部合同与内部证据能关联
练到的知识点局部恢复与上抛 · 日志与对外错误边界
- 作业 07面试场景
配置文件有一行非法值,程序可选择整份拒绝或保留其余行。分别制定两种政策下“启动成功”的定义和操作人员可看到的证据。
- 提交内容
- 两份成功/失败合同和案例表。
- 怎样算完成
- 部分成功不使用含糊表述
- 恢复政策与配置一致性需求对应
练到的知识点局部恢复与上抛
- 作业 08设计评审
同一异常被 DAO、Service、Controller 各记录一次,报警系统据此计为三次失败。制定错误归属、关联与采样政策,并说明如何验证一次事件没有失去诊断信息。
- 提交内容
- 日志责任表与事件计数验收。
- 怎样算完成
- 日志条目和业务事件分开
- 保留的信息足以追溯调用链
练到的知识点日志与对外错误边界
进阶挑战 · 掌握前置后选做
- 作业 09设计评审
完成网络基础后:某导入任务失败可能已经提交部分记录。评审“任何异常都自动重试三次”的提案,给出可重试条件、重复效果控制和人工介入边界。
- 提交内容
- 失败阶段表、重试政策与验证计划。
- 怎样算完成
- 按副作用阶段判断重试
- 不以异常类名代替业务事实
练到的知识点局部恢复与上抛 · try-with-resources · 日志与对外错误边界
思维拓展
先写出你的猜想,再用例子或实验验证。答案可以不同,但要说清依据、适用情况,以及还有什么没弄懂。
- 作业 10思维拓展 · 条件变化
导出从同步调用改为后台任务,用户只看到任务编号。重新审查哪些异常信息应进入任务状态、何时允许恢复、哪些状态不可再被成功覆盖。
- 提交内容
- 任务状态图与事件竞争样本。
- 怎样算完成
- 异步结果有明确终结合同
- 恢复决定仍依赖失败阶段
练到的知识点局部恢复与上抛 · 日志与对外错误边界
完成后,再检验一次
给每题标记“独立完成 / 查阅后完成 / 尚未完成”。查过资料或没做出的题,记下卡在哪里。隔一天换一组输入再试,能解释结果和出错原因,再把这个知识点标为掌握。
继续阅读
先看能帮你完成当前练习的部分。例子跑通后,再读更深入的解释。
- 必读规范英文
Java 17 语言规范:异常 (新标签页)↗
阅读 checked
/unchecked异常分类及方法抛出约束,避免只背异常名称。 - 实践教程英文
Oracle:try-with-resources (新标签页)↗
阅读关闭顺序与 suppressed 异常;教程以较早 JDK 为背景,相关语义适用于 Java 17。
- 深入规范英文
Java 17 语言规范:类、方法与记录类 (新标签页)↗
按需阅读构造器、方法重载、static
/final、枚举和 record 的定义与约束。 - 深入参考文档英文
OpenJDK 17u java.lang.Throwable (新标签页)↗
定位
addSuppressed的自身与null校验、禁用状态和惰性列表记录。 - 深入参考文档英文
Java17 try-with-resources 规范 (新标签页)↗
阅读资源初始化、逆序关闭和异常优先级的语言语义。
- 深入参考文档英文
Java17 AutoCloseable API (新标签页)↗
确认 close 的异常与实现者契约,不假设全部资源可重复关闭。