一段读文件的代码在 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. 自测
- 三种写法里,
finally的问题是什么机制?为什么原始异常会消失? - try-with-resources 把关闭异常放到哪里?为什么"用对了语法但日志只打 message"仍看不到它?
- 多资源为什么是逆序关闭?声明顺序错会导致什么具体错误?
- 自定义资源实现
AutoCloseable时,close()为什么必须幂等? - "在 try 外创建资源"具体会在什么情况下泄漏?
- 判断"能否用
finally"的那个问题是什么?