qinghuan
← Java SE / 全部笔记

单元 26 / Java SE

P0 · 必会主线L1 · 基础

异常与资源释放:失败也要有出口

失败时给调用方明确结果,同时把文件、连接等资源关好,避免打印了错误却继续假装成功。

本篇目录 · 按需要跳转

读完这篇,你能做什么

能在合适层处理异常,并保证资源在成功与失败路径都被关闭。

学习理由、准备要求与知识点

为什么学它

错误传播与资源释放决定程序失败时是否仍保持可排查和可恢复。

先读这些内容

知识点清单

  • 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 就会负责调用关闭方法。

  1. 资源按声明顺序创建。
  2. 资源按相反顺序关闭。
  3. 如果后面的资源创建失败,前面已经成功创建的资源也会被关闭。

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["若无主异常,首个关闭失败成为主异常"]

按顺序看,每一步做了什么

  1. 确定当前层能否恢复或需要传播。
  2. 包装异常时保留 cause。
  3. 让资源所有权与 close 责任明确。
  4. 使用 try-with-resources 管理有限生命周期。
  5. 同时检查主异常、cause 和 suppressed。

03对照实现代码

次要异常如何附着到主异常 (新标签页)↗

Microsoft OpenJDK 17.0.20.1;java.base/java/lang/Throwable.java · java.lang.Throwable#addSuppressed(Throwable)

以下为原创 Java 17 教学实现,不是原样 JDK 源码;只保留文中明确讨论的机制,省略优化、兼容及内部接口。

核心机制代码 · java
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 也用独立数组返回记录。诊断工具需要考虑哪里被复制、哪里仍共享,以及并发变化,不要反射读取私有列表。

代码先调用谁,再调用谁

  1. 业务或关闭路径形成主异常
  2. try-with-resources 关闭已成功创建的资源
  3. 关闭再次失败时调用主异常 addSuppressed
  4. 诊断通过 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。

时间 O(V+E) 上界,V、E 为实际检查的节点与关系边;以异常访问器正常返回、成本与返回内容规模相符为前提。额外空间 O(V+E) 最坏上界,包含队列、身份集合和输出。

每个新节点至多展开一次,每条展开边至多入队一次;重复节点会跳过。邻接数组和队列可能大于最终输出数量。

保存为 Main.java,运行 javac --release 17 -encoding UTF-8 Main.java,再运行 java Main。程序包含固定输入自测,断言失败时会抛出错误。

展开收起代码 · Main.java · 完整可运行程序Java · 58 行

长行可在代码区域内左右滚动,按 Tab 可把焦点移入代码区。

Main.java · 完整可运行程序 · Java
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 道思维拓展,试着换个条件,看看原来的办法是否还管用。不附参考答案。

这次练什么

能在合适层处理异常,并保证资源在成功与失败路径都被关闭。

本篇重点加练

重点加练恢复范围与错误对外边界,检查失败处理是否仍保留真实业务状态。

  • 局部恢复与上抛
  • 日志与对外错误边界

重点内容会多练几次。先说清楚怎么做,再试试空数据、出错或条件改变时会发生什么。

先自己做,卡住时记下问题,再回来查。问答题写清“为什么、什么时候适用”;动手题留下输入、输出和运行步骤,方便下次重做。

基础必做

  1. 作业 01原理问答

    为文件找不到、业务余额不足、数组下标错误和运行环境严重故障建立异常分类表,分别说明调用者在编译和运行阶段承担什么责任;可提出不同领域建模但需说明依据。

    提交内容
    分类表与调用方处理合同。
    怎样算完成
    • 区分类别与业务可恢复性
    • 编译约束可由最小代码验证

    练到的知识点Throwable 分类 · checked/unchecked 约束

  2. 作业 02设计评审

    导入程序分为读文件、解析行、保存记录三层。规定坏行可报告、文件不可读终止、保存失败不得伪装成功,设计各层如何传递异常及保留原始原因(cause)。

    提交内容
    层次图、异常类型和失败报告草案。
    怎样算完成
    • 恢复范围具体
    • 原始原因可追溯

    练到的知识点局部恢复与上抛 · 保留 cause

综合应用

  1. 作业 03排错实验

    用两个自定义 AutoCloseable 资源记录 open/use/close 事件,分别注入获取第二资源失败、业务体失败以及一个或两个 close 失败;逐种运行并完整记录异常树。

    提交内容
    可运行 Java 实验、自写断言、事件与异常记录。
    怎样算完成
    • 覆盖资源未全部创建情况
    • 区分主异常与附属异常
    • 验证清理次序与执行次数

    练到的知识点try-with-resources · 资源逆序关闭 · suppressed 异常

  2. 作业 04面试场景

    支付接口发生包含银行卡片段和内部路径的异常。为客户端、客服检索和内部诊断分别设计错误信息,并说明如何关联同一失败而不复制敏感原文。

    提交内容
    三类错误示例模板和关联字段设计。
    怎样算完成
    • 客户端信息足以处理失败
    • 内部证据保留且敏感字段受控

    练到的知识点日志与对外错误边界

  3. 作业 05编程实践

    编写批量数字导入器:输入 ["12","bad","9",""],收集行号与失败类别,限制最多保存十条错误,成功记录与失败记录分别返回;不得捕获所有 Throwable 后继续。

    提交内容
    完整 Java 实现、自写边界测试与运行证据。
    怎样算完成
    • 失败行定位准确
    • 超过错误预算的行为有合同
    • 输入错误与程序故障分开

    练到的知识点局部恢复与上抛 · 保留 cause · suppressed 异常 · 日志与对外错误边界

  4. 作业 06编程实践

    编写三步本地导出模拟器:读取记录、格式化、交付结果,各步可注入失败;调用方只接收稳定错误类别与关联编号,内部保留原因链。

    提交内容
    Java 实现、自写各阶段失败及恢复测试、运行证据。
    怎样算完成
    • 恢复只发生于声明的阶段
    • 外部合同与内部证据能关联

    练到的知识点局部恢复与上抛 · 日志与对外错误边界

  5. 作业 07面试场景

    配置文件有一行非法值,程序可选择整份拒绝或保留其余行。分别制定两种政策下“启动成功”的定义和操作人员可看到的证据。

    提交内容
    两份成功/失败合同和案例表。
    怎样算完成
    • 部分成功不使用含糊表述
    • 恢复政策与配置一致性需求对应

    练到的知识点局部恢复与上抛

  6. 作业 08设计评审

    同一异常被 DAO、Service、Controller 各记录一次,报警系统据此计为三次失败。制定错误归属、关联与采样政策,并说明如何验证一次事件没有失去诊断信息。

    提交内容
    日志责任表与事件计数验收。
    怎样算完成
    • 日志条目和业务事件分开
    • 保留的信息足以追溯调用链

    练到的知识点日志与对外错误边界

进阶挑战 · 掌握前置后选做

  1. 作业 09设计评审

    完成网络基础后:某导入任务失败可能已经提交部分记录。评审“任何异常都自动重试三次”的提案,给出可重试条件、重复效果控制和人工介入边界。

    提交内容
    失败阶段表、重试政策与验证计划。
    怎样算完成
    • 按副作用阶段判断重试
    • 不以异常类名代替业务事实

    练到的知识点局部恢复与上抛 · try-with-resources · 日志与对外错误边界

思维拓展

先写出你的猜想,再用例子或实验验证。答案可以不同,但要说清依据、适用情况,以及还有什么没弄懂。

  1. 作业 10思维拓展 · 条件变化

    导出从同步调用改为后台任务,用户只看到任务编号。重新审查哪些异常信息应进入任务状态、何时允许恢复、哪些状态不可再被成功覆盖。

    提交内容
    任务状态图与事件竞争样本。
    怎样算完成
    • 异步结果有明确终结合同
    • 恢复决定仍依赖失败阶段

    练到的知识点局部恢复与上抛 · 日志与对外错误边界

完成后,再检验一次

给每题标记“独立完成 / 查阅后完成 / 尚未完成”。查过资料或没做出的题,记下卡在哪里。隔一天换一组输入再试,能解释结果和出错原因,再把这个知识点标为掌握。

继续阅读

先看能帮你完成当前练习的部分。例子跑通后,再读更深入的解释。

打开资料库,找下一份学习材料 ↗