Hôm qua ta thấy finally có thể nuốt mất một ngoại lệ. Hôm nay là một phiên bản tinh vi hơn của cùng vấn đề — và nó xảy ra ngay cả khi bạn viết finally hoàn toàn đúng theo sách vở.

Cùng một tình huống, hai cách viết:

// Cách cũ
TaiNguyen t = new TaiNguyen();
try {
    throw new IllegalStateException("LỖI GỐC — cái ta cần biết");
} finally {
    t.close();          // close() cũng ném ngoại lệ
}

// Cách mới
try (TaiNguyen t = new TaiNguyen()) {
    throw new IllegalStateException("LỖI GỐC — cái ta cần biết");
}

Người gọi nhận được gì:

--- try-finally viết tay: mất lỗi gốc ---
    người gọi nhận được: đóng thất bại

--- try-with-resources: giữ cả hai ---
    người gọi nhận được: LỖI GỐC — cái ta cần biết
    kèm bị chặn: đóng thất bại

Bản trên báo "đóng thất bại". Nguyên nhân thật — thứ khiến chương trình hỏng — biến mất không dấu vết. Bạn sẽ đi sửa mã đóng file, trong khi vấn đề nằm ở chỗ khác hoàn toàn.

Bản dưới báo đúng lỗi gốc, và vẫn giữ lỗi phụ trong danh sách bị chặn.

Chỉ riêng khác biệt này đã đủ để nói: đừng viết try-finally để đóng tài nguyên nữa.

Vì sao lỗi gốc bị mất

Cơ chế của Java: khi một ngoại lệ đang được ném ra khỏi khối tryfinally lại ném một ngoại lệ khác, ngoại lệ mới thay thế hoàn toàn ngoại lệ cũ. Không có chỗ nào giữ lại cái đầu tiên.

try-with-resources sinh ra mã tương đương nhưng có thêm một bước: nó bắt ngoại lệ lúc close(), rồi gắn vào ngoại lệ gốc bằng addSuppressed() thay vì để nó đè lên.

Đọc chúng lúc gỡ lỗi:

catch (Exception e) {
    System.out.println(e.getMessage());
    for (Throwable s : e.getSuppressed()) {
        System.out.println("  bị chặn: " + s.getMessage());
    }
}

Stack trace in ra bằng printStackTrace() cũng có sẵn phần này, dưới nhãn Suppressed:. Rất nhiều người nhìn thấy dòng đó mà chưa từng biết nó nghĩa là gì — giờ thì biết.

Thứ tự đóng: ngược với thứ tự mở

try (TN a = new TN("A"); TN b = new TN("B"); TN c = new TN("C")) {
    ...
}
    mở A
    mở B
    mở C
    thân try
    đóng C
    đóng B
    đóng A

Giống như tháo dỡ giàn giáo: cái dựng sau tháo trước.

Thứ tự này không phải tuỳ tiện. Tài nguyên khai sau thường phụ thuộc vào tài nguyên khai trước — một BufferedReader bọc lấy một FileReader, một PreparedStatement thuộc về một Connection. Đóng cái ngoài trước là đúng thứ tự an toàn.

Hệ quả thực dụng: khi bọc nhiều lớp, chỉ cần khai cái ngoài cùng, vì đóng nó sẽ đóng luôn cái bên trong:

try (BufferedReader br = new BufferedReader(new FileReader("a.txt"))) { ... }

Nhưng nếu hàm khởi tạo BufferedReader ném ngoại lệ, FileReader đã tạo xong sẽ không được đóng. Trường hợp này hiếm, và nếu bạn cần chắc chắn thì khai cả hai:

try (FileReader fr = new FileReader("a.txt");
     BufferedReader br = new BufferedReader(fr)) { ... }

Hai cú pháp ít người dùng

Dùng lại biến có sẵn — từ Java 9, không cần khai lại nếu biến là final hoặc effectively final:

TN daCo = new TN("D");
try (daCo) { ... }
    mở D
    dùng lại biến bên ngoài
    đóng D

Hữu ích khi tài nguyên được truyền vào từ tham số, hoặc lấy từ một hàm nhà máy ở dòng trước.

Tài nguyên null không gây lỗi:

    thân vẫn chạy
    không NPE khi đóng null

try-with-resources kiểm tra null trước khi gọi close(). Nên một tài nguyên tuỳ chọn có thể để null mà không cần bọc thêm if.

Tự viết một AutoCloseable

Chỉ cần cài một phương thức:

class KetNoiTam implements AutoCloseable {
    private final String ten;
    KetNoiTam(String ten) { this.ten = ten; /* mở tài nguyên thật ở đây */ }

    @Override public void close() {
        // giải phóng
    }
}

Ba nguyên tắc khi viết:

close() nên gọi được nhiều lần mà không sao. Người dùng có thể gọi tay rồi try-with-resources gọi lại. Tài liệu của Closeable yêu cầu điều này; AutoCloseable thì chỉ khuyến khích, nhưng vẫn nên làm.

close() không nên ném ngoại lệ nếu tránh được. Nó thường chạy trong lúc đang xử lý một lỗi khác, và ngoại lệ từ đây chỉ làm nhiễu. Nếu buộc phải ném, hãy nghĩ xem có nên ghi log rồi nuốt không — đây là một trong số ít chỗ nuốt ngoại lệ có lý.

Đừng khai throws Exception nếu không cần. AutoCloseable.close() khai throws Exception, nhưng lớp của bạn được phép thu hẹp:

@Override public void close() { ... }         // không throws

Chi tiết nhỏ này quan trọng hơn vẻ ngoài: khai throws Exception buộc mọi người dùng phải bắt Exception — kiểu rộng nhất — trong khi họ chỉ muốn bắt lỗi cụ thể của mình.

Closeable khác AutoCloseable chỗ nào

Closeable có từ Java 5, nằm trong java.io, và close() chỉ ném IOException.

AutoCloseable có từ Java 7 cùng với try-with-resources, nằm trong java.lang, và close() ném Exception. Closeable kế thừa nó.

Chọn: viết tài nguyên I/O thì cài Closeable; mọi thứ khác — khoá, giao dịch, bộ đo thời gian, kết nối tới dịch vụ ngoài — thì cài AutoCloseable.

Dùng cho những thứ không phải I/O

Đây là chỗ try-with-resources bị dùng ít hơn mức nó xứng đáng. Bất cứ cặp "mở rồi phải đóng" nào cũng hợp:

class DoThoiGian implements AutoCloseable {
    private final String ten;
    private final long batDau = System.nanoTime();
    DoThoiGian(String ten) { this.ten = ten; }
    @Override public void close() {
        System.out.printf("%s mất %.2f ms%n", ten, (System.nanoTime() - batDau) / 1e6);
    }
}

try (var d = new DoThoiGian("nạp dữ liệu")) {
    napDuLieu();
}

Cùng mẫu đó dùng được cho: khoá (lock trong hàm khởi tạo, unlock trong close), giao dịch CSDL, ngữ cảnh log tạm thời, hoặc bật/tắt một cờ cấu hình trong test.

Cái hay là không thể quên đóng — trình biên dịch và cú pháp lo hộ, kể cả khi thân try ném ngoại lệ hay return giữa chừng.

Vài chỗ hay sai

Khai tài nguyên bên ngoài rồi mới try — như ví dụ "cách cũ" đầu bài. Không được bảo vệ gì cả.

Bắt ngoại lệ rồi vẫn đóng bằng tay trong catch — thừa, và dễ gọi close() hai lần.

Dùng cho thứ không nên đóng. Đóng System.out là hết in được cho tới khi chương trình kết thúc. Tương tự với một Connection lấy từ connection pool mà bạn định trả lại pool — tuy trường hợp này thì close() của pool thường chính là "trả về pool", nên vẫn đúng.

Quên rằng thân try vẫn có thể return. Tài nguyên vẫn được đóng — trước khi giá trị trả về tới tay người gọi. Đây là hành vi đúng và cũng là lý do mẫu này an toàn hơn hẳn viết tay.

Từ Java 7 tới nay, gần như mọi thứ trong JDK cần đóng đều đã cài AutoCloseable: InputStream, Connection, Statement, ResultSet, Scanner, ExecutorService (từ Java 19), và cả Stream — tuy Stream chỉ cần đóng khi nguồn của nó là file, chuyện của chặng sau.

Thử ba mươi giây

Viết một lớp cài AutoCloseableclose() ném ngoại lệ, rồi thử cả hai cách: try-finallytry-with-resources, với thân try cũng ném một ngoại lệ khác.

In ra e.getMessage() ở cả hai trường hợp. Hai chuỗi khác nhau, và một trong hai sẽ khiến bạn đi sửa nhầm chỗ.

Ngày mai: tự viết ngoại lệ cho miền nghiệp vụ — mang dữ liệu gì trong ngoại lệ, và vì sao một trường maDon đáng giá hơn mười dòng thông báo.