《Java 编程入门》13.2 try-with-resources 与关闭顺序

从手工 finally 关闭两个资源时异常互相覆盖的问题出发,讲清 AutoCloseable 的契约、try-with-resources 的逆序关闭和异常抑制机制;再把 AuditLog 与 LoanBatch 接到 library 的借阅审计和报表导出上,并给出资源未实现 AutoCloseable、close 抛异常、所有权误用等真实输出与边界。

本节目标:理解 AutoCloseable 的契约,能正确使用 try-with-resources 管理多个资源,说清逆序关闭和异常抑制的规则,并把审计日志与借阅报表接到 library 上。
适用版本:JDK 25 LTS(Temurin 25.0.5);try-with-resources 与 getSuppressed() 在 JDK 21 上行为一致。

上一节留下的问题

library 的借阅操作需要写审计日志,导出报表需要写字符流。只要涉及「打开—使用—关闭」,就要保证关闭一定执行。最朴素的写法是 try/finally:

AuditLog log = new AuditLog(sink, "borrow");
try {
    log.log("borrow 978-7-111-54742-6");
} finally {
    log.close();
}

一个资源还好。两个资源就要嵌套:

AuditLog outer = new AuditLog(sink, "outer");
try {
    AuditLog inner = new AuditLog(sink, "inner");
    try {
        outer.log("borrow");
        inner.log("write");
    } finally {
        inner.close();
    }
} finally {
    outer.close();
}

这段代码有三个问题:

  • 关闭顺序靠人记,写反了可能让内层资源在使用外层资源时被提前关掉。
  • 如果 try 块抛异常,finally 里的 close() 也抛异常,后者会替换前者,原始错误丢失。
  • 资源一多,嵌套层级迅速失控。

try-with-resources 就是把这套模板交给编译器生成。

AutoCloseable 与 Closeable

能被 try-with-resources 管理的对象必须实现 AutoCloseable:

public interface AutoCloseable {
    void close() throws Exception;
}

Closeable 是它的子接口,把异常收窄成 IOException:

public interface Closeable extends AutoCloseable {
    void close() throws IOException;
}
接口close() 声明典型实现
AutoCloseablethrows Exception自定义资源、锁、作用域对象
Closeablethrows IOExceptionInputStream、OutputStream、Reader、Writer

自定义资源实现 AutoCloseable 即可;如果关闭只涉及 IO,实现 Closeable 能让调用方的异常类型更精确。library 的 AuditLog 不涉及 IO,用 AutoCloseable;LoanBatch 包装 Writer,用 AutoCloseable 并把 close() 声明成 throws IOException。

基本语法与关闭顺序

try-with-resources 的写法:

try (AuditLog log = new AuditLog(sink, "borrow")) {
    log.log("borrow 978-7-111-54742-6");
}

资源在 try 后面的括号里声明,作用域仅限于 try 块。块结束时,编译器生成的 finally 会调用 close(),即使块里抛异常也一样。

多个资源用分号隔开:

try (AuditLog outer = new AuditLog(sink, "outer");
     AuditLog inner = new AuditLog(sink, "inner")) {
    outer.log("borrow");
    inner.log("write");
}

关闭顺序是声明的逆序:先关 inner,再关 outer。这像栈:后打开的资源可能依赖先打开的资源,所以必须先关。AuditLog 把打开和关闭都写进同一个 StringBuilder,运行结果能直接看到顺序:

StringBuilder sink = new StringBuilder();
try (AuditLog outer = new AuditLog(sink, "outer");
     AuditLog inner = new AuditLog(sink, "inner")) {
    outer.log("borrow");
    inner.log("write");
}
System.out.print(sink);

本机 JDK 25 输出:

open  outer
open  inner
log   outer: borrow
log   inner: write
close inner
close outer

open 按声明顺序,close 按逆序。这个顺序不是约定,而是编译器生成的固定行为。

JDK 9 起,还可以把已有的 effectively final 变量放进 try-with-resources:

AuditLog log = new AuditLog(sink, "borrow");
try (log) {
    log.log("borrow");
}

变量必须是事实 final,且类型实现 AutoCloseable。JDK 10 起还能用 var:

try (var log = new AuditLog(sink, "borrow")) {
    log.log("borrow");
}

异常抑制:主异常不会被 close 覆盖

try-with-resources 最重要的行为是异常抑制(suppressed exception)。如果 try 块和 close() 都抛异常,try 块的异常成为主异常,close() 的异常被挂到主异常的 getSuppressed() 上。

先看一个两个方法都会失败的资源:

public final class FailingResource implements AutoCloseable {
    private final String name;

    public FailingResource(String name) {
        this.name = name;
    }

    public void work() {
        throw new IllegalStateException("work failed: " + name);
    }

    @Override
    public void close() {
        throw new IllegalStateException("close failed: " + name);
    }
}
try (FailingResource resource = new FailingResource("db")) {
    resource.work();
} catch (IllegalStateException e) {
    System.out.println("primary    = " + e.getMessage());
    for (Throwable suppressed : e.getSuppressed()) {
        System.out.println("suppressed = " + suppressed.getMessage());
    }
}

本机 JDK 25 输出:

primary    = work failed: db
suppressed = close failed: db

如果手写 finally,close() 抛出的 close failed 会替换 work failed,调用方看到的只是关闭失败,真正的业务错误反而丢了。try-with-resources 保留了两条信息,排查时可以顺着 getSuppressed() 看到全部。

只有一种情况 close() 的异常会成为主异常:try 块正常结束,close() 自己失败。这时没有主异常可挂,close() 的异常直接向外抛。

在 library 里落地

审计日志是一个最小资源:打开时记一行,关闭时再记一行。

public final class AuditLog implements AutoCloseable {
    private final StringBuilder sink;
    private final String name;

    public AuditLog(StringBuilder sink, String name) {
        this.sink = sink;
        this.name = name;
        sink.append("open  ").append(name).append('\n');
    }

    public void log(String message) {
        sink.append("log   ").append(name).append(": ").append(message).append('\n');
    }

    @Override
    public void close() {
        sink.append("close ").append(name).append('\n');
    }
}

它不持有外部资源,close() 不抛异常,适合演示顺序。真正写报表的 LoanBatch 持有 Writer:

public final class LoanBatch implements AutoCloseable {
    private final Writer out;
    private int rows;

    public LoanBatch(Writer out) {
        this.out = out;
    }

    public void write(Loan loan) throws IOException {
        out.write(String.format("%d,%s,%s,%s,%s%n",
                loan.id(), loan.isbn(), loan.memberId(),
                loan.borrowedOn(), loan.dueOn()));
        rows++;
    }

    public int rows() {
        return rows;
    }

    @Override
    public void close() throws IOException {
        out.close();
    }
}

LoanBatch 的契约是接管 out 的所有权:close() 会关闭调用方传进来的 Writer。如果调用方还想继续用这个 Writer,就不应该把它交给 LoanBatch,或者把 close() 改成只 flush()。资源所有权必须在 API 文档或方法名里说清楚,否则很容易出现「导出完报表,标准输出也被关了」的问题。

服务层导出借阅报表:

public void exportLoans(Writer out) throws IOException {
    try (LoanBatch batch = new LoanBatch(out)) {
        for (Loan loan : loans.findActive()) {
            batch.write(loan);
        }
    }
}

exportLoans 声明 throws IOException,因为 LoanBatch.write 和 close 都可能失败。这是受检异常的合理位置:调用方是 CLI 或测试,能决定「报错退出」还是「重试」。异常一路传到 13.3 的 CLI 边界,再翻译成用户可见的错误。

运行导出:

LibraryService service = DemoData.service();
service.borrow("978-7-111-54742-6", "M001");
service.borrow("978-7-115-54608-1", "M002");

StringWriter buffer = new StringWriter();
service.exportLoans(buffer);
System.out.print(buffer);

本机 JDK 25 输出:

1,978-7-111-54742-6,M001,2026-10-13,2026-10-27
2,978-7-115-54608-1,M002,2026-10-13,2026-10-27

StringWriter 的 close() 是空操作,所以即使 LoanBatch 关闭了它,缓冲区内容仍然可以读取。换成 Files.newBufferedWriter 时,关闭会真正落盘,行为差异必须心里有数。

常见错误与边界

错误一:资源没有实现 AutoCloseable

try (String text = "x") {
    System.out.println(text);
}
错误: 不兼容的类型: try-with-resources 不适用于变量类型
        try (String text = "x") {
                    ^
    (String无法转换为AutoCloseable)

try-with-resources 不是「自动调用任何对象的某个方法」,它只认 AutoCloseable。包装类必须显式实现接口。

错误二:以为 close 的顺序按书写习惯来

很多人直觉是先声明的先关闭,实际正好相反。声明顺序是「打开顺序」,关闭顺序是它的逆序。依赖顺序的资源要按依赖关系声明:被依赖的放前面,依赖它的放后面。

错误三:在 try 块里 return 就忘了关闭

不需要手动处理。try-with-resources 生成的 finally 在 return 之前执行,资源照样关闭。这一点比自己写 finally 更可靠,因为 return、break、continue、异常都会触发关闭。

边界:不要管理不属于你的资源

System.out、调用方传入的 Connection、容器管理的 DataSource,这些资源的生命周期不由当前方法决定。把它们放进 try-with-resources 会提前关闭,导致后续代码失败。只对「这个方法打开、这个方法负责关闭」的资源使用 try-with-resources。

边界:close 不是幂等的

AutoCloseable 没有要求 close() 可以重复调用。try-with-resources 只调用一次;如果代码里又手动调用一次,某些实现会抛异常或重复释放。需要幂等关闭时,在自己的 close() 里加一个 closed 标志。

小结

  • try-with-resources 只接受 AutoCloseable;Closeable 是它的子接口,把 close() 的异常收窄为 IOException。
  • 多个资源按声明顺序打开、按逆序关闭;编译器生成的 finally 保证 return 和异常路径都会关闭。
  • try 块和 close() 同时失败时,try 的异常是主异常,close() 的异常挂在 getSuppressed() 上。
  • AuditLog 适合演示顺序,LoanBatch 展示了带 IOException 的真实资源;exportLoans 把关闭责任封装在服务层。
  • 资源所有权要显式约定:LoanBatch 关闭调用方的 Writer,调用方就不能再使用它。
  • 不要管理不属于自己的资源,不要假设 close() 可以重复调用。

资源关闭解决的是「清理」,但错误怎么让用户看懂还没解决。底层可能抛 IOException、NumberFormatException、DateTimeParseException,CLI 不能把这些栈追踪直接甩给用户。下一节讲异常转换:在哪一层翻译、怎么保留原因、怎么设计用户可见的错误。

阅读导航:上一节:13.1 受检与非受检异常 · 下一节:13.3 异常转换与用户可见错误 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「java」更多文章

  1. 《Netty 高性能网络编程》15.3 linkd-v2 验收与复盘
  2. 《Netty 高性能网络编程》15.2 限流、超时与降级
  3. 《Netty 高性能网络编程》15.1 网关路由与转发