try-with-resources 与 AutoCloseable 在 Java 中的应用
1. 概述
文件、Socket、数据库连接或与支付网关的会话都是使用后需要关闭的资源。如果忘记关闭,应用程序将泄漏文件描述符、耗尽连接池或长时间占用锁。
本文介绍如何使用 try-with-resources 自动关闭资源。你还将学习如何编写实现 AutoCloseable 的自定义类,Java 如何处理关闭时抛出的异常(suppressed exception),以及使用这种语法时常见的错误。
2. try-finally 的问题
Java 7 之前,关闭资源的标准做法是使用 finally 块:
import java.io.BufferedReader;
import java.io.FileReader;
import java.io.IOException;
public class LegacyOrderReader {
public static String readFirstLine(String path) throws IOException {
BufferedReader reader = new BufferedReader(new FileReader(path));
try {
return reader.readLine();
} finally {
reader.close();
}
}
}
这段代码能工作,但有两个问题:
- 冗长。 当有两个、三个资源时,你必须嵌套多个 try-finally 块。每个块还需要检查 null。
- 隐藏原始异常。 如果 readLine() 抛出 IOException,然后 close() 也抛出异常,那么来自 finally 的异常将替换原始异常。你会丢失真实错误的信息。
try-with-resources 解决了这两个问题。
3. try-with-resources 的语法
你在 try 关键字后的圆括号中声明资源:
import java.io.BufferedReader;
import java.io.FileReader;
import java.io.IOException;
public class OrderReader {
public static String readFirstLine(String path) throws IOException {
try (BufferedReader reader = new BufferedReader(new FileReader(path))) {
return reader.readLine();
}
}
}
当 try 块结束时,Java 自动调用 reader.close()。无论块正常运行、遇到 return 还是异常,都是这样。你不需要编写 finally。
唯一的条件是变量的类型必须实现 java.lang.AutoCloseable。JDK 中大多数 I/O 类都满足这个条件,例如 InputStream、Reader、Connection、Statement、ResultSet 和 Scanner。
3.1. 多个资源和关闭顺序
你可以声明多个资源,用分号分隔。Java 按照声明顺序的相反关闭它们:
public class ClosingOrderDemo {
record NamedResource(String name) implements AutoCloseable {
NamedResource {
System.out.println("打开 " + name);
}
@Override
public void close() {
System.out.println("关闭 " + name);
}
}
public static void main(String[] args) {
try (var connection = new NamedResource("connection");
var statement = new NamedResource("statement");
var resultSet = new NamedResource("resultSet")) {
System.out.println("读取客户数据");
}
}
}
输出:
打开 connection
打开 statement
打开 resultSet
读取客户数据
关闭 resultSet
关闭 statement
关闭 connection
这个顺序是合理的。后面声明的资源通常依赖于先前声明的资源,比如 statement 依赖 connection,所以要先关闭。
有一点需要记住。如果第二个资源在构造函数中立即抛出异常,Java 仍然会关闭已成功打开的第一个资源。未被创建的资源则不会调用 close()。
3.2. 使用现有变量(Java 9+)
从 Java 9 开始,你可以在 try 中放入在外部声明的变量,只要该变量是 final 或实际上是 final 的:
import java.util.Scanner;
public class CustomerInput {
public static void main(String[] args) {
Scanner scanner = new Scanner("Nguyen Van A\n0901234567");
try (scanner) {
System.out.println("姓名:" + scanner.nextLine());
System.out.println("电话:" + scanner.nextLine());
}
}
}
当资源由参数传递或通过复杂的工厂创建时,这种方式很方便。
3.3. catch 和 finally 在关闭后运行
try-with-resources 仍然允许添加 catch 和 finally。但是,两者都在资源关闭之后运行。因此,在 catch 中你不能再使用该资源,资源变量也已超出作用域。
4. 编写自定义 AutoCloseable 类
AutoCloseable 只有一个方法:
void close() throws Exception;
假设你有一个与支付网关的会话,每笔订单后需要释放:
public class PaymentGatewaySession implements AutoCloseable {
private final String orderId;
private boolean closed;
public PaymentGatewaySession(String orderId) {
this.orderId = orderId;
System.out.println("打开订单 " + orderId + " 的支付会话");
}
public void charge(long amount) {
if (closed) {
throw new IllegalStateException("会话已关闭");
}
if (amount <= 0) {
throw new IllegalArgumentException("金额无效:" + amount);
}
System.out.println("为订单 " + orderId + " 扣除 " + amount + " VND");
}
public boolean isClosed() {
return closed;
}
@Override
public void close() {
if (closed) {
return;
}
closed = true;
System.out.println("关闭订单 " + orderId + " 的支付会话");
}
}
这个类有三点值得注意:
- close() 没有声明 throws Exception。覆写方法可以缩小 checked exception 列表。这样,类的用户不会被迫 catch (Exception e)。
- close() 幂等的:调用多次也不会出错且不会释放两次。
- charge() 检查 closed 状态以防止在关闭后使用资源。
使用方式:
public class CheckoutService {
public static void main(String[] args) {
try (var session = new PaymentGatewaySession("ORD-1001")) {
session.charge(250_000);
}
}
}
打开订单 ORD-1001 的支付会话
为订单 ORD-1001 扣除 250000 VND
关闭订单 ORD-1001 的支付会话
4.1. AutoCloseable 还是 Closeable?
java.io.Closeable 从 Java 5 开始存在并继承 AutoCloseable。两个接口的区别如下:
| AutoCloseable | Closeable | |
|---|---|---|
| close() 的异常 | Exception | IOException |
| 幂等性 | 建议 | 根据契约强制 |
| 目的 | 任何类型的资源 | I/O 资源 |
如果你的资源与 I/O 相关,使用 Closeable。对于其他情况,如锁、会话或事务,使用 AutoCloseable 并尽可能缩小 throws。
5. Suppressed exception
回到第 2 节的问题:try 块抛出异常,然后 close() 也抛出异常。使用 try-with-resources 时,try 块中的异常被保留为主异常。来自 close() 的异常被附加为suppressed:
public class SuppressedDemo {
static class FlakyConnection implements AutoCloseable {
@Override
public void close() {
throw new IllegalStateException("关闭连接时出错");
}
}
public static void main(String[] args) {
try (var connection = new FlakyConnection()) {
throw new RuntimeException("保存订单时出错");
} catch (RuntimeException e) {
System.out.println("主异常:" + e.getMessage());
for (Throwable suppressed : e.getSuppressed()) {
System.out.println("被压制的:" + suppressed.getMessage());
}
}
}
}
输出:
主异常:保存订单时出错
被压制的:关闭连接时出错
你保留了两个信息。当通过 e.printStackTrace() 或日志打印堆栈跟踪时,Suppressed: 部分也会出现。这样在生产环境中调试会容易得多。
如果 try 块正常运行,只有 close() 抛出异常,那么来自 close() 的异常将是主异常。
6. 测试资源关闭行为
要编写测试,你需要添加 JUnit 5 和 AssertJ:
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>5.11.3</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.assertj</groupId>
<artifactId>assertj-core</artifactId>
<version>3.26.3</version>
<scope>test</scope>
</dependency>
下面的测试检查两个主要行为:资源始终被关闭(即使有错误),以及 close() 的异常被作为 suppressed 附加到主异常。
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
class PaymentGatewaySessionTest {
@Test
void shouldCloseSessionEvenWhenChargeFails() {
var session = new PaymentGatewaySession("ORD-2002");
assertThatThrownBy(() -> {
try (session) {
session.charge(-5);
}
}).isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("-5");
assertThat(session.isClosed()).isTrue();
}
@Test
void shouldAttachCloseFailureAsSuppressed() {
AutoCloseable failingResource = () -> {
throw new IllegalStateException("close failed");
};
assertThatThrownBy(() -> {
try (failingResource) {
throw new RuntimeException("business failed");
}
}).hasMessage("business failed")
.satisfies(e -> assertThat(e.getSuppressed())
.singleElement()
.extracting(Throwable::getMessage)
.isEqualTo("close failed"));
}
@Test
void closeShouldBeIdempotent() {
var session = new PaymentGatewaySession("ORD-3003");
session.close();
session.close();
assertThat(session.isClosed()).isTrue();
assertThatThrownBy(() -> session.charge(100))
.isInstanceOf(IllegalStateException.class);
}
}
第二个测试使用 lambda 快速创建一个 AutoCloseable。这是可能的,因为 AutoCloseable 只有一个 abstract 方法,所以可以像函数式接口一样使用。
7. 常见错误
7.1. 在构造函数中嵌套资源
try (var in = new ObjectInputStream(new FileInputStream("orders.bin"))) {
// ...
}
ObjectInputStream 的构造函数读取流的头部,可能抛出 IOException。此时变量 in 尚未被赋值。内部的 FileInputStream 已打开但将永远不会被关闭。安全的做法是分别声明每个资源:
try (var file = new FileInputStream("orders.bin");
var in = new ObjectInputStream(file)) {
// ...
}
7.2. 从 try 块返回资源
public static Stream<String> loadOrderLines(Path path) throws IOException {
try (Stream<String> lines = Files.lines(path)) {
return lines; // 错误:到达调用者时流已关闭
}
}
调用者会在尝试使用流时立即遇到 IllegalStateException。你有两种方式修复。第一是在 try 内完成所有数据处理,例如 return lines.toList();。第二是将流返回给调用者,让他们在 try-with-resources 中使用它。
注意: 只有绑定到 I/O 资源的 Stream 才需要关闭,例如 Files.lines、Files.list 或 Files.walk。从 List.stream() 创建的 Stream 则不需要。
7.3. 在 close() 中抛出 InterruptedException
AutoCloseable 的 Javadoc 建议不要从 close() 抛出 InterruptedException。原因是这个异常可能被压制,导致线程的中断状态丢失。如果必须在 close() 中等待,请捕获异常,然后调用 Thread.currentThread().interrupt()。
7.4. 空资源
声明 try (var conn = findConnectionOrNull()) 是合法的。如果值是 null,Java 将简单地跳过 close() 调用。不过,这通常是设计不良的信号。你应该使用 Optional 或从工厂抛出异常。
7.5. 何时不应使用
不是每个实现 AutoCloseable 的对象都应该在某个方法内立即关闭。例如,在 Spring Boot 中,DataSource 或 HttpClient 之类的共享 bean 会伴随应用上下文生存。如果你在 try-with-resources 中包装共享 bean,你会在其他组件仍在使用它时关闭它。
只对你的代码在该范围内创建和拥有的资源使用 try-with-resources。
7.6. 技巧:Java 21 中的 ExecutorService
从 Java 19 开始,ExecutorService 实现 AutoCloseable。close() 方法将等待所有任务完成。使用虚拟线程时这非常方便:
import java.util.concurrent.Executors;
public class InvoiceBatch {
public static void main(String[] args) {
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 1; i <= 3; i++) {
int invoiceId = i;
executor.submit(() -> System.out.println("发送发票 #" + invoiceId));
}
}
System.out.println("已发送所有发票");
}
}
“已发送所有发票” 这一行总是最后打印,因为 close() 会阻塞直到三个任务完成。但三张发票的顺序则不固定。
8. 结论
try-with-resources 相比 try-finally 使代码更短、更安全。资源始终被关闭,按照打开顺序的相反关闭。原始异常不会被隐藏,得益于 suppressed exception 机制。
编写自定义 AutoCloseable 类时,你应该:
- 缩小 close() 的 throws;
- 让 close() 是幂等的;
- 防止在关闭后使用对象。
此外,避免在构造函数中嵌套资源,不要从 try 块返回资源,只关闭你的代码确实拥有的资源。