try-with-resources 中 close 抛异常时被抑制异常的 getSuppressed 读取

写 Java 的 IO 代码时,多数人已经习惯用 try-with-resources 替代手写 finally 里的 close()。但真正遇到线上问题时,一个高频疑问是:主逻辑抛了异常,close() 也抛了异常,为什么日志里只看得到主异常?第二个异常去哪了?答案是被挂到了主异常的 suppressed 列表里,需要通过 Throwable.getSup

写 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 异常」怎么查

  1. 确认是否是 try-with-resources 场景:搜索代码里是否用 finally { rs.close(); } 手写关闭,手写代码里的 close 异常会直接覆盖或丢失,需要改成 try-with-resources 或手动 addSuppressed。
  2. 打印完整栈:把 log.error(e.getMessage()) 改成 log.error("xxx", e),或临时调用 ExceptionPrinter.nativeFormat(e),观察是否出现 Suppressed: 行。
  3. 检查是否达到 100 上限:若 e.getSuppressed().length == 100,说明有批量关闭场景,后续异常已被丢弃,需要拆分资源管理粒度或逐个 close 并单独记录。
  4. 检查异常是否被包装:new RuntimeException(msg) 不带 cause 会切断链路,正确写法是 new RuntimeException(msg, e)。
  5. 检查是否被 catch 后忽略:catch (IOException ignored) {} 是 suppressed 消失的头号元凶,至少在 catch 块里 log.warn。
  6. 注意 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 的包装异常。