Mở đầu chặng mới, và tôi muốn bắt đầu bằng một đoạn mã ba dòng:

static int finallyNuot() {
    try { throw new IllegalStateException("lỗi thật"); }
    finally { return 42; }
}

Hàm này ném một ngoại lệ, rồi finallyreturn. Chuyện gì xảy ra?

  finallyNuot() trả về: 42

Ngoại lệ biến mất hoàn toàn. Không log, không stack trace, không dấu vết. Người gọi nhận về 42 và đi tiếp như chưa có gì.

Đây là ví dụ rõ nhất cho luận điểm của cả bài: với ngoại lệ, thứ nguy hiểm không phải là lỗi nổ ra, mà là lỗi không nổ ra.

Cây phân cấp

  Exception                -> Throwable -> Object
  RuntimeException         -> Exception -> Throwable -> Object
  IOException              -> Exception -> Throwable -> Object
  IllegalArgumentException -> RuntimeException -> Exception -> Throwable -> Object
  StackOverflowError       -> VirtualMachineError -> Error -> Throwable -> Object

Gốc là Throwable, chia hai nhánh:

Error — hỏng ở mức JVM: hết bộ nhớ, tràn ngăn xếp, lớp không nạp được. Đừng bắt. Bắt OutOfMemoryError rồi chạy tiếp thì lần nổ sau còn khó hiểu hơn.

Exception — mọi thứ còn lại, và đây mới là phần đáng bàn. Trong nhánh này lại chia đôi:

RuntimeException và lớp con — unchecked, trình biên dịch không ép xử lý.

Mọi Exception khác — checked, bắt buộc phải catch hoặc khai throws.

Chú ý là ranh giới checked/unchecked nằm ở chỗ có kế thừa RuntimeException hay không, chứ không phải mức độ nghiêm trọng.

Checked hay unchecked

Đây là câu hỏi gây tranh cãi nhất trong Java, và cũng là chỗ Java khác gần như mọi ngôn ngữ khác — C#, Kotlin, Python đều không có checked exception.

Ý tưởng ban đầu rất hợp lý: nếu lỗi có thể xảy ra dù bạn viết mã đúng — file biến mất, mạng đứt, CSDL từ chối — thì trình biên dịch nên nhắc bạn nghĩ tới nó.

Vấn đề là trong thực tế nó thường dẫn tới đúng thứ nó muốn ngăn:

try {
    doFile();
} catch (IOException e) {
    // TODO xử lý sau
}

Khối catch rỗng là cách nhanh nhất để trình biên dịch im lặng, và nó xoá sạch thông tin về lỗi.

Quy tắc tôi theo:

Dùng unchecked cho lỗi lập trình — tham số sai, trạng thái sai, null không mong đợi. Người gọi không thể "xử lý" một lỗi lập trình; họ phải sửa mã.

Dùng checked khi người gọi thật sự có phương án B — thử lại, dùng bản dự phòng, hỏi lại người dùng. Nếu nghĩ mãi không ra người gọi làm được gì khác ngoài "ghi log rồi bỏ cuộc", hãy dùng unchecked.

Ở ranh giới hạ tầng, chuyển checked thành unchecked của miền nghiệp vụ. Tầng nghiệp vụ không nên biết SQLException là gì.

Xu hướng chung của Java hiện đại nghiêng hẳn về unchecked. Spring bọc toàn bộ SQLException thành DataAccessException unchecked. Lambda và Stream cũng không nhận checked exception, khiến chúng càng bất tiện. Nhưng lý do gốc — buộc người viết nghĩ về thất bại — vẫn đúng, và đó là điều cần giữ lại kể cả khi bạn dùng unchecked.

Ba cách nuốt lỗi

Một: catch rỗng. Đã nói ở trên. Nếu thật sự có lý do bỏ qua, hãy viết lý do đó ra:

} catch (InterruptedException e) {
    Thread.currentThread().interrupt();   // khôi phục cờ, có chủ ý
}

Hai: return trong finally. Đúng ví dụ đầu bài. Bỏ return đi thì ngoại lệ đi tiếp bình thường:

    finally chạy, không return
  finallyDeRa() ném ra: lỗi thật

Quy tắc: không bao giờ return, break, continue hay ném ngoại lệ mới trong finally. IDE nào cũng cảnh báo, và cảnh báo đó đáng nghe.

Ba: mất nguyên nhân gốc khi bọc lại.

catch (IOException e) {
    throw new RuntimeException("không đọc được cấu hình");   // mất e!
}

Stack trace giờ chỉ tới dòng throw này, còn nguyên nhân thật — file nào, lỗi gì — biến mất. Luôn truyền nguyên nhân vào:

throw new RuntimeException("không đọc được cấu hình", e);
  không đọc được cấu hình
  nguyên nhân: java.nio.file.NoSuchFileException: /khong/co/that

Hai dòng: một cho người đọc log, một cho người gỡ lỗi. Không phải chọn giữa chúng.

Ngoại lệ bị chặn trong try-with-resources

Có một tình huống mà hai ngoại lệ xảy ra cùng lúc: thân try hỏng, rồi lúc đóng tài nguyên cũng hỏng.

try (TaiNguyen t = new TaiNguyen("tệp A")) {
    t.dung();          // ném ngoại lệ
}                      // close() cũng ném ngoại lệ
    mở tệp A
    bắt được: hỏng khi dùng tệp A
    bị chặn (suppressed): hỏng khi đóng tệp A

Java giữ lại cả hai: ngoại lệ chính là cái từ thân try — vì nó là nguyên nhân gốc — còn ngoại lệ lúc đóng được gắn kèm vào danh sách suppressed.

Đọc chúng bằng e.getSuppressed(). Stack trace in ra cũng có, dưới dòng "Suppressed:".

Với try-finally viết tay thì khác hẳn: ngoại lệ trong finally đè mất ngoại lệ gốc, và bạn chỉ còn lỗi "không đóng được file" trong khi nguyên nhân thật là "không đọc được dữ liệu". Đây là một trong những lý do mạnh nhất để dùng try-with-resources — chuyện của ngày mai.

Bắt cái gì

Bắt kiểu hẹp nhất có thể. catch (Exception e) bắt luôn cả những lỗi lập trình mà bạn không hề định xử lý.

Nhiều kiểu trong một khối nếu cách xử lý giống nhau:

catch (IOException | SQLException e) { ... }

Thứ tự từ hẹp tới rộng. Đặt catch (Exception e) trước catch (IOException e) là lỗi biên dịch — nhánh sau không bao giờ tới được, đúng luật che nhánh đã gặp ở pattern matching.

Đừng bắt Throwable. Nó gồm cả Error.

Viết thông báo lỗi cho người đọc lúc hai giờ sáng

Đây là phần ít được dạy nhất mà lại dùng nhiều nhất.

Thông báo lỗi tồi:

java.lang.IllegalArgumentException: invalid input

Thông báo lỗi tốt:

java.lang.IllegalArgumentException: số lượng đặt hàng phải từ 1 đến 100, nhận được: 250 (đơn DH-2026-0512)

Ba thứ có trong bản sau: giá trị mong đợi, giá trị thực tế, và đủ ngữ cảnh để tìm ra bản ghi. Người đọc log không có debugger, không có dữ liệu của bạn, và thường không phải người viết đoạn mã đó.

Objects.requireNonNull(x, "thông điệp") cũng nên có thông điệp vì lý do này.

Một cảnh báo: đừng đưa dữ liệu nhạy cảm vào thông báo lỗi. Mật khẩu, token, số thẻ, thông tin cá nhân — log thường được gom về nơi nhiều người đọc được hơn bạn tưởng, và giữ lâu hơn bạn tưởng.

Chi phí và những chỗ hay nhầm

Tạo ngoại lệ đắt, ném thì rẻ. Phần lớn chi phí nằm ở việc chụp stack trace lúc new. Nên đừng dùng ngoại lệ cho luồng điều khiển bình thường — vòng lặp bắt NumberFormatException để kiểm chuỗi có phải số không sẽ chậm hơn hẳn một phép kiểm tra thường.

finally luôn chạy, kể cả khi có return trong try. Ngoại lệ duy nhất là khi JVM thoát hẳn (System.exit) hoặc luồng bị giết.

try-with-resources đóng tài nguyên theo thứ tự ngược với thứ tự khai — giống như tháo dỡ.

Thử ba mươi giây

Chạy finallyNuot() trên máy bạn, rồi thêm một dòng System.out.println vào khối try trước câu throw để chắc là nó có chạy.

Ngoại lệ vẫn biến mất. Không một dấu vết. Nếu đoạn mã này nằm giữa một hệ thống thanh toán, bạn sẽ có những giao dịch "thành công" mà thật ra đã hỏng — và không có cách nào biết được từ log.

Ngày mai: try-with-resourcesAutoCloseable — vì sao nó thay thế hẳn try-finally, và cách viết một tài nguyên tự đóng đúng chuẩn.