
上周我在团队里做代码评审一位新同事提交了一个文件导出的功能读CSV、写TXT逻辑本身没毛病但资源关闭用的是经典的 try-catch-finally 三段式。我在评审意见里写了一句这里建议改成 try-with-resources。同事有点不服气说 finally 不也能关资源吗我当时没在评论里长篇大论只回了一句你先去实测一下如果 try 块里抛了异常close 的时候又抛一个异常线上错误日志里你还找得到原始异常吗这不是风格偏好问题这是一个很实际的坑。try-with-resources 不是 Java 7 的新特性炫技它是 JDK 设计者在吃了无数教训之后给资源管理出的标准答案。今天我把这个知识点完整拆一遍语法对比、执行顺序、多资源关闭、suppressed 异常机制、常见踩坑以及我这些年实际排查过的几个案例一次性说透。无论你是刚学 Java 的在校生还是写了三年还在用 finally 关流的老开发这篇文章都值得你花十分钟看完。1. 内容整体设计与思路拆解1.1 两种写法放在一起差距不只是代码量先说最常见的老写法。假设你要读一个文件大家习惯性写成这样FileInputStream fis null; InputStreamReader reader null; BufferedReader br null; try { fis new FileInputStream(data.txt); reader new InputStreamReader(fis, StandardCharsets.UTF_8); br new BufferedReader(reader); String line br.readLine(); // 各种业务处理 } finally { if (br ! null) { br.close(); } if (reader ! null) { reader.close(); } if (fis ! null) { fis.close(); } }这段代码有三个肉眼可见的问题一是啰嗦三个资源的判空关闭就占了六行二是顺序敏感如果 br.close() 抛异常reader 和 fis 的关闭就永远不会执行到三是 mask exception也就是异常遮蔽假如 try 里面 readLine 抛了一个真正需要关注的业务异常而 finally 里 close 又抛了一个 IOException那方法最终抛出去的会是 close 的异常真正的业务异常就这么悄无声息地被盖掉了。同样的功能用 try-with-resources 写是这样try (FileInputStream fis new FileInputStream(data.txt); InputStreamReader reader new InputStreamReader(fis, StandardCharsets.UTF_8); BufferedReader br new BufferedReader(reader)) { String line br.readLine(); // 各种业务处理 }代码量从十几行缩到几行而且三个资源的声明集中在 try 后面的括号里生命周期边界一目了然。你根本不需要在最下面写那一堆判空和关闭的逻辑编译器会帮你把这些事全部编排好。1.2 设计思路把“关闭”变成一种约定而不是靠自觉有人可能会想编译器帮我们加了关闭逻辑那它到底加了什么核心是两点第一所有可以自动关闭的资源都必须实现一个统一的接口——AutoCloseable 或者它的子接口 Closeable第二编译器会把 close 调用编排到语法上等价于 finally 的位置但异常处理策略比手动 finally 要聪明得多。我见过不少开发者以为 try-with-resources 只是语法糖编译之后和 try-finally 一样。这个理解不算全错字节码层面确实会生成 finally 块来调用 close但异常处理逻辑完全不同。手写 finally 时如果 try 和 close 都抛异常close 的异常会把 try 的异常顶掉而 try-with-resources 会把 close 的异常作为一个被抑制的异常suppressed exception挂到 try 块原始异常的头上然后用 addSuppressed 方法把它记录下来。用大白话说老办法默认“后来者居上”后面的异常覆盖前面的异常新办法默认“先来后到”原始异常是主角后面的异常作为配角被记在案需要的时候可以查但不会喧宾夺主。这才是 try-with-resources 最核心的设计思想——异常信息不能轻易丢失。1.3 为什么推荐它三个“确定性”和一个“可读性”第一关闭时机的确定性。手写 finally 时你永远无法百分之百保证每一条分支都写对了return 之前、catch 之后、嵌套异常的时候人脑很容易漏。try-with-resources 把关闭这件事固定为语法行为不管 try 块是正常结束、return、还是抛异常close 一定会在合适时机被调用。第二异常链的确定性。try-finally 的异常覆盖问题是随机的跟哪个异常先发生相关而 try-with-resources 的 suppressed 机制保证原始异常始终可见close 的异常作为附加信息保留。排查线上问题时这个区别往往决定了你能不能快速定位根因。第三资源作用域的确定性。资源声明在 try 后面的括号里作用域被严格限制在 try 块内部避免了资源变量泄漏到外层方法。老写法里资源声明在外面finally 里判空你永远担心它是不是已经被提前关了。第四是可读性。看代码的人一看到 try (...) 就知道括号里是资源整个生命周期就在这个块里不需要把整个方法读完才知道资源在哪里创建、在哪里关闭。对后续维护的人来说这种结构本身就是文档。2. 核心细节解析与实操要点2.1 try-finally 执行顺序到底先关资源还是先走 catch“try-finally 执行顺序”这个话题最近讨论度很高说明很多人对这个问题只是一知半解。我先给出一个完整准确的执行顺序再配合一段代码验证。在一个 try-with-resources 结构中如果同时存在 catch 和 finally是的try-with-resources 也能配 catch/finally完整顺序是执行 try 块代码同时按照声明顺序创建资源对象。如果 try 块内发生异常立即跳到资源关闭阶段。按声明逆序调用每个资源的 close 方法最后一个声明的先关。如果 close 阶段又抛出异常则把它附加到 try 块原始异常上作为 suppressed如果 try 块没有异常那 close 的异常就是本方法要抛出的异常。资源关闭完毕后才轮到代码中写的 catch 块处理捕获的异常。最后执行 finally 块。写一段代码验证最容易理解public class OrderDemo { static class Res implements AutoCloseable { private final String name; Res(String name) { this.name name; } Override public void close() { System.out.println(close: name); } } public static void main(String[] args) { try (Res a new Res(A); Res b new Res(B)) { System.out.println(body); throw new RuntimeException(body error); } catch (Exception e) { System.out.println(catch: e.getMessage()); } finally { System.out.println(finally); } } }运行结果body close: B close: A catch: body error finally看到没有close 的执行发生在 catch 之前的。这和你手写 try-finally 时的直觉不太一样你可能会以为 catch 先抓到异常然后才去关资源其实顺序是反的先把资源都关上异常再传到 catch。这个顺序对资源使用的实际影响是什么就是你在 catch 里想读取某些资源状态做诊断时资源已经关闭了。比如你想在 catch 里检查 InputStream.available() 或 Connection.isValid()得提前把关键状态保存出来。2.2 多资源关闭顺序为什么是“后声明先关闭”多资源声明的关闭顺序是倒序也就是最后声明的最先关闭。这个设计初看反直觉仔细一想非常合理。设想一个典型的数据库场景你先拿到 Connection然后基于 Connection 创建 PreparedStatement最后用 PreparedStatement 执行得到 ResultSet。这三者之间的依赖关系是单向的ResultSet 依赖 Statement 存活Statement 依赖 Connection 存活。如果先关 Connection那 Statement 和 ResultSet 会立刻变成无效对象后续再关它们不仅没有意义还可能抛异常。所以 JVM 选择“后声明者先关闭”正好顺着依赖关系ResultSet 先关然后 PreparedStatement最后 Connection。这跟我们手工写 finally 时的最佳实践完全一致。我见过有人把顺序理解错在代码里这样写try (Connection conn getConnection(); PreparedStatement ps conn.prepareStatement(...); ResultSet rs ps.executeQuery()) { // 处理结果集 }这段代码是正确且推荐的。但有人在旁边注释“关闭顺序是 conn - ps - rs”这就大错特错实际关闭顺序是 rs - ps - conn。这个顺序不是随便定的它写进了语言规范就是“资源以相反的顺序关闭”。牢记这一点你在设计自己的资源类时也要考虑如果我的 A 资源依赖 B 资源那 A 应该比 B 后创建这样才会被先关闭。2.3 让自定义资源支持 try-with-resources一个最小实现很多人以为 try-with-resources 只适用于 JDK 自带的 IO 和数据库类其实任何实现了 AutoCloseable 接口的类都可以。这个接口只有一个方法public interface AutoCloseable { void close() throws Exception; }在 JDK 5 里其实已经有一个类似的接口叫 Closeable抛的是 IOException很多 IO 类实现的是它AutoCloseable 是 JDK 7 新加的接口方法抛的是 Exception范围更宽。两者区别记住一点就够了如果只是包装一个资源优先考虑实现 Closeable如果自定义资源本身有业务语义比如连接池归还、锁释放、临时文件删除实现 AutoCloseable 更合适。给你看一个我项目中真实的案例。一个简易的数据库连接包装类close 不是真的把连接关掉而是归还给连接池public class PooledConnection implements AutoCloseable { private final Connection delegate; private boolean closed false; PooledConnection(Connection delegate) { this.delegate delegate; } Override public void close() { if (closed) { return; } closed true; // 归还连接而不是物理关闭 ConnectionPool.INSTANCE.returnConnection(delegate); } }注意我在这里处理了 closed 标志让 close 幂等。这是个很实用的小细节因为资源可能被重复关闭如果 close 里做的是释放锁、归还连接这类操作不幂等就会引发各种奇奇怪怪的异常。3. 实操过程与核心环节实现3.1 场景一文件读写改造实录用一个完整的文件复制功能来做实验。老代码是这样public void copyFile(String srcPath, String destPath) throws IOException { BufferedReader br null; BufferedWriter bw null; try { br new BufferedReader(new FileReader(srcPath)); bw new BufferedWriter(new FileWriter(destPath)); String line; while ((line br.readLine()) ! null) { bw.write(line); bw.newLine(); } } finally { if (bw ! null) { bw.close(); } if (br ! null) { br.close(); } } }这里有真实的隐患如果 bw.write() 过程中抛异常finally 里 bw.close() 可能再抛一个 IOException。如果 br.close() 又抛异常那 bw.close() 的异常会被覆盖前面的业务异常更不用提了。三层异常叠加日志里只剩最后一条根因淹没在代码里。改成 try-with-resources 之后public void copyFile(String srcPath, String destPath) throws IOException { try (BufferedReader br new BufferedReader(new FileReader(srcPath)); BufferedWriter bw new BufferedWriter(new FileWriter(destPath))) { String line; while ((line br.readLine()) ! null) { bw.write(line); bw.newLine(); } } }两种写法在功能上等价但第二种不会再出现“异常被覆盖”的连锁反应。而且这里还有一个隐形的优势BufferedWriter 的 close 方法会先把缓冲区剩余数据 flush 出去再关闭底层流。如果代码从头到尾忘了调用 flush老写法的 finally 里 close 也会帮你 flush这一点很多开发者没意识到所以在文件写入场景try-with-resources 不光帮关了资源还顺手帮你把数据落盘这件事兜住了。写文件的时候有一个反直觉的细节你不能主动在 try 块里调用 bw.flush() 之后就认为数据一定落盘了因为底层 FileWriter 的写盘行为还取决于操作系统缓冲。真正可靠的做法是在 try 块结束时依赖 close 内部的 flush。所以 try-with-resources 在文件写入场景下不止是代码规范更是数据完整性的保障。3.2 场景二数据库连接、Statement、ResultSet 三件套数据库资源是 try-with-resources 受益最大的场景。老手写 JDBC 时最痛苦的部分就是 finally 里三个嵌套判空关闭。尤其如果公司规范要求每个 close 都得 try-catch那整个 finally 块体积堪比一个小函数。老代码不贴了太占篇幅直接看改造后的标准形态public User findUserById(long id) { String sql select * from user where id ?; try (Connection conn dataSource.getConnection(); PreparedStatement ps conn.prepareStatement(sql); ResultSet rs ps.executeQuery()) { ps.setLong(1, id); if (rs.next()) { User user new User(); user.setId(rs.getLong(id)); user.setName(rs.getString(name)); return user; } return null; } catch (SQLException e) { throw new DataAccessException(查询用户失败, id id, e); } }三个资源全部声明在括号里代码从十几行缩减到几行。特别注意 executeQuery 的执行时机ps 声明后执行 executeQuery 拿到 rsrs 也一并声明进了括号。如果 executeQuery 抛异常那 rs 这个变量根本不会被创建也就无需关闭编译器只关闭已经创建成功的 ps 和 conn这个逻辑非常严谨。还有一点是数据库连接池场景的特殊性如果你用的是 HikariCP、Druid 这类连接池conn.close() 并不是真的物理断开而是把连接还给池子。很多人因为这一点误以为可以不用关闭连接这是大错特错。池化连接的 close 是归还你不调用 close连接就一直在池外漂泊连接数迟早被耗尽。try-with-resources 恰恰是强制你在使用完毕后把连接归还它完美匹配了连接池的语义。3.3 场景三包装流的关闭层次别把每个流都写进括号我经常看到有人把包装流的每一层都写进 try 的资源列表里比如try (FileInputStream fis new FileInputStream(data.bin); BufferedInputStream bis new BufferedInputStream(fis); DataInputStream dis new DataInputStream(bis)) { int value dis.readInt(); // ... }代码不报错但这是过度设计。原因在于包装流默认会级联关闭底层流dis.close() 内部会调用 bis.close()bis.close() 又会调用 fis.close()。三个流都声明进去反而要多做两次无意义的 close 调用。正确做法是只声明最外层流try (DataInputStream dis new DataInputStream( new BufferedInputStream( new FileInputStream(data.bin)))) { int value dis.readInt(); // ... }不过这里有个例外要单独记一下并不是所有包装类都会自动关闭底层资源。比如 ByteArrayInputStream 的 close 方法是空操作因为它背后是字节数组不需要操作系统资源关不关都行。再比如有些第三方库自己定制的 Filter 流close 语义不一定按标准来。所以在实际工作中我的原则是先看这个流包装类的 close 方法文档确认它会级联关闭底层流再决定是否只声明最外层。JDK 自带的 BufferedInputStream、BufferedReader、DataInputStream 这些都可以放心只关最外层。4. 常见问题与排查技巧实录4.1 问题一线上日志只看到 close 异常原始异常消失这个问题的排查过程我这几年遇到不下五次。典型场景一个文件导入任务偶发失败日志打出来的异常是 java.io.IOException: Stream closed但你翻遍代码也找不到哪里把这个流给关了感觉莫名其妙。其实根因多半是用户在某个 catch 分支里提前关了一次资源或者另一个线程把流给关了。但 try-finally 的异常遮蔽把真正的根因给盖住了。之前我在一个项目里遇到过原始异常是 FileNotFoundException因为某个配置文件被运维误删了但 finally 里 BufferedReader.close() 又抛了 IOException最终日志里只留下后者大家找了很久才发现是文件缺失。改成 try-with-resources 之后这个问题的排查路径就清晰了。FileNotFoundException 会成为主异常close 的 IOException 会作为 suppressed 异常挂在它下面。不过这里还有第二个坑很多日志框架默认只输出主异常的堆栈不会自动打印 suppressed 异常的细节你要是不知道这个机制升级完之后照样看不到完整信息。怎么解决在关键 catch 里手动打印} catch (IOException e) { Throwable[] suppressed e.getSuppressed(); for (Throwable s : suppressed) { log.error(suppressed exception:, s); } log.error(primary exception:, e); }或者调试的时候直接看异常对象的 suppressed 数组。做完这个改造很多“灵异事件”就水落石出了。4.2 问题二finally 块里判空不彻底导致空指针老代码里一个非常经典的 BugInputStream in null; try { in new FileInputStream(file.txt); // 业务逻辑 } finally { in.close(); // 如果 new FileInputStream 抛异常in 是 null这里直接 NPE }严格来说上面的 NPE 会把原始抛出的异常也一起盖掉这是最恶劣的异常遮蔽场景我排第一。看看 try-with-resources 是怎么避免的资源声明在 try 括号里如果 new FileInputStream 这步就失败了后面的 close 根本不会被注册也就不会执行。编译器只对已经成功创建的资源进行关闭不存在判空问题这是语法层面的保证比任何人为编码规范都可靠。还有一个相关的边界情况如果你在同一段代码里创建两个资源第一个成功、第二个失败那么第一个会不会被关闭try-with-resources 的答案是——会。编译器会记住已经创建成功的资源并在异常发生时按照逆序把它们一一关闭。这种细节手写 finally 时很容易漏这也是我强烈建议团队规范里写上“资源管理默认使用 try-with-resources”的原因。4.3 问题三代理模式下的资源包装直接用会编译报错项目里用了 Spring AOP 或者 JDK 动态代理给某些资源类做增强代理对象可能没有实现 AutoCloseable。比如你定义了一个接口 ResourceService它的实现类内部拥有一个需要关闭的底层资源但接口本身没有继承 AutoCloseable。此时直接写 try (ResourceService rs ...) 是编译不过的。碰到这种情况我的处理思路是优先考虑让接口直接继承 AutoCloseable如果接口是外部依赖无法修改就在实现类上额外提供一个getCloseable()方法返回一个可关闭的包装视图。还有更通用的做法写一个工具方法把任意 AutoCloseable 和业务函数组合起来public static T extends AutoCloseable, R R withResource(T resource, ThrowingFunctionT, R action) throws Exception { try (T r resource) { return action.apply(r); } }这里的 ThrowingFunction 是自定义的、允许抛异常的函数式接口。这个方法在写测试和工具库时非常实用可以统一管理非 IO 资源的生命周期。4.4 常见问题速查表场景具体问题解决方法文件读写原始异常被 finally 里的 close 异常覆盖改用 try-with-resources利用 suppressed 机制保留完整异常链数据库操作三个资源手写关闭代码冗余且易漏Connection、Statement、ResultSet 全部声明进 try 括号包装流每一层都声明进括号关闭顺序混乱通常只声明最外层流内层会级联关闭资源未初始化finally 里 close 判空不彻底导致 NPE使用 try-with-resources只关闭创建成功的资源代理/反射代理对象未实现 AutoCloseable接口继承 AutoCloseable 或封装工具方法日志排查suppressed 异常不出现在日志手动遍历 getSuppressed() 或调整日志配置4.5 一个额外提醒close 方法本身要写得优雅最后讲一个经常被忽略的点很多人只顾着用 try-with-resources 调用 close却从不关心 close 内部实现是不是靠谱。如果你是自己写资源类close 方法有三个要求幂等、快、不要吞异常。幂等是指连续调两次 close 不会出问题用 boolean 标志位控制最稳妥。快是指 close 里面不要做网络重连、循环等待这类耗时操作因为资源关闭如果太慢会拖垮业务接口的响应时间。不要吞异常是指 close 里的异常应当正常向上抛出让上层知道关闭失败这个事实而不是默默 catch 掉。注意这不是让你在 close 里打日志就行——打日志和抛异常不冲突重要的关闭失败一定要让调用方感知到否则可能发生连接泄漏、文件句柄耗尽这类无声事故。我在实际项目中有个习惯凡是实现了 AutoCloseable 的类都会在代码审查时特别留意它的 close 方法是被谁调用的、在什么线程里调用的、以及 close 失败后多久会被重试或丢弃。资源管理从来不是“写几行 close 完事”这么简单它是一个从语法、到实现、再到运维全链路的事。我个人在经历过多次线上资源泄漏和异常被吞的问题之后已经形成了一种肌肉记忆凡是代码里出现 InputStream、OutputStream、Reader、Writer、Connection、Statement、ResultSet、Socket、Channel、Lock 这些类型第一反应就是它们一定得出现在 try-with-resources 的括号里。如果有人在 review 时用了 try-finally 手写关闭我会建议他仔细想想你省下的这几行代码值不值得用一个线下的疑难杂症来换。试试把项目中还在用 try-finally 的老代码挑一个改过来跑一遍测试再模拟一次 try 和 close 同时抛异常的场景你就能直观感受到这个语法糖到底帮你挡住了多少坑。