2508 字
约 8 分钟
15
S04-A17 · try-with-resources:close 抛异常时谁被吞掉

一段读文件的代码在 finally 里 close(),而 close() 也抛了异常。结果日志里只有 close 的异常,真正的原因(读失败)消失了——因为finally 里抛出的异常会覆盖 try 里的异常。换成 try-with-resources 后,日志里出现了 Suppressed: ...,真正的原因回来了。

资源关闭时的异常覆盖,是"错误信息丢失"的最常见来源之一。这个问题有个"沉默"特征:代码路径本身是成功的(资源确实关闭了),只是"错误信息少了一条"。所以它不会被测试发现,只会在事故复盘时表现为"日志里看不出原因"。
而它的修法在 Java 7 之后有了标准答案,只是很多人仍在用 finally 手工关。


1. 三种写法的差异

写法 主异常 关闭异常
finally { close(); } 被覆盖 成为主异常
try-with-resources 保留 进入 getSuppressed()
手工 try/catch 关闭 取决于写法 可能被吞

第一项的机制很直接:finally 里抛出异常,会替换掉 try 块正在传播的异常第二项(手工 try/catch)的坑在"顺序":先捕获原异常、再关闭、再重新抛出——顺序错一处,就变成覆盖或吞掉。而 try-with-resources 把这段顺序交给编译器。
,于是最重要的信息(原始失败原因)丢失。

第二项是正解:主异常保留,关闭异常附加在 suppressed 列表里,两者都能看到。

// 反例:finally 里的 close 覆盖了原始异常
Reader r = open();
try { read(r); }            // 抛 IOException("磁盘错误")
finally { r.close(); }      // 也抛(如 "句柄已失效")
// 结果:调用方只看到 close 的异常,原始原因丢失

// 正例:try-with-resources
try (Reader r = open()) {
    read(r);                // 原始异常成为主异常
}                           // close 的异常进入 getSuppressed()

2. suppressed 怎么看

方式 输出
printStackTrace() 含 Suppressed: 段
getSuppressed() 数组,可遍历
日志框架 多数会把 suppressed 打出来
只打 getMessage() 看不到

第二项(getSuppressed())在"需要汇总上报"时有用:可以把它转成结构化字段(错误码列表),而不是只留一段文本。
第四项是实践中的问题:很多自定义日志只记录了 e.getMessage(),于是 suppressed 永远不出现。

所以"用 try-with-resources"只是第一步,日志方式也要能输出 suppressed。

// 记录异常时用完整对象,而不是字符串
log.error("read failed", e);        // 对:含栈与 suppressed
log.error("read failed: " + e);     // 错:只有 message

// 需要自定义汇总时,显式遍历
for (Throwable s : e.getSuppressed()) {
    log.warn("suppressed while closing", s);
}

3. 资源关闭顺序

声明顺序 关闭顺序
先声明 A,后声明 B 先关 B,后关 A
多个资源 逆序

第二项的"逆序"是有意的:后声明的资源可能依赖先声明的(如 Writer 依赖 Stream),所以必须后进先出。

这也意味着声明顺序错了会导致"关了一半"的奇怪错误第二项(逆序)还有一个"缓冲"层面的后果:带缓冲的流必须比底层流后关,否则 flush 时底层已关闭,数据丢失而抛异常——而异常又被覆盖,于是"文件少了一段"但日志里只有关闭失败。
:如果一个 BufferedWriter 在底层流之后声明,那么关闭时底层流先断,buffer 里的数据就写不出去。

// 正确:外层依赖在内层之后声明(关闭时先关外层)
try (InputStream in = new FileInputStream(f);
     BufferedInputStream buf = new BufferedInputStream(in)) {
    // 关闭顺序:buf → in
}
// 反例:把 buf 声明在 in 之前,则 in 先关,buf 的 flush 会失败

4. 自定义资源该怎么写

要求 说明
实现 AutoCloseable 必要条件
close() 幂等 多次调用无副作用
不抛受检异常(或声明清楚) 影响调用方
关闭失败要留痕 不能静默

第四项(关闭失败要留痕)与"吞异常"是同一件事的两面:能吞(不致命)但要记。完全不记,等于"资源可能没关"这件事永远不可见。
第二项常被忽略:框架可能调用 close() 两次(显式关 + 容器回收),而第二次抛异常会让"正常关闭"变成错误。

第三项的选择取决于设计:AutoCloseable 允许抛 Exception,Closeable 只能抛 IOException。若不打算抛,用后者更友好。

// 幂等且安全的 close
public class MyResource implements AutoCloseable {
    private boolean closed = false;
    @Override public synchronized void close() {
        if (closed) return;              // 幂等
        closed = true;
        try { flush(); } catch (IOException e) { log.warn("flush on close failed", e); }
    }
}

还有一个"多层资源"的写法值得记:资源数量多时,按"依赖顺序"分组声明,而不是挤在一个 try 里。因为一个 try 里的所有资源共享同一个异常上下文,一处失败就全部不执行。

// 分组:独立的资源分开声明,互不影响
try (var cfg = loadConfig()) {
    try (var pool = openPool(cfg); var cache = openCache(cfg)) {
        serve(pool, cache);      // 两个资源互不依赖 → 同一组可接受
    }
}
// 好处:cfg 关闭时,pool/cache 已关;异常链仍保留

5. 五个常见误用

误用 后果
在 try 外创建资源 异常时未关闭
在循环里创建不关 句柄泄漏
用 finally 手工关 异常覆盖
关闭时吞掉异常 静默失败
依赖 GC 关闭 不确定

第一项最典型:Reader r = open(); try (r) {...} 在 Java 9 之后合法,但如果 open() 抛异常,try 根本没开始——这没问题;真正的问题是"打开后、进入 try 之前"的代码抛异常。

第三项与"Java 7 之前的老代码"有关:那个年代的写法就是 finally 手工关第四项(依赖 GC 关闭)在"文件句柄耗尽"的故障里最常见:GC 不保证及时回收,而句柄是有限资源。所以"反正有 finalize"是不可依赖的假设。
,而迁移到 try-with-resources 通常只是语法改写,收益很大。

验证"资源确实被关闭"有一个低成本手段:给自定义资源加"关闭计数",在测试里断言"打开 N 次、关闭 N 次"。

// 可观测的资源:计数 + 断言
public class TrackingResource implements AutoCloseable {
    static final AtomicInteger opened = new AtomicInteger();
    static final AtomicInteger closed = new AtomicInteger();
    public TrackingResource() { opened.incrementAndGet(); }
    @Override public void close() { closed.incrementAndGet(); }
}
// 测试:跑完业务后断言 opened.get() == closed.get()

6. 排错:五种"资源相关问题"

现象 成因 处理方向
只看到 close 的异常 用了 finally 改 try-with-resources
日志里没有 Suppressed 只打 message 打完整对象
文件被写一半 关闭顺序错 调整声明顺序
句柄泄漏 循环里未关 用 try-with-resources
关闭时报"已关闭" close 非幂等 加幂等判断

第三项(关闭顺序错)的排查方式是"打印关闭顺序":在自定义资源的 close() 里打一行。顺序错了会立刻看出来,比读声明位置快。
第二项最容易被忽略:代码改对了,但日志方式没改,于是"看起来还是没信息"。

最后一条"跨语言"的对照:Go 用 defer,Python 用 with,C# 用 using——它们都解决同一个问题,而解决方式都是"把关闭的异常附在原始异常上(或至少不覆盖)"。所以这个问题不是 Java 特有,而是"手工关资源"的通用陷阱。

// 三种语言的处理方式(同一问题的三种答案)
Java:   try-with-resources → suppressed 列表
Python: with 语句 → __exit__ 返回 False 时原异常继续传播
Go:     defer close  → 需自行记录 close error(否则丢失)
// 结论:Go 的 defer 需要"显式记录",最容易被忽略

7. 边界:什么情况 finally 也能接受

场景 为什么 做法
只需要"尽力关闭",不关心异常 关闭失败不影响结果 finally + 吞掉 + 日志
资源不实现 AutoCloseable 语法不支持 手工关 + 日志
关闭逻辑有额外语义(如提交事务) 需要显式控制 手工但显式处理异常
代码在 Java 6 及更早 语言不支持 手工关

判断口径一句话:关闭失败时,"原始异常"是否重要。最后一条与"迁移"有关的边界:老代码迁移到 try-with-resources 时,最容易漏掉"隐式关闭":某些框架会在流上包一层(如压缩流),而"关闭外层"是否等于"关闭全部",取决于实现。所以迁移后要跑一次"资源泄漏"的验证。
重要(排查需要原始原因),就必须用 try-with-resources;不重要(关闭失败可忽略),finally 也能接受。


8. 自测

  1. 三种写法里,finally 的问题是什么机制?为什么原始异常会消失?
  2. try-with-resources 把关闭异常放到哪里?为什么"用对了语法但日志只打 message"仍看不到它?
  3. 多资源为什么是逆序关闭?声明顺序错会导致什么具体错误?
  4. 自定义资源实现 AutoCloseable 时,close() 为什么必须幂等?
  5. "在 try 外创建资源"具体会在什么情况下泄漏?
  6. 判断"能否用 finally"的那个问题是什么?
S04-A17 · try-with-resources:close 抛异常时谁被吞掉
https://www.clxhxhhr.top/posts/6523/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。