Java 的 Suppressed Exception

举报
yd_231768394 发表于 2026/10/18 15:08:50 2026/10/18
【摘要】 别让异常"人间蒸发":Java 的 Suppressed Exception 与 try-with-resources一个点讲透:为什么你的线上日志里只剩一句 Connection reset by peer,而真正的业务异常不见了。 一、从一个真实的排查现场说起某次线上报警,日志里只有一行:java.io.IOException: 流已关闭 at com.xxx.ExportSer...

别让异常"人间蒸发":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: 流已关闭,只是"凶手离开现场时顺手关上的那扇门",真正的凶手早就湮灭了。

这个坑的三个特点

  1. 只在高负载/异常路径出现——close() 平时不抛异常,只有底层出问题时才抛,而底层出问题往往正是因为你前面已经出错了。两个异常高度正相关,所以"能复现"的时候往往就是丢异常的时候。
  2. 丢掉的是因,留下的是果——close() 失败通常是前面失败导致的状态污染,保留果、丢掉因,等于把排查方向彻底带偏。
  3. 静态检查发现不了——这段代码完全合法,编译无警告,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 清单的规则:

  1. 所有资源必须用 try-with-resources,不允许手写 try-finally 关闭。CI 里可以用 ErrorProne 的 MustBeClosedChecker 或 SpotBugs 的 OBL 规则卡住。
  2. 禁止 catch (Exception ignored) {},尤其是资源清理路径。清理失败也是信息,要么 addSuppressed,要么明确注释说明为什么可以吞。
  3. 日志必须传异常对象。正则扫描 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()。

排查线上问题时,最贵的成本从来不是修复,而是"错误信息本身就是错的"。

【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0)

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。