try-with-resources và AutoCloseable trong Java
1. Tổng quan
File, socket, kết nối database hay phiên làm việc với cổng thanh toán đều là tài nguyên cần được đóng sau khi dùng. Nếu quên đóng, ứng dụng sẽ rò rỉ file descriptor, cạn connection pool hoặc giữ lock lâu hơn cần thiết.
Bài này trình bày cách dùng try-with-resources để đóng tài nguyên tự động. Bạn cũng sẽ biết cách tự viết lớp implement AutoCloseable, cách Java xử lý exception phát sinh khi đóng (suppressed exception), và những lỗi hay gặp khi dùng cú pháp này.
2. Vấn đề với try-finally
Trước Java 7, cách chuẩn để đóng tài nguyên là dùng khối 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();
}
}
}
Đoạn code này chạy được, nhưng có hai vấn đề:
- Dài dòng. Khi có hai, ba tài nguyên, bạn phải lồng nhiều khối
try-finally. Mỗi khối còn cần kiểm tranull. - Che mất exception gốc. Nếu
readLine()némIOException, rồiclose()cũng ném exception, thì exception từfinallysẽ thay thế exception gốc. Bạn mất thông tin về lỗi thật sự.
try-with-resources giải quyết cả hai vấn đề này.
3. Cú pháp try-with-resources
Bạn khai báo tài nguyên trong cặp ngoặc tròn ngay sau từ khóa 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();
}
}
}
Khi khối try kết thúc, Java tự gọi reader.close(). Điều này đúng cả khi khối chạy bình thường, khi gặp return, và khi có exception. Bạn không cần viết finally.
Điều kiện duy nhất là kiểu của biến phải implement java.lang.AutoCloseable. Hầu hết lớp I/O trong JDK đều thỏa điều kiện này, ví dụ InputStream, Reader, Connection, Statement, ResultSet và Scanner.
3.1. Nhiều tài nguyên và thứ tự đóng
Bạn có thể khai báo nhiều tài nguyên, cách nhau bằng dấu chấm phẩy. Java đóng chúng theo thứ tự ngược với thứ tự khai báo:
public class ClosingOrderDemo {
record NamedResource(String name) implements AutoCloseable {
NamedResource {
System.out.println("Mở " + name);
}
@Override
public void close() {
System.out.println("Đóng " + 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("Đọc dữ liệu khách hàng");
}
}
}
Kết quả:
Mở connection
Mở statement
Mở resultSet
Đọc dữ liệu khách hàng
Đóng resultSet
Đóng statement
Đóng connection
Thứ tự này hợp lý. Tài nguyên khai báo sau thường phụ thuộc vào tài nguyên khai báo trước, như statement phụ thuộc connection, nên phải đóng trước.
Có một điểm cần nhớ. Nếu tài nguyên thứ hai ném exception ngay trong constructor, Java vẫn đóng tài nguyên thứ nhất đã mở thành công. Còn những tài nguyên chưa được tạo thì không bị gọi close().
3.2. Dùng biến có sẵn (Java 9+)
Từ Java 9, bạn có thể đưa vào try một biến đã khai báo bên ngoài, miễn là biến đó final hoặc effectively 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("Tên: " + scanner.nextLine());
System.out.println("SĐT: " + scanner.nextLine());
}
}
}
Cách này tiện khi tài nguyên được truyền vào từ tham số hoặc được tạo bởi một factory phức tạp.
3.3. catch và finally chạy sau khi đóng
try-with-resources vẫn cho phép thêm catch và finally. Tuy nhiên, cả hai đều chạy sau khi tài nguyên đã được đóng. Vì vậy, trong catch bạn không thể dùng lại tài nguyên đó, và biến tài nguyên cũng không còn trong scope.
4. Tự viết lớp AutoCloseable
AutoCloseable chỉ có một method:
void close() throws Exception;
Giả sử bạn có một phiên làm việc với cổng thanh toán, và phiên này cần được giải phóng sau mỗi đơn hàng:
public class PaymentGatewaySession implements AutoCloseable {
private final String orderId;
private boolean closed;
public PaymentGatewaySession(String orderId) {
this.orderId = orderId;
System.out.println("Mở phiên thanh toán cho " + orderId);
}
public void charge(long amount) {
if (closed) {
throw new IllegalStateException("Phiên đã đóng");
}
if (amount <= 0) {
throw new IllegalArgumentException("Số tiền không hợp lệ: " + amount);
}
System.out.println("Trừ " + amount + " VND cho đơn " + orderId);
}
public boolean isClosed() {
return closed;
}
@Override
public void close() {
if (closed) {
return;
}
closed = true;
System.out.println("Đóng phiên thanh toán cho " + orderId);
}
}
Có ba điểm đáng chú ý trong lớp này:
close()không khai báothrows Exception. Một method override được phép thu hẹp danh sách checked exception. Nhờ vậy, người dùng lớp của bạn không bị ép phảicatch (Exception e).close()idempotent: gọi nhiều lần cũng không gây lỗi và không giải phóng hai lần.charge()kiểm tra trạng tháiclosedđể chặn việc dùng tài nguyên sau khi đóng.
Cách dùng:
public class CheckoutService {
public static void main(String[] args) {
try (var session = new PaymentGatewaySession("ORD-1001")) {
session.charge(250_000);
}
}
}
Mở phiên thanh toán cho ORD-1001
Trừ 250000 VND cho đơn ORD-1001
Đóng phiên thanh toán cho ORD-1001
4.1. AutoCloseable hay Closeable?
java.io.Closeable có từ Java 5 và kế thừa AutoCloseable. Hai interface này khác nhau ở những điểm sau:
AutoCloseable |
Closeable |
|
|---|---|---|
Exception của close() |
Exception |
IOException |
| Idempotent | Khuyến nghị | Bắt buộc theo contract |
| Mục đích | Mọi loại tài nguyên | Tài nguyên I/O |
Nếu tài nguyên của bạn liên quan đến I/O, hãy dùng Closeable. Với các trường hợp còn lại, như lock, phiên làm việc hay transaction, hãy dùng AutoCloseable và thu hẹp throws càng nhiều càng tốt.
5. Suppressed exception
Quay lại vấn đề ở mục 2: khối try ném exception, rồi close() cũng ném exception. Với try-with-resources, exception từ khối try được giữ làm exception chính. Exception từ close() được gắn vào exception chính dưới dạng suppressed:
public class SuppressedDemo {
static class FlakyConnection implements AutoCloseable {
@Override
public void close() {
throw new IllegalStateException("Lỗi khi đóng kết nối");
}
}
public static void main(String[] args) {
try (var connection = new FlakyConnection()) {
throw new RuntimeException("Lỗi khi lưu đơn hàng");
} catch (RuntimeException e) {
System.out.println("Chính: " + e.getMessage());
for (Throwable suppressed : e.getSuppressed()) {
System.out.println("Bị nén: " + suppressed.getMessage());
}
}
}
}
Kết quả:
Chính: Lỗi khi lưu đơn hàng
Bị nén: Lỗi khi đóng kết nối
Bạn giữ được cả hai thông tin. Khi in stack trace bằng e.printStackTrace() hoặc qua logger, phần Suppressed: cũng xuất hiện. Nhờ đó, việc debug trên production dễ hơn nhiều.
Nếu khối try chạy bình thường và chỉ close() ném exception, thì exception từ close() sẽ là exception chính.
6. Kiểm thử hành vi đóng tài nguyên
Để viết test, bạn cần thêm JUnit 5 và 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>
Test dưới đây kiểm tra hai hành vi chính: tài nguyên luôn được đóng kể cả khi có lỗi, và exception từ close() được gắn vào exception chính dưới dạng 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);
}
}
Test thứ hai dùng lambda để tạo nhanh một AutoCloseable. Cách này được vì AutoCloseable chỉ có một abstract method, nên dùng được như một functional interface.
7. Lỗi hay gặp
7.1. Lồng tài nguyên trong constructor
try (var in = new ObjectInputStream(new FileInputStream("orders.bin"))) {
// ...
}
Constructor của ObjectInputStream đọc header của stream và có thể ném IOException. Khi đó biến in chưa được gán. FileInputStream bên trong đã mở nhưng sẽ không bao giờ được đóng. Cách an toàn là khai báo riêng từng tài nguyên:
try (var file = new FileInputStream("orders.bin");
var in = new ObjectInputStream(file)) {
// ...
}
7.2. Trả tài nguyên ra khỏi khối try
public static Stream<String> loadOrderLines(Path path) throws IOException {
try (Stream<String> lines = Files.lines(path)) {
return lines; // Sai: stream đã bị đóng khi tới tay người gọi
}
}
Người gọi sẽ nhận IllegalStateException ngay khi dùng stream. Bạn có hai cách sửa. Cách thứ nhất là xử lý hết dữ liệu bên trong try, ví dụ return lines.toList();. Cách thứ hai là trả stream ra ngoài và để người gọi tự bọc nó trong try-with-resources.
Lưu ý: Chỉ những
Streamgắn với tài nguyên I/O mới cần đóng, ví dụFiles.lines,Files.listhayFiles.walk. Stream tạo từList.stream()thì không cần.
7.3. Ném InterruptedException trong close()
Javadoc của AutoCloseable khuyên không nên ném InterruptedException từ close(). Lý do là exception này có thể bị suppress, khiến trạng thái interrupt của thread bị mất. Nếu bắt buộc phải chờ trong close(), hãy bắt exception rồi gọi Thread.currentThread().interrupt().
7.4. Tài nguyên null
Khai báo try (var conn = findConnectionOrNull()) là hợp lệ. Nếu giá trị là null, Java đơn giản bỏ qua bước gọi close(). Tuy vậy, đây thường là dấu hiệu của thiết kế chưa tốt. Bạn nên dùng Optional hoặc ném exception từ factory.
7.5. Khi nào không nên dùng
Không phải đối tượng nào implement AutoCloseable cũng nên đóng ngay trong một method. Ví dụ, trong Spring Boot, DataSource hoặc HttpClient dùng chung là các bean sống cùng application context. Spring sẽ đóng chúng khi context tắt. Nếu bạn bọc một bean dùng chung trong try-with-resources, bạn sẽ đóng nó khi các thành phần khác vẫn đang dùng.
Chỉ dùng try-with-resources cho tài nguyên mà code của bạn tạo ra và sở hữu trong phạm vi đó.
7.6. Mẹo: ExecutorService trong Java 21
Từ Java 19, ExecutorService implement AutoCloseable. Method close() sẽ chờ mọi task hoàn thành. Điều này rất tiện khi dùng virtual thread:
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("Gửi hóa đơn #" + invoiceId));
}
}
System.out.println("Đã gửi xong tất cả hóa đơn");
}
}
Dòng “Đã gửi xong tất cả hóa đơn” luôn được in cuối cùng, vì close() chặn cho tới khi ba task kết thúc. Thứ tự giữa ba hóa đơn thì không cố định.
8. Kết luận
try-with-resources giúp code ngắn hơn và an toàn hơn so với try-finally. Tài nguyên luôn được đóng, theo thứ tự ngược với lúc mở. Exception gốc không bị che mất, nhờ cơ chế suppressed exception.
Khi tự viết lớp AutoCloseable, bạn nên:
- thu hẹp
throwscủaclose(); - làm cho
close()idempotent; - chặn việc dùng đối tượng sau khi đã đóng.
Ngoài ra, hãy tránh lồng tài nguyên trong constructor, đừng trả tài nguyên ra khỏi khối try, và chỉ đóng những tài nguyên mà code của bạn thực sự sở hữu.