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 块返回资源,只关闭你的代码确实拥有的资源。

javatry-with-resourcesautocloseableexceptionbest-practices