我至今记得那一幕做代码评审时看到一个同事写了一个读配置文件的方法二十几行的代码try-catch-finally层层嵌套文件流倒是关了但关闭动作被塞在一个条件判断里注释还写着“防止万一”。我当场问他这路径到底会不会走到他答不上来。后来我们翻线上日志发现这个模块隔三差五报“Too many open files”根因就是那层“防御”在某些异常路径下根本没执行。这不是个别现象。Java里面的资源关闭问题可以说是老生常谈的痛点了。Java 7引入try-with-resources语法糖初衷就是为了彻底解决这个痛点但很多Java开发者对它只有“听说过”或“大概会用”的层面对底层机制、被抑制异常、关闭顺序这些细节并不清楚。这篇文章就围绕这个语法糖从踩坑记录讲到字节码层面的执行逻辑把它的用法和边界一次说透适合刚接触Java的小白也适合需要用得更讲究的老手。1. 一段最熟悉的代码一个最容易忽略的问题1.1 从一次Code Review说起谁还没关好的资源我的这个同事代码大致长这样protected Properties loadConfig(String filePath) { Properties props new Properties(); FileInputStream in null; try { in new FileInputStream(filePath); props.load(in); } catch (IOException e) { log.error(加载配置文件失败, e); } finally { if (in ! null) { try { in.close(); } catch (IOException ignored) { // 忽略关闭异常 } } } return props; }这段代码看起来没什么大毛病但问题就在finally块里那个if (in ! null)判断。理论上in在try块开头赋值要么成功要么抛异常异常了还会走到finally吗会的。但关键在于异常路径的复杂程度远超过你对几个if条件的直觉预期。比如FileInputStream本身构造成功了但props.load(in)读了一半底层文件句柄因为IO错误被操作系统提前回收这时候in非空close()调用是安全的。但如果说文件权限校验、路径解析、类加载这些环节里发生了Error比如OutOfMemoryError呢in可能根本没初始化或者某些中间环节状态不一致你的if (in ! null)判断就不够用了。更复杂的场景是多个资源互相依赖一个关不好影响另一个。代码评审的结果是我们重构了这个方法改用try-with-resources代码变成七行。但真正让我触动的是后面几周——另一个同事在另外一段旧代码里同样因为“手动关流”的路径没走对泄漏了数据库连接直接导致一次线上批量任务失败。那一刻我意识到资源关闭这件事靠人的细心是堵不住的必须靠语言层面的机制兜底。1.2 try-finally的三大隐患冗余、覆盖、嵌套传统写法用到的try-finally本身是好的设计但当资源数量变多它的三个问题就会暴露第一代码冗余严重。每引入一个资源就要多一个finally块、多一个null判断、多一个close调用。开三个资源finally块里要嵌套两层try-catch光是“关闭”这件事的代码量就比“使用”还多。写的人容易烦看的人容易晕。第二异常信息会互相覆盖。这是最隐蔽、也最致命的问题。假设你的try块里抛出了一个业务异常A在finally里关闭资源时又抛出了一个IOException B这时候JVM会怎么处理它会用B把A覆盖掉。也就是说你真正需要关注的业务异常还没出方法的门就被一个“关闭失败”的异常顶掉了。你去查日志只能看到B根因A完全丢失。这在生产排障时是灾难性的。第三多资源之间需要手动保证关闭顺序。当资源之间存在依赖关系时关闭顺序是有讲究的比如“先关外层再关内层”。手动写finally意味着这个顺序完全依赖程序员的自觉Java编译器帮不上任何忙。顺序写反了通常不会有立竿见影的报错而是在高并发、高负载的时候出现文件句柄泄漏、连接池耗尽这类延迟性的故障。这三个隐患本质上都是把“本应由语言runtime保证的东西交给了程序员的人为约定”。try-with-resources就是为了把这个约定变成强制规则而生的。2. try-with-resources的两种用法与底层执行顺序2.1 基础写法把资源声明挪到括号里try-with-resources的语法结构很简单在try关键字后面跟一对括号把需要管理的资源声明写进去try (FileInputStream in new FileInputStream(config.properties)) { Properties props new Properties(); props.load(in); } catch (IOException e) { log.error(读取配置失败, e); }声明在括号里的资源不需要你手动调用close()try块执行结束后Java会自动调用。而且这个机制非常严格无论业务代码是正常结束、抛出异常、还是中途return资源都会被关闭。这里需要注意一个细节资源声明只能写在try后面的括号里不能写在try块内部。如果写在内部它就是普通局部变量编译器不会帮你管理。再看一个稍微复杂一点的多资源示例try (Connection conn dataSource.getConnection(); PreparedStatement ps conn.prepareStatement(SELECT * FROM user WHERE id ?); ResultSet rs ps.executeQuery()) { while (rs.next()) { // 处理结果 } }三个资源零个finally代码简洁到让人怀疑是不是漏了什么。但这正是语法糖的价值它把那些“非核心”的样板代码从业务代码里彻底剥离了。2.2 多资源先后声明时为什么是逆序关闭多资源声明的关闭顺序和声明顺序是相反的也就是“后声明的先关闭”。JDK官方文档明确写了这一点但很多人不知道为什么我在这里解释一下。原因在于资源之间的依赖关系。比如你创建一个BufferedReader去包装一个FileReadertry (FileReader reader new FileReader(data.txt); BufferedReader buffered new BufferedReader(reader)) { String line; while ((line buffered.readLine()) ! null) { // 处理 } }这里buffered依赖readerbuffered负责缓冲读真正落到底层文件系统的是reader。关闭的时候必须先让buffered把缓冲区的数据冲刷干净、释放自己的资源然后再关闭底层reader最后才是关闭文件句柄。逆序关闭恰好保证了这个顺序BufferedReader先关FileReader后关。如果反过来会怎么样你先把底层FileReader关了再关BufferedReaderBufferedReader内部可能尝试向底层写入剩余数据结果底层已经关闭直接抛IOException。更严重的是底层文件句柄可能没有真正释放导致资源泄漏。所以我一直建议当多条资源存在包装、依赖关系时要么只声明最外层资源要么让编译器按逆序自动处理不要自己手动去开多个关闭链。后面的第5章会专门讲“只声明最外层”这个实践为什么重要。2.3 catch与finally的真实执行时机很多人以为try-with-resources只是把finally块“吃掉”了从此写不了catch和finally。不是的catch和finally都可以继续写只是它们的执行时机需要理顺。一个带catch和finally的完整示例try (FileInputStream in new FileInputStream(config.properties)) { // 业务代码 } catch (IOException e) { log.error(读取失败, e); } finally { log.info(配置文件读取流程结束); }执行顺序是这样先是try块里的业务代码然后自动执行资源关闭再进入catch如果try抛了异常最后执行finally。换句话说资源关闭发生在catch之前、finally之前。为什么资源关闭在catch之前因为编译器把资源关闭逻辑编译成了内层try-finally的展开代码。业务代码先执行然后进入内层finally关闭资源如果在关闭过程中又抛出了异常这个异常会和业务异常打包在一起最终一起交给外层catch处理。所以你可以这样理解catch拿到的异常已经是“业务异常和关闭异常合并之后”的结果而finally一定是最后收尾的。这个执行顺序一旦理解很多诡异的日志顺序你就能看明白了——比如为什么catch日志里出现的异常堆栈中包含了“Suppressed”信息而finally日志总在最后一行。3. 被抑制异常当try和close同时抛出异常时发生了什么3.1 用一段实验代码看清Suppressed前面提到异常覆盖是try-finally的致命缺陷try-with-resources是怎么解决的答案是被抑制异常Suppressed Exception。我用一段实验代码来演示。先定义一个资源类它的close方法故意抛IOExceptionclass FlakyResource implements AutoCloseable { Override public void close() throws IOException { throw new IOException(资源关闭失败); } }然后写一个业务方法try块抛业务异常资源关闭又抛异常public static void main(String[] args) { try (FlakyResource resource new FlakyResource()) { throw new RuntimeException(业务处理异常); } catch (Exception e) { e.printStackTrace(); } }运行结果很有意思堆栈长这样java.lang.RuntimeException: 业务处理异常 at Main.main(Main.java:5) Suppressed: java.io.IOException: 资源关闭失败 at FlakyResource.close(FlakyResource.java:7) at Main.main(Main.java:4)注意看主异常是“业务处理异常”它没有被关闭异常覆盖。关闭异常乖乖待在“Suppressed”标签后面。这就是try-with-resources对异常覆盖问题的答案不是不让关闭异常出现而是把它降级为从属信息挂在主异常下面。3.2 异常覆盖问题的终结Suppressed的设计初衷JDK设计这个机制的逻辑其实很直白当多个异常同时发生你需要保留的是那个“最有价值”的异常——通常是最先发生的、最能说明问题的那个。关闭异常无论多重要它的产生时机都在业务异常之后对根因定位的贡献通常不如业务异常大。所以Throwable类在Java 7中新增了两个方法addSuppressed(Throwable)用来挂载被抑制的异常getSuppressed()用来取回被抑制的异常列表。编译器在展开try-with-resources时如果检测到业务代码抛了异常就会把所有close()抛出的异常逐个打包到业务异常上而不是用新的异常覆盖旧的。这也解释了一个常见疑问为什么有时日志里看不到Suppressed因为只有当try块里抛了异常且close也抛了异常才会触发打包逻辑。如果try块正常结束、只有close抛出异常那这个close异常就是普通异常直接往外抛不存在Suppressed标签。3.3 getSuppressed在日志排查里的实际用法理解了这个机制对你日常排查问题很有帮助。我见过不少人因为不知道Suppressed的存在看日志只盯主异常堆栈漏掉了最关键的辅助信息。实际上很多线上疑难杂症答案就在Suppressed里。比如SQLException它与数据库连接池、Statement执行纠缠在一起一个SQLException上可能挂了好几个被抑制的异常其中可能有“连接关闭失败”“事务回滚失败”“游标释放失败”。你不调用getSuppressed()去遍历永远看不到这些被藏起来的信息。排查时我一般会这样处理catch (Exception e) { log.error(操作失败主异常{}, e.getMessage()); Throwable[] suppressed e.getSuppressed(); if (suppressed ! null suppressed.length 0) { for (Throwable t : suppressed) { log.error(被抑制异常{}, t.getMessage()); } } }这里有个需要注意的细节主异常本身的toString通常不包含Suppressed信息必须显式遍历。如果你们团队使用的日志框架只格式化主异常不打印堆栈详情那么Suppressed信息相当于是“半丢失”的——保住了不覆盖但肉眼看不见。所以我建议在公共的异常处理切面里把suppressed统一打印出来。4. 不只是流自定义AutoCloseable资源与Java 9增强4.1 让自定义业务对象也能被自动关闭try-with-resources真正有意思的地方在于它不局限于IO流、连接这类标准资源。任何一个实现了AutoCloseable接口的类都能享受自动关闭的待遇。我先说AutoCloseable和Closeable的关系Closeable扩展了AutoCloseable但Closeable.close()声明抛出IOExceptionAutoCloseable.close()声明抛出Exception。也就是说AutoCloseable更宽松实现类可以抛出任意受检异常甚至不抛异常。举个例子假设我要写一个带租约机制的内存锁租约到期未释放会导致后续操作无法进行class LeaseLock implements AutoCloseable { private final String leaseId; private boolean released false; public LeaseLock(String leaseId) { this.leaseId leaseId; } public void release() { // 通知协调者释放租约 released true; } Override public void close() { if (!released) { release(); } } }有了这个类调用方的代码就非常干净try (LeaseLock lock new LeaseLock(batch-job-001)) { // 执行批量任务 }你会注意到我在close里做了一个if (!released)判断这是close方法设计里非常重要的一条close必须幂等。也就是说不管调用多少次效果应该和调用一次一样。编译器不会保证close只被调用一次——如果某个close内部抛了异常外层catch又手动调了一次close你的实现里就不该出现“第二次close把状态搞坏”的问题。4.2 Java 9的升级effectively final变量直接放进tryJava 7到Java 8try后面的括号里只能直接声明新变量。Java 9开始你可以把已经存在的、effectively final的变量放进去BufferedReader reader new BufferedReader(new FileReader(data.txt)); try (reader) { String line; while ((line reader.readLine()) ! null) { // 处理 } }这里有个要求变量在后面的代码中不能再被重新赋值。比如你有类似reader new BufferedReader(...)的代码编译器会直接报错。原因很好理解语法糖展开后JVM需要保证自己持有的资源引用和实际使用、关闭的引用是同一个。如果变量可以被重新赋值你以为关掉的是最初的资源实际关掉的可能是另一个最初的资源就泄漏了。这项增强的实际意义是当你不方便在try括号里直接创建资源时比如资源创建逻辑封装在一个工厂方法里或者需要先做日志记录再进入try块你就可以先声明变量再放进try里。注意这个变量同样必须满足“未被修改”的条件否则编译器会拒绝编译。4.3 资源类设计的几个自我检查点这些年我自定义过不少AutoCloseable资源踩过几次坑以后总结出几个检查点写自定义资源类时都会过一遍第一close里面要不要处理并发问题。如果资源有可能被两个线程同时close你的实现就必须加同步或原子标记。我之前写的租约锁release方法里就用了AtomicBoolean来防止双重释放。第二close和业务方法之间的状态一致性。资源一旦关闭再调用业务方法应该快速失败而不是留下半死不活的状态。比如你关了文件再调readLine就应该抛出明确提示“资源已关闭”而不是返回null或空数据让调用方误判。第三close抛出的异常类型要有约束。AutoCloseable.close()声明的是Exception但这个异常会直接参与try-with-resources的异常合并逻辑。如果你在close里抛了RuntimeException它同样会被挂到suppressed列表里但排查时容易和业务异常混淆。我习惯约定close只抛必须抛出的受检异常比如IOException其余一律不抛甚至尽量保证close本身不失败。5. 我在项目里踩过的坑三段真实排查记录5.1 连接池耗尽关闭方法没被真正调用前年有一个做批量导出的服务上线后运行两天开始频繁报“获取连接超时”所有请求堆积在数据库连接池获取处。我第一反应是连数据库的并发太高了结果一看连接池配置最大50个连接实际活跃数长期在48、49左右明显是有连接没归还。排查过程是这样的先用jstack抓线程栈发现大量线程阻塞在wait连接池的许可上再看活跃连接的持有线程ID定位到具体业务代码——导出方法里用了try-with-resources。按理说try块结束就会自动close为什么连接不归还最后发现问题出在一个第三方工具类上我们用它把查询结果封装成Excel流这个封装对象没有实现AutoCloseable。所以我的代码长这样try (Connection conn dataSource.getConnection(); Statement stmt conn.createStatement(); ExcelStreamWriter writer new ExcelStreamWriter(stmt.executeQuery(...))) { writer.writeTo(outputStream); }编译器确实帮我们关闭了conn和stmt但ExcelStreamWriter是普通类它持有ResultSet引用只有在自己的close方法被调用时才会释放ResultSet、归还连接。而ResultSet没关连接也永远还不了池。这个坑的核心教训是用try-with-resources之前必须先确认括号里每个对象都是AutoCloseable且它的close方法真的触发了底层资源释放。如果第三方库提供了类似的封装类一定要先查文档或源码看它的资源生命周期归谁管。5.2 包装流的二次关闭不是所有close都幂等另一个坑是关于流包装的。我一度为了提高性能把所有IO流都声明在try括号里比如上面ArrayList那种包装流想着反正是逆序关闭多关一个也是关。一开始也确实没出过问题因为主流JDK的BufferedInputStream、InputStreamReader这些类的close都做了幂等处理重复调用无非是再走一遍内部标记。但后来接了一个上报数据用的二进制协议库它提供的是CompositeEncodedStream内部维护了一个已关闭标志。这个库的close方法在第二次调用时会抛异常而它的源码注释写着“请确保调用方不要重复关闭”。我们的业务代码因为习惯性把所有流对象都写进try括号导致内层流和包装流都被列进资源列表逆序关闭时包装流先关然后内层流close时包装流又尝试关闭一次直接抛异常。这个异常被Suppressed挂到了业务异常上排查了半天才看清楚。从此我养成一个习惯包装流场景下只在try括号里声明最外层资源。因为关闭外层流的时候它内部会递归关闭所有底层流。多写几层声明看起来是把每个资源都“管起来了”实际上是自己给自己制造双关风险。让最外层资源全权负责才是与底层实现解耦的可靠做法。5.3 主异常误导被抑制异常才是根因这是我最想强调的一个真实案例。有段时间一个消息消费服务频繁出现一条报错“处理消息失败: java.io.IOException: 连接被重置”。我一开始把重点放在网络连接上重启了网络组件、调整了超时时间问题还在。后来我按本文第三章提到的方法把异常里的suppressed信息完整打印出来才看到一行关键日志Suppressed: java.sql.SQLException: 游标已关闭真相是消息处理时开启了一个数据库游标业务代码从流里读的时候抛了IOException游标本身的关闭动作也跟着抛了SQLException。如果只盯着主异常“连接被重置”你永远想不到根因在数据库游标的生命周期管理上。找到根因后我们改了游标的取数策略问题彻底消失。这个案例给我的感触很深。在try-with-resources的时代同一个try块里可能同时跑多个任务、持有多类资源异常信息的数据结构天然就是树形的。排查问题不能只看最上层的主异常必须顺着Suppressed往下剥。有些团队直接把e.printStackTrace()的完整堆栈存进日志中心看起来是存了但检索分析时只匹配主异常关键词suppressed信息同样会被忽略。最靠谱的做法是监控系统里专门加一个字段把suppressed的类名和方法名拆出来建立索引这样才不会漏掉真正的根因。回想这些年从手写try-finally的绞尽脑汁到try-with-resources的一行收尾再到排查各种suppressed和关闭顺序问题算是在资源管理这件事上交了不少学费。这个语法糖虽然简单但要真正用明白还是得理解它的底层执行顺序、异常合并逻辑以及close方法实现的好习惯。也希望这篇文章里那些踩过的坑能帮你在项目里免掉同样的一趟折腾。