写 Java 的 IO 代码时,多数人已经习惯用 try-with-resources 替代手写 finally 里的 close()。但真正遇到线上问题时,一个高频疑问是:主逻辑抛了异常,close() 也抛了异常,为什么日志里只看得到主异常?第二个异常去哪了?答案是被挂到了主异常的 suppressed 列表里,需要通过 Throwable.getSuppressed() 读取。遗憾的是,大量日志框架、全局异常处理器、甚至自研的 Result 包装类都只打印 e.getMessage() 和 stub 栈,suppressed 数组被整体丢弃,于是「资源释放失败」这个关键信息永久消失。
这篇文章解决三个具体问题:一是讲清 try-with-resources 在 close 抛出异常时到底做了什么(对应 JDK 的字节码行为);二是给出可直接运行的代码,演示 getSuppressed() 的读取方式与常见陷阱(suppressed 默认关闭、只保留最近 100 个、数组不可变但元素可变等);三是给出生产可用的日志输出方案和排障清单。所有示例基于 JDK 17 验证,JDK 8/11 行为一致。
一、先搞清楚:close 抛异常时到底发生了什么
try-with-resources 的语义在 JLS 14.20.3.1 中有明确定义,编译后大致等价于下面这段手写逻辑(以单个资源为例):
// 编译器为 try (Resource r = open()) { body } 生成的等价逻辑(简化示意)
Resource r = open();
Throwable primary = null;
try {
body(r);
} catch (Throwable t) {
primary = t;
throw t;
} finally {
if (r != null) {
if (primary != null) {
try {
r.close();
} catch (Throwable closeEx) {
primary.addSuppressed(closeEx); // 关键:close 异常被“抑制”
}
} else {
r.close(); // 没有主异常时,close 异常正常抛出
}
}
}
由此得到两条必须记住的规则:
- 有主异常时,close() 抛出的异常不会覆盖主异常,而是调用
primary.addSuppressed(closeEx),最终抛出的仍是主异常,栈信息只显示主异常。 - 没有主异常时,close() 的异常就是唯一异常,正常向上抛出,此时它不是 suppressed,
getSuppressed().length为 0。 - 多个资源按声明顺序的逆序关闭,因此 suppressed 数组的顺序是「后声明的资源先 close」,越靠前的元素越晚被关闭。
很多人误以为 suppressed 只用于 try-with-resources,其实它是 Throwable 的通用机制(JDK 7 引入),只是 try-with-resources 是最大的使用者。用 addSuppressed 手动挂载同样有效。
二、最小可运行示例:制造双异常并读取 getSuppressed
下面这段代码不依赖任何第三方库,直接 javac + java 就能跑。它自定义了一个 AutoCloseable,在 close 时抛异常,用来复现场景。
public class SuppressedDemo {
static class BrokenResource implements AutoCloseable {
private final String name;
BrokenResource(String name) { this.name = name; }
void work() { throw new IllegalStateException("业务处理失败: " + name); }
@Override
public void close() {
throw new RuntimeException("资源释放失败: " + name);
}
}
public static void main(String[] args) {
try (BrokenResource r = new BrokenResource("db-conn-1")) {
r.work();
} catch (Exception e) {
System.out.println("主异常类型: " + e.getClass().getName());
System.out.println("主异常消息: " + e.getMessage());
Throwable[] suppressed = e.getSuppressed();
System.out.println("suppressed 数量: " + suppressed.length);
for (Throwable s : suppressed) {
System.out.println(" suppressed: " + s.getClass().getName()
+ " -> " + s.getMessage());
}
}
}
}
实际输出(JDK 17):
主异常类型: java.lang.IllegalStateException
主异常消息: 业务处理失败: db-conn-1
suppressed 数量: 1
suppressed: java.lang.RuntimeException -> 资源释放失败: db-conn-1
如果把抛异常的 r.work() 注释掉,输出会变成主异常为 RuntimeException、suppressed 数量: 0——这正是很多人误判「close 没抛异常」的原因:它抛了,只是变成了主异常本身。
注意:
e.printStackTrace()是会打印 suppressed 的,格式为Suppressed: java.lang.RuntimeException: ...。所以如果你在控制台看到过 “Suppressed:” 字样,那就是它。但绝大多数线上日志只打印了e.toString()或自定义的 message,才导致信息丢失。
三、getSuppressed 的四个真实陷阱
1. 返回的是内部数组的副本,元素本身仍可被修改
getSuppressed() 返回 Throwable[],实现上调用 Arrays.copyOf,所以修改数组元素不影响原异常,但元素(Throwable 对象)是同一引用。不要把返回数组当成缓存长期持有,异常对象可能很大。
2. 数量上限是 100,超出部分被静默丢弃
JDK 的 Throwable 内部有个常量 SUPPRESSED_CAPACITY = 100(源码位置 java.lang.Throwable),addSuppressed 在数组满 100 个之后直接 return,不报错、不警告。在「连接池批量关闭」这类场景下,101 个之后的 close 异常会彻底消失。这不是 bug,是 JDK 防止内存膨胀的设计。排查批量资源泄漏时,看到 100 这个数字就要怀疑被截断了。
3. 禁用抑制机制会让整个特性失效
构造 Throwable 时传 writableStackTrace=false 只影响栈,不影响 suppressed;但如果你在全局配置了 -XX:-OmitStackTraceInFastThrow 之外的某些 JVM 参数,或使用自定义异常类重写了 addSuppressed,就可能出问题。更常见的是框架层:一些 RPC / ORM 的异常包装类只保留 cause,在 new XxxException(msg, cause) 时把主异常吞掉,suppressed 自然丢失。自定义异常务必保留构造参数 Throwable cause,且不要用 initCause 覆盖已有的 cause。
4. suppressed 不会自动递归到 cause 链
假设主异常有 cause,cause 也有 suppressed,你只遍历 e.getSuppressed() 是拿不到的。完整读取需要「异常链 + suppressed 树」双重遍历,见下一节。
四、生产可用:完整遍历异常链与 suppressed 的工具方法
下面的工具类解决两个问题:把 cause 链上每一层的 suppressed 都打出来,并用 Set<Throwable> 去重,防止循环引用或同一异常被多次打印(框架里 initCause 互指的情况真实存在,会引发 StackOverflowError)。
import java.io.PrintWriter;
import java.io.StringWriter;
import java.util.Collections;
import java.util.IdentityHashMap;
import java.util.Set;
public final class ExceptionPrinter {
private ExceptionPrinter() {}
/** 把异常链 + suppressed 树渲染成字符串,用于日志 */
public static String render(Throwable root) {
StringBuilder sb = new StringBuilder(512);
append(sb, root, 0, Collections.newSetFromMap(new IdentityHashMap<>()));
return sb.toString();
}
private static void append(StringBuilder sb, Throwable t, int depth, Set<Throwable> seen) {
if (t == null || !seen.add(t)) {
return;
}
String indent = " ".repeat(depth);
sb.append(indent).append(t.getClass().getName());
if (t.getMessage() != null) {
sb.append(": ").append(t.getMessage());
}
sb.append(System.lineSeparator());
for (Throwable s : t.getSuppressed()) {
sb.append(indent).append(" [suppressed] ");
append(sb, s, depth + 2, seen);
}
if (t.getCause() != null) {
sb.append(indent).append(" [caused by] ");
append(sb, t.getCause(), depth + 2, seen);
}
}
/** 只取根因,跳过 suppressed 和 cause 链 */
public static Throwable rootCause(Throwable t) {
Throwable cur = t;
Set<Throwable> seen = Collections.newSetFromMap(new IdentityHashMap<>());
while (cur.getCause() != null && seen.add(cur)) {
cur = cur.getCause();
}
return cur;
}
/** 统计整条链(含 suppressed)上的异常总数,用于监控埋点 */
public static int countAll(Throwable t) {
return count(t, Collections.newSetFromMap(new IdentityHashMap<>()));
}
private static int count(Throwable t, Set<Throwable> seen) {
if (t == null || !seen.add(t)) {
return 0;
}
int n = 1;
for (Throwable s : t.getSuppressed()) {
n += count(s, seen);
}
n += count(t.getCause(), seen);
return n;
}
/** 保留 JDK 原生格式(含 Suppressed: 行),适合排查时直接输出 */
public static String nativeFormat(Throwable t) {
StringWriter sw = new StringWriter();
t.printStackTrace(new PrintWriter(sw));
return sw.toString();
}
}
验证一下多层结构:主异常有 cause,cause 上又挂了 suppressed。
public class ChainDemo {
public static void main(String[] args) {
RuntimeException cause = new RuntimeException("底层连接超时");
cause.addSuppressed(new IllegalStateException("关闭 socket 失败"));
Exception top = new IllegalStateException("查询失败", cause);
top.addSuppressed(new RuntimeException("释放 PreparedStatement 失败"));
System.out.println(ExceptionPrinter.render(top));
System.out.println("链上异常总数: " + ExceptionPrinter.countAll(top));
}
}
输出:
java.lang.IllegalStateException: 查询失败
[suppressed] java.lang.RuntimeException: 释放 PreparedStatement 失败
[caused by] java.lang.RuntimeException: 底层连接超时
[suppressed] java.lang.IllegalStateException: 关闭 socket 失败
链上异常总数: 4
五、与日志框架对接:别让 suppressed 死在日志采集前
SLF4J 的占位符 log.error("msg", e) 会走 Logback/Log4j2 的 ThrowableProxy,它们是支持 suppressed 的,会输出 Suppressed: 行,也会递归 cause。真正出问题的是这几类写法:
| 写法 | 是否包含 suppressed | 说明 |
|---|---|---|
log.error("msg", e) |
是 | 推荐,Logback 1.2+ / Log4j2 均支持 |
log.error("msg " + e.getMessage()) |
否 | 只有 message,栈和 suppressed 全丢 |
log.error("msg", e.toString()) |
否 | toString 只含类名+message |
log.error("msg", e.getMessage(), e) |
是(但参数错位) | 占位符不匹配时 SLF4J 会把最后一个 Throwable 当异常,侥幸可用但不推荐 |
| 自定义 Result 只存 code + msg | 否 | 需显式追加 ExceptionPrinter.render(e) |
如果是统一的全局异常处理器(@RestControllerAdvice),建议在写日志时附加结构化字段:
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
@RestControllerAdvice
public class GlobalExceptionHandler {
private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class);
@ExceptionHandler(Exception.class)
public String handle(Exception e) {
// 主异常交给框架打印(含 suppressed 和 cause)
log.error("请求处理失败, 异常总数={}", ExceptionPrinter.countAll(e), e);
// 单独把 suppressed 抽出来做监控埋点,例如上报到 Prometheus / SLS
for (Throwable s : e.getSuppressed()) {
log.warn("资源关闭异常被抑制: {}", s.getClass().getName());
}
return ExceptionPrinter.rootCause(e).getMessage();
}
}
注意 rootCause 返回的是 cause 链的末端,不是 suppressed 里的异常。业务上判断「是不是连接池问题」应以主异常类型为准,suppressed 只作为补充证据。
六、排障清单:线上「日志里没有 close 异常」怎么查
- 确认是否是 try-with-resources 场景:搜索代码里是否用
finally { rs.close(); }手写关闭,手写代码里的 close 异常会直接覆盖或丢失,需要改成 try-with-resources 或手动addSuppressed。 - 打印完整栈:把
log.error(e.getMessage())改成log.error("xxx", e),或临时调用ExceptionPrinter.nativeFormat(e),观察是否出现Suppressed:行。 - 检查是否达到 100 上限:若
e.getSuppressed().length == 100,说明有批量关闭场景,后续异常已被丢弃,需要拆分资源管理粒度或逐个 close 并单独记录。 - 检查异常是否被包装:
new RuntimeException(msg)不带 cause 会切断链路,正确写法是new RuntimeException(msg, e)。 - 检查是否被 catch 后忽略:
catch (IOException ignored) {}是 suppressed 消失的头号元凶,至少在 catch 块里log.warn。 - 注意 finally 中的 return:
finally { return result; }会吞掉 try 块里的异常,连 suppressed 都不会产生,这是更隐蔽的坑。
常见的真实报错长这样,看到就要立刻联想到 suppressed:
java.sql.SQLException: Connection is closed
at com.zaxxer.hikari.pool.HikariProxyConnection.checkException(HikariProxyConnection.java)
Suppressed: java.sql.SQLException: Connection is closed
at com.zaxxer.hikari.pool.ProxyConnection.close(ProxyConnection.java:239)
这里的 Suppressed 行就是 getSuppressed 的内容,说明主流程失败的同时连接也已被关闭,往往指向连接池配置不当(maxLifetime 小于数据库 wait_timeout)或连接未归还。
小结
try-with-resources 在 close 抛异常时并不会「吞掉」异常,而是通过 Throwable.addSuppressed 把它挂到主异常上,读取入口是 getSuppressed()。真正让信息消失的是三件事:日志只打 message、异常包装时丢了 cause、以及 100 个上限的静默截断。把 log.error("msg", e) 用对,再配一个能遍历 cause + suppressed 树的工具方法,绝大多数「资源释放失败看不到原因」的问题都能当场定位。
FAQ
Q1:getSuppressed() 返回 null 吗?
不会,无 suppressed 时返回长度为 0 的数组(JDK 中为共享的空数组),可以直接 for 循环,不需要判 null。但元素可能是 null,严谨写法里加个判空。
Q2:为什么我 catch 到的是 close 的异常,而不是业务异常?
说明 try 块里没有抛异常,close 异常就是唯一异常,它不进入 suppressed。如果业务异常确实发生了,检查是否被 catch 后转换成了 null 返回值,或 finally 里有 return。
Q3:suppressed 异常需要单独 catch 吗?
不需要也无法直接 catch,它只作为主异常的附属信息存在。要处理释放失败的逻辑,应该在 close 方法内部自行处理并记录,而不是依赖调用方去读 suppressed。
Q4:可以手动给异常加 suppressed 吗?
可以,e.addSuppressed(other),但同一对象重复添加会被忽略,且 addSuppressed 传入 this 会抛 IllegalArgumentException(”Self-suppression not permitted”)。
Q5:JDK 8 和 JDK 17 行为有差异吗?
核心语义一致。JDK 7 引入该特性,JDK 9 之后 Throwable 内部实现有优化(如延迟创建栈、空的 suppressed 数组共享),但 SUPPRESSED_CAPACITY = 100 这个上限在各版本都相同。
Q6:Spring 的 NestedRuntimeException 会保留 suppressed 吗?
会,它只是包装 cause,原异常对象引用不变,getSuppressed() 依然可读。但注意要通过 getMostSpecificCause() 或 getRootCause() 拿到根因后再读 suppressed,否则你可能阅读的是最外层无 suppressed 的包装异常。