Java 的 Suppressed Exception
别让异常"人间蒸发":Java 的 Suppressed Exception 与 try-with-resources
一个点讲透:为什么你的线上日志里只剩一句
Connection reset by peer,而真正的业务异常不见了。
一、从一个真实的排查现场说起
某次线上报警,日志里只有一行:
java.io.IOException: 流已关闭
at com.xxx.ExportService.write(ExportService.java:88)
第 88 行是 out.flush()。可业务同学坚称报错发生在更早的 buildReport() 里。翻遍日志,找不到 buildReport 的任何堆栈。
真相是:buildReport() 确实抛了异常,但它被 finally 里的 out.close() 抛出的异常覆盖了,并且永久消失。
这就是 Java 异常体系里最容易被忽略的一条规则:finally 块里抛出的异常,会替换掉 try 块里正在传播的异常。
二、根因:finally 的"异常覆盖"
看一段再普通不过的 JDK 6 风格代码:
OutputStream out = null;
try {
out = new FileOutputStream(file);
buildReport(out); // ← 真正的错误发生在这里,抛 IllegalStateException
out.flush();
} finally {
if (out != null) {
out.close(); // ← 这里也抛了 IOException,把上面的异常顶掉了
}
}
finally 的执行时机是在异常抛出之前、方法返回之前。JVM 规范里 finally 是通过把整段 try-catch 包进一个更大的 try 并用 athrow 重新抛出来实现的。这意味着:
try块抛出A,控制权交给finally;finally里执行close(),抛出B;finally是正常路径之外的代码,它的异常会直接中断"重新抛出 A"这一步;- 最终调用方只收到
B,A的堆栈对象失去了所有引用,被 GC 回收。
所以你看到的 IOException: 流已关闭,只是"凶手离开现场时顺手关上的那扇门",真正的凶手早就湮灭了。
这个坑的三个特点
- 只在高负载/异常路径出现——
close()平时不抛异常,只有底层出问题时才抛,而底层出问题往往正是因为你前面已经出错了。两个异常高度正相关,所以"能复现"的时候往往就是丢异常的时候。 - 丢掉的是因,留下的是果——
close()失败通常是前面失败导致的状态污染,保留果、丢掉因,等于把排查方向彻底带偏。 - 静态检查发现不了——这段代码完全合法,编译无警告,FindBugs 的老版本才会提示
OBL_UNSATISFIED_OBLIGATION。
三、JDK 7 的官方解法:try-with-resources 与 addSuppressed
try-with-resources(TWR)不只是语法糖,它在异常语义上做了实质修正。
try (OutputStream out = new FileOutputStream(file)) {
buildReport(out);
out.flush();
}
JLS 14.20.3.1 给出了它脱糖(desugaring)后的等价代码,这才是理解一切的关键:
{
final OutputStream out = new FileOutputStream(file);
Throwable primary = null;
try {
buildReport(out);
out.flush();
} catch (Throwable t) {
primary = t; // ① 先把 try 块的异常"抓住存起来"
throw t;
} finally {
if (out != null) {
if (primary != null) { // ② 已经有主异常了
try {
out.close();
} catch (Throwable closeEx) {
primary.addSuppressed(closeEx); // ③ 把 close 的异常"挂"到主异常上
}
} else {
out.close(); // ③' 没有主异常,close 的异常照常抛出
}
}
}
}
注意第 ③ 步:close() 的异常没有丢,也没有覆盖主异常,而是被 addSuppressed() 挂到了主异常的附属列表里。
调用方拿到的是主异常 IllegalStateException,close() 的 IOException 变成了它的 suppressed 异常:
catch (Throwable t) {
t.printStackTrace();
}
输出:
java.lang.IllegalStateException: 报表数据校验失败
at com.xxx.ExportService.buildReport(ExportService.java:42)
at com.xxx.ExportService.write(ExportService.java:86)
Suppressed: java.io.IOException: 流已关闭
at com.xxx.ExportService.write(ExportService.java:88)
Suppressed: 前缀就是 JVM 打印 suppressed 异常的标志。
三个 API 细节
| 行为 | 说明 |
|---|---|
Throwable.addSuppressed(Throwable) |
final synchronized 方法,把异常追加到抑制列表 |
Throwable.getSuppressed() |
返回数组副本,改它不影响原对象;没有任何 suppressed 时返回空数组(不是 null) |
| 自抑制 | t.addSuppressed(t) 抛 IllegalArgumentException: Self-suppression not permitted |
| 关闭抑制 | Throwable 有 protected 构造器 Throwable(String, Throwable, boolean enableSuppression, boolean writableStackTrace),第 3 个参数传 false 可关闭抑制能力(用于极致性能优化的异常类) |
顺带一提:
printStackTrace()从 JDK 7 起就会自动打印Suppressed:段;Logback / Log4j2 的ThrowableProxy同样会输出。所以只要异常对象本身没丢,日志框架不会漏。
四、TWR 之后仍然会踩的三个坑
换成 try-with-resources 不代表万事大吉,下面三个坑更隐蔽。
坑 1:日志里只打了 e.getMessage()
catch (Exception e) {
log.error("导出失败: {}", e.getMessage()); // ← suppressed 全丢了
}
getMessage() 只返回主异常的消息,suppressed 列表完全不在里面。正确写法是把异常对象作为最后一个参数传给日志框架:
catch (Exception e) {
log.error("导出失败", e); // ✅ Logback/Log4j2 会连同 Suppressed 一起打印
}
规则:日志里永远传异常对象,不要只传 getMessage()。 这一条比今天讲的所有内容都更常犯。
坑 2:自己实现的 close() 忘了 addSuppressed
TWR 的抑制机制只在编译器生成的 close() 调用上生效。如果你在 catch/finally 里手工清理资源,编译器帮不了你:
// ❌ 错误示范
Connection conn = pool.borrow();
try {
doWork(conn);
} catch (Exception e) {
try { conn.close(); } catch (Exception ignored) {} // close 的异常被吞了
throw e;
}
正确做法是照抄编译器的行为:
Connection conn = pool.borrow();
try {
doWork(conn);
return;
} catch (Throwable primary) {
try {
conn.close();
} catch (Throwable closeEx) {
primary.addSuppressed(closeEx); // ✅
}
throw primary;
}
conn.close();
更简单的规则:能用 TWR 就别手写清理。 AutoCloseable 实现起来只是一个 close() 方法,包装一下就能进 TWR。
坑 3:资源初始化阶段的异常
多个资源声明在一个 TWR 头里时,它们按声明顺序初始化,按逆序关闭。如果第 2 个资源的构造器就抛异常:
try (
Connection conn = dataSource.getConnection(); // ① 成功
Statement stmt = conn.createStatement() // ② 抛异常,stmt 根本没赋值
) {
stmt.execute(sql);
}
此时 conn 会被正常关闭,而 stmt 因为从未成功创建,不会被关闭(也没有可关的对象)。如果 conn.close() 此时又抛异常,它会作为 createStatement() 异常的 suppressed 出现——这部分编译器处理得很好。
但如果你写的是嵌套获取(在一个 close() 里拿另一个资源),就得自己保证异常安全了。
五、把它变成团队规范
三条可以直接进 Code Review 清单的规则:
- 所有资源必须用
try-with-resources,不允许手写try-finally关闭。CI 里可以用 ErrorProne 的MustBeClosedChecker或 SpotBugs 的OBL规则卡住。 - 禁止
catch (Exception ignored) {},尤其是资源清理路径。清理失败也是信息,要么addSuppressed,要么明确注释说明为什么可以吞。 - 日志必须传异常对象。正则扫描
log\.(error|warn)\(.*e\.getMessage\(\)可以直接拦掉大部分问题。
还有一个值得做的:在全局异常处理器里把 suppressed 显式展开,因为有些老的 JSON 日志序列化器(早期的 LogstashEncoder 配置、自研的 toString)会漏掉它:
private static List<String> flatten(Throwable t) {
List<String> out = new ArrayList<>();
for (Throwable s : t.getSuppressed()) {
out.add(s.getClass().getName() + ": " + s.getMessage());
out.addAll(flatten(s)); // suppressed 也可以有自己的 suppressed
}
return out;
}
六、一句话总结
finally 里的异常会覆盖主异常,这是 JVM 语义决定的;try-with-resources 通过 addSuppressed() 把清理异常挂载到主异常上,让因果同时保留。
但这一切的前提是——你得让异常对象完整地走进日志。所以真正的行动项只有两条:
- 用
try-with-resources,别手写finally清理; - 打日志传异常对象,别传
getMessage()。
排查线上问题时,最贵的成本从来不是修复,而是"错误信息本身就是错的"。
- 点赞
- 收藏
- 关注作者
评论(0)