Hãy coi việc đóng tài nguyên như tắt đèn khi rời phòng. Điều bạn muốn là không thể rời phòng mà quên tắt — dù bước ra cửa chính đàng hoàng (hàm return), hay nhảy qua cửa sổ vì cháy (ngoại lệ). Nhưng có một chỗ tinh vi: nếu lúc với tay tắt đèn mà cầu dao chập (bản thân close() ném lỗi), đừng để sự cố nhỏ đó che mất lý do bạn phải bỏ chạy. try-finally viết tay đúng sách vở vẫn phạm đúng chỗ này — nó báo "cầu dao chập" rồi quên hẳn vụ cháy. Hôm nay là về cú pháp làm cả hai việc cho đúng.

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 try mà finally 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.

Muốn tự tay thấy lỗi gốc bị nuốt thì thử trong ba mươi giây: viết một lớp cài AutoCloseable mà close() ném ngoại lệ, rồi chạy cả hai cách — try-finally và try-with-resources — với thân try cũng ném một ngoại lệ khác. In ra e.getMessage() ở cả hai. Hai chuỗi khác nhau, và một trong hai sẽ khiến bạn đi sửa nhầm chỗ.

Mẫu số chung

Ý tưởng "dọn tài nguyên tự động khi ra khỏi phạm vi" không phải của riêng Java — nó là một trong những mẫu thiết kế được sao chép nhiều nhất giữa các ngôn ngữ, và biết nó đến từ đâu làm rõ try-with-resources đang đứng ở đâu trên thang "tự động tới mức nào".

  • C++ là gốc, với RAII: hàm huỷ (destructor) của đối tượng tự chạy khi biến ra khỏi phạm vi, kể cả khi thoát vì ngoại lệ. Đây là mức tự động cao nhất — bạn không viết try gì cả, chỉ cần khai biến. Rust kế thừa trực tiếp qua trait Drop, cùng cơ chế phạm vi.
  • Python có with cộng context manager (__enter__/__exit__) — cặp song sinh gần nhất của try-with-resources về cú pháp, và __exit__ nhận thông tin ngoại lệ để xử lý đúng cái "đừng che lỗi gốc".
  • C# gần như trùng khít: using (var r = ...) cộng interface IDisposable — khác tên, giống hệt ý, tới cả chuyện "thu hẹp kiểu ngoại lệ khi đóng".
  • Go chọn cú pháp khác mà cùng mục tiêu: defer r.Close() đặt ngay sau khi mở, chạy lúc hàm kết thúc theo thứ tự ngược (LIFO) — đúng "đóng ngược thứ tự mở" của ta, và khó quên vì viết liền ngay chỗ mở.

Sợi chỉ chung đáng mang theo: giải phóng tài nguyên nên buộc vào phạm vi, không buộc vào kỷ luật của người viết — vì con người quên, nhất là trên đường đi của ngoại lệ. Mọi ngôn ngữ hiện đại đều có một cơ chế để "mở thì chắc chắn đóng dù thoát kiểu gì", chỉ khác mức tự động: C++/Rust làm hoàn toàn tự động (ra khỏi phạm vi là xong), Python/C#/Java cần một từ khoá bao quanh (with/using/try), Go cần một dòng defer. Khi viết bất kỳ ngôn ngữ nào, thấy một cặp "mở/đóng", "lock/unlock", "begin/commit" là phản xạ đúng phải là "buộc nó vào phạm vi", chứ không phải "nhớ gọi cái đóng ở cuối" — vì cái "nhớ" đó chính là thứ hỏng khi có ngoại lệ.

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.