Chương 06 · Ngoại lệ

Xử lý ngoại lệ

Java có một thứ mà hầu như không ngôn ngữ chính thống nào khác có: ngoại lệ được kiểm tra (checked exception). Trình biên dịch bắt buộc bạn phải xử lý hoặc khai báo chúng. Đây là quyết định thiết kế gây tranh cãi nhất lịch sử Java — C# cố tình không làm theo, Kotlin bỏ hẳn. Chương này giải thích cơ chế, chỉ ra khi nào nó giúp và khi nào nó phiền, cùng công cụ hiện đại nhất để quản lý tài nguyên: try-with-resources.

Độ dài: ~1500 dòng Bài tập: 5 Quiz: 8 Prerequisites: Chương 1-5
🎯 Mục tiêu chương
  • Đọc được cây phân cấp Throwable và biết mỗi nhánh nghĩa là gì.
  • Phân biệt dứt điểm checked và unchecked; biết khi nào chọn loại nào.
  • Dùng try/catch/finally và multi-catch.
  • Làm chủ try-with-resources và AutoCloseable — thay thế hẳn finally cho tài nguyên.
  • Phân biệt throw (ném) và throws (khai báo).
  • Thiết kế custom exception thành cây lỗi cho ứng dụng.
  • Giữ nguyên nhân gốc bằng exception chaining.
  • Đọc stack trace — kể cả phần Caused by và Suppressed.
  • Tránh 6 anti-pattern kinh điển, đặc biệt là "nuốt ngoại lệ".
  • Biết vì sao finally có return là bug và System.exit() bỏ qua finally.

1. Cơ chế — điều gì xảy ra khi có ngoại lệ

CoChe.java
static void tang3() {
    System.out.println("  tầng 3: bắt đầu");
    throw new IllegalStateException("hỏng ở đáy");
    // mọi dòng sau đây KHÔNG chạy — trình biên dịch còn báo "unreachable statement"
}

static void tang2() {
    System.out.println(" tầng 2: gọi tầng 3");
    tang3();
    System.out.println(" tầng 2: dòng này bị bỏ qua");
}

static void tang1() {
    try {
        tang2();
    } catch (IllegalStateException e) {
        System.out.println("tầng 1: bắt được → " + e.getMessage());
    }
}

tang1();
//  tầng 2: gọi tầng 3
//    tầng 3: bắt đầu
// tầng 1: bắt được → hỏng ở đáy

Gỡ ngăn xếp (stack unwinding)

Khi throw chạy, JVM dừng phương thức hiện tại ngay lập tức và bắt đầu đi ngược lên ngăn xếp lời gọi, tìm khối catch phù hợp. Mỗi khung được gỡ bỏ, và finally của khung đó (nếu có) được chạy trên đường đi.

Nếu đi hết ngăn xếp mà không ai bắt: luồng kết thúc, JVM in stack trace ra System.err. Nếu đó là luồng chính, chương trình thoát với mã khác 0. Điều này khác hoàn toàn với mô hình trả về mã lỗi (kiểu C): mã lỗi có thể bị bỏ qua, ngoại lệ thì không.

2. Cây phân cấp Throwable

Throwable ← gốc của MỌI thứ ném được │ ├── Error ⚠️ UNCHECKED — ĐỪNG BẮT │ ├── OutOfMemoryError hết heap │ ├── StackOverflowError đệ quy vô hạn │ ├── NoClassDefFoundError thiếu class lúc chạy │ └── AssertionError assert thất bại │ └── Exception ✅ nhánh của ứng dụng │ ├── RuntimeException ⭐ UNCHECKED — lỗi lập trình │ ├── NullPointerException gọi method trên null │ ├── ArrayIndexOutOfBoundsException chỉ số ngoài phạm vi │ ├── StringIndexOutOfBoundsException │ ├── ClassCastException ép kiểu sai │ ├── NumberFormatException parseInt("abc") │ │ └── (là con của IllegalArgumentException) │ ├── IllegalArgumentException tham số không hợp lệ │ ├── IllegalStateException object sai trạng thái │ ├── ArithmeticException chia số nguyên cho 0 │ ├── UnsupportedOperationException List.of().add() │ ├── ConcurrentModificationException │ └── NoSuchElementException Optional.get() khi rỗng │ └── các Exception khác ⭐ CHECKED — bắt buộc xử lý ├── IOException lỗi nhập/xuất │ ├── FileNotFoundException │ ├── EOFException │ └── SocketException ├── SQLException lỗi database (chương 13) ├── ClassNotFoundException Class.forName() thất bại ├── InterruptedException luồng bị ngắt (chương 12) └── ParseException
CheckedUnchecked (RuntimeException)Error
Trình biên dịch bắt buộc xử lý✅ Có❌ Không❌ Không
Nguyên nhânĐiều kiện bên ngoàiLỗi lập trìnhHệ thống/JVM hỏng
Ví dụTệp không tồn tại, mạng đứt, SQL lỗiNull, chỉ số sai, ép kiểu saiHết bộ nhớ, tràn ngăn xếp
Nên bắt?✅ Có — và xử lý có ý nghĩa⚠️ Thường là sửa code, không phải bắt❌ Không
Người gọi làm gì được?Có thể thử lại, dùng phương án khácKhông — code sai thì phải sửaKhông
Cách phân biệt thực dụng: tự hỏi "người gọi có làm được gì có ích khi gặp lỗi này không?" — Tệp không có? Họ có thể hỏi lại đường dẫn khác → checked. Chỉ số mảng sai? Không ai làm gì được, đó là bug cần sửa → unchecked.
⚠️ Đừng bao giờ bắt Error

OutOfMemoryError nghĩa là JVM hết bộ nhớ — khối catch của bạn cũng cần bộ nhớ để chạy. StackOverflowError nghĩa là ngăn xếp đã đầy. Bắt chúng thường chỉ khiến chương trình chạy tiếp trong trạng thái hỏng, che giấu vấn đề thật và gây ra lỗi khó hiểu hơn ở nơi khác.

Hệ quả: tránh catch (Throwable t) — nó bắt cả Error. Nếu thật sự cần lưới an toàn ở biên chương trình, dùng catch (Exception e).

3. try / catch / finally

TryCatch.java
try {
    int[] a = new int[3];
    a[5] = 10;
} catch (ArrayIndexOutOfBoundsException e) {
    System.out.println("Chỉ số sai: " + e.getMessage());
} finally {
    System.out.println("Luôn chạy");
}

// --- Nhiều catch: CỤ THỂ TRƯỚC, TỔNG QUÁT SAU ---
try {
    xuLyTep(duongDan);
} catch (FileNotFoundException e) {      // con của IOException
    System.out.println("Không thấy tệp");
} catch (IOException e) {                 // cha — phải đứng SAU
    System.out.println("Lỗi I/O khác");
}
// Đảo ngược thứ tự → error: exception FileNotFoundException has already been caught

// --- MULTI-CATCH (Java 7+): gộp khi xử lý giống nhau ---
try {
    xuLy();
} catch (IOException | SQLException e) {   // biến e ngầm là final
    log.error("Thao tác thất bại", e);
    throw new DichVuException("Không xử lý được", e);
}
// ⚠️ Các kiểu trong multi-catch không được có quan hệ cha-con với nhau
KhốiChạy khi nàoDùng để
tryLuônChứa đúng thao tác có thể ném — càng hẹp càng tốt
catchKhi có ngoại lệ khớp kiểuXử lý, chuyển đổi, hoặc ghi log
finallyLuôn — kể cả khi có returnDọn dẹp (nay thường thay bằng try-with-resources)
⚠️ return trong finally nuốt cả ngoại lệ
FinallyBay.java
static int xau() {
    try {
        return 1;
    } finally {
        return 2;      // ❌ ghi đè giá trị trên
    }
}
xau();   // 2

static int ratXau() {
    try {
        throw new RuntimeException("lỗi nghiêm trọng!");
    } finally {
        return 0;      // ❌❌ NUỐT ngoại lệ — không ai biết có lỗi
    }
}
ratXau();   // 0 — RuntimeException biến mất hoàn toàn

// ⚠️ Sửa giá trị trong finally cũng không có tác dụng với kiểu nguyên thủy
static int laLung() {
    int x = 1;
    try { return x; }
    finally { x = 99; }   // giá trị trả về đã được "chốt" trước đó
}
laLung();   // 1, không phải 99

Quy tắc cứng: không bao giờ đặt return, break, continue, hay throw trong finally. Chỉ đặt code dọn dẹp. Mọi công cụ phân tích tĩnh nghiêm túc (SonarQube, SpotBugs, IntelliJ) đều cảnh báo trường hợp này.

🕳️ Một trường hợp finally không chạy
KhongChay.java
try {
    System.exit(0);       // JVM thoát ngay lập tức
} finally {
    System.out.println("Không bao giờ in ra");
}

Ngoài System.exit(), finally cũng bị bỏ qua nếu JVM bị kill hoặc luồng bị huỷ ở tầng hệ điều hành. Muốn chạy code lúc thoát bình thường, đăng ký shutdown hook: Runtime.getRuntime().addShutdownHook(new Thread(...)).

4. try-with-resources — cách quản lý tài nguyên đúng

TaiNguyen.java
// ❌ CÁCH CŨ — dài dòng và VẪN CÒN BUG
BufferedReader br = null;
try {
    br = new BufferedReader(new FileReader("data.txt"));
    System.out.println(br.readLine());
} catch (IOException e) {
    e.printStackTrace();
} finally {
    if (br != null) {
        try {
            br.close();          // close() cũng ném IOException → phải try lồng!
        } catch (IOException e) {
            // nếu cả readLine() và close() cùng lỗi, lỗi nào bị mất?
        }
    }
}

// ✅ TRY-WITH-RESOURCES (Java 7+) — ngắn hơn và ĐÚNG hơn
try (BufferedReader br = new BufferedReader(new FileReader("data.txt"))) {
    System.out.println(br.readLine());
} catch (IOException e) {
    e.printStackTrace();
}
// close() tự động gọi — kể cả khi có ngoại lệ, kể cả khi return

// Nhiều tài nguyên — ĐÓNG THEO THỨ TỰ NGƯỢC (cái tạo sau đóng trước)
try (
    Connection conn = DriverManager.getConnection(url, user, pass);
    PreparedStatement ps = conn.prepareStatement(sql);
    ResultSet rs = ps.executeQuery()
) {
    while (rs.next()) { ... }
}   // đóng: rs → ps → conn

// Java 9+: dùng biến effectively final đã khai báo trước
BufferedReader reader = new BufferedReader(...);
try (reader) {
    ...
}
AutoCloseable.java — tự viết tài nguyên
public class DongHo implements AutoCloseable {
    private final String ten;
    private final long batDau = System.nanoTime();

    public DongHo(String ten) { this.ten = ten; }

    @Override
    public void close() {          // AutoCloseable.close() ném Exception;
        long ms = (System.nanoTime() - batDau) / 1_000_000;
        System.out.printf("%s: %dms%n", ten, ms);
    }                             // thu hẹp còn không ném gì là hợp lệ (chương 5)
}

try (var d = new DongHo("Nạp dữ liệu")) {
    napDuLieu();
}   // tự in thời gian, kể cả khi napDuLieu() ném lỗi

Suppressed exception — điều try-with-resources làm mà finally không làm được

Tình huống: thân try ném lỗi A, rồi close() cũng ném lỗi B. Với finally thủ công, lỗi B ghi đè lỗi A — bạn mất nguyên nhân gốc và debug rất khổ.

try-with-resources giữ cả hai: A được ném ra ngoài như bình thường, còn B được đính kèm vào A dưới dạng suppressed exception. Stack trace hiển thị đủ:

Stack trace
Exception in thread "main" java.io.IOException: lỗi khi đọc
	at Demo.main(Demo.java:5)
	Suppressed: java.io.IOException: lỗi khi đóng
		at Demo$TaiNguyen.close(Demo.java:15)
		at Demo.main(Demo.java:6)

Lấy bằng code: e.getSuppressed() trả về mảng các ngoại lệ bị chặn.

5. throw vs throws

throwthrows
Là gìCâu lệnh — ném ngoại lệMệnh đề trong chữ ký — khai báo có thể ném
Vị tríTrong thân phương thứcSau danh sách tham số
Số lượngMột object mỗi lầnNhiều kiểu, cách nhau bằng dấu phẩy
Ví dụthrow new IOException("x");void f() throws IOException, SQLException
ThrowThrows.java
// throws — khai báo: "phương thức này CÓ THỂ ném IOException"
static String docTep(String duongDan) throws IOException {
    if (duongDan == null)
        throw new IllegalArgumentException("Đường dẫn không được null");
        // ↑ unchecked → KHÔNG cần khai báo trong throws

    return Files.readString(Path.of(duongDan));  // ném IOException — checked
}

// Người gọi có HAI lựa chọn với checked exception:

// ① BẮT và xử lý
static void cach1() {
    try {
        String nd = docTep("a.txt");
    } catch (IOException e) {
        System.out.println("Dùng giá trị mặc định");
    }
}

// ② KHAI BÁO tiếp, đẩy trách nhiệm lên người gọi mình
static void cach2() throws IOException {
    String nd = docTep("a.txt");
}

// ❌ Không làm gì cả → lỗi biên dịch
// static void cach3() { docTep("a.txt"); }
//   error: unreported exception IOException; must be caught or declared to be thrown

// Đây gọi là quy tắc "CATCH OR SPECIFY" — đặc trưng riêng của Java

6. Thiết kế custom exception

CustomException.java
// ① Lớp gốc cho toàn module — cho phép bắt gộp mọi lỗi của bạn
public class ThanhToanException extends RuntimeException {
    public ThanhToanException(String msg)                  { super(msg); }
    public ThanhToanException(String msg, Throwable nguyenNhan) {
        super(msg, nguyenNhan);      // ⭐ constructor NÀY rất quan trọng
    }
}

// ② Lỗi cụ thể — MANG THEO DỮ LIỆU, không chỉ chuỗi
public class KhongDuSoDuException extends ThanhToanException {
    private final long can;
    private final long co;

    public KhongDuSoDuException(long can, long co) {
        super("Cần %,dđ nhưng chỉ có %,dđ (thiếu %,dđ)"
                .formatted(can, co, can - co));
        this.can = can;
        this.co = co;
    }

    public long getThieu() { return can - co; }
}

public class TheHetHanException extends ThanhToanException {
    private final LocalDate ngayHetHan;
    public TheHetHanException(LocalDate ngay) {
        super("Thẻ hết hạn ngày " + ngay);
        this.ngayHetHan = ngay;
    }
    public LocalDate getNgayHetHan() { return ngayHetHan; }
}

// ③ Người gọi bắt ở nhiều mức chi tiết khác nhau
try {
    thanhToan(don);
} catch (KhongDuSoDuException e) {
    hienNutNapTien(e.getThieu());          // hành động cụ thể nhờ có dữ liệu
} catch (ThanhToanException e) {
    hienThongBaoChung(e.getMessage());     // lưới an toàn cho cả module
}
Đặt dữ liệu vào exception, đừng chỉ đặt chữ. KhongDuSoDuException mang theo can và co nên handler có thể hành động (hiện nút "Nạp thêm 150.000đ"). Nếu chỉ có message, handler buộc phải phân tích chuỗi — cách làm dễ vỡ nhất.
💡 Custom exception nên checked hay unchecked?
Kế thừaKhi nàoVí dụ
RuntimeException ⭐Mặc định — người gọi không làm gì được ngoài báo lỗiVi phạm quy tắc nghiệp vụ, dữ liệu không hợp lệ
Exception (checked)Người gọi thực sự có phương án thay thếThử lại, chuyển sang nguồn dữ liệu khác

Xu hướng hiện đại nghiêng hẳn về unchecked. Spring, Hibernate và hầu hết framework lớn đều bọc SQLException (checked) thành DataAccessException (unchecked). Lý do ở mục tiếp theo.

7. Vì sao checked exception gây tranh cãi

VanDe.java
// ❌ VẤN ĐỀ 1: "throws lây lan" khắp mọi tầng
void tangDuoi()  throws IOException { ... }
void tangGiua()  throws IOException { tangDuoi(); }
void tangTren()  throws IOException { tangGiua(); }
// Tầng giao diện đâu quan tâm tới IOException — nó chỉ cần biết "thất bại"

// ❌ VẤN ĐỀ 2: dẫn tới NUỐT LỖI vì lười
try {
    xuLy();
} catch (IOException e) {
    // TODO: xử lý sau
}   // 💥 lỗi biến mất — đây là hậu quả TỆ NHẤT của checked exception

// ❌ VẤN ĐỀ 3: không dùng được với lambda (chương 10)
List<String> duongDan = ...;
duongDan.forEach(p -> Files.readString(Path.of(p)));
// ❌ error: unhandled exception type IOException
//    Consumer.accept() không khai báo throws → lambda không ném checked được

// ✅ GIẢI PHÁP: bọc thành unchecked ở biên
static String docAnToan(String p) {
    try {
        return Files.readString(Path.of(p));
    } catch (IOException e) {
        throw new UncheckedIOException(e);   // JDK có sẵn class này!
    }
}
duongDan.forEach(p -> System.out.println(docAnToan(p)));   // ✅

Bài học lịch sử

Checked exception ra đời năm 1995 với ý tưởng rất hợp lý: ép lập trình viên nghĩ về lỗi. Nhưng ba mươi năm thực tế cho thấy nó thường phản tác dụng — khi trình biên dịch bắt buộc viết catch, nhiều người viết một khối rỗng cho xong, và như vậy còn tệ hơn là để ngoại lệ bay lên.

C# (ra đời 2000, học từ Java) cố tình không có checked exception. Kotlin, Scala, và mọi ngôn ngữ JVM sau này cũng vậy. Bản thân JDK cũng đi theo hướng đó: Java 8 thêm UncheckedIOException để bọc IOException.

Lời khuyên thực dụng: với API mới của bạn, dùng unchecked là mặc định. Với checked exception từ thư viện, bọc lại thành unchecked ở biên giữa tầng hạ tầng và tầng nghiệp vụ. Chỉ giữ checked khi người gọi thật sự có phương án thay thế cụ thể.

8. Exception chaining — giữ nguyên nhân gốc

Chaining.java
// ❌ SAI — mất hoàn toàn nguyên nhân gốc
try {
    conn.executeQuery(sql);
} catch (SQLException e) {
    throw new DichVuException("Không lấy được dữ liệu");
    // stack trace chỉ trỏ vào dòng này — không biết SQL nào hỏng, vì sao
}

// ✅ ĐÚNG — truyền nguyên nhân vào constructor
try {
    conn.executeQuery(sql);
} catch (SQLException e) {
    throw new DichVuException("Không lấy được dữ liệu người dùng", e);
}

// Hoặc dùng initCause() nếu exception không có constructor nhận cause
MyException ex = new MyException("lỗi");
ex.initCause(e);
throw ex;
Stack trace có chaining
Exception in thread "main" com.vidu.DichVuException: Không lấy được dữ liệu người dùng
	at com.vidu.NguoiDungService.tim(NguoiDungService.java:42)     ← ② lớp bọc
	at com.vidu.Main.main(Main.java:15)
Caused by: java.sql.SQLException: Table 'app.nguoi_dung' doesn't exist
	at com.mysql.cj.jdbc.StatementImpl.executeQuery(StatementImpl.java:1200)
	at com.vidu.NguoiDungService.tim(NguoiDungService.java:38)     ← ① nguyên nhân THẬT
	... 1 more
💡 Cách đọc stack trace
  1. Đọc từ dưới lên — khối Caused by cuối cùng chứa nguyên nhân gốc thật sự.
  2. Tìm dòng đầu tiên thuộc code của bạn — bỏ qua các frame của thư viện và JDK. Đó thường là nơi cần sửa.
  3. ... 1 more nghĩa là các frame còn lại trùng với stack trace phía trên, đã lược bớt cho gọn.
  4. Trong ví dụ trên: bảng nguoi_dung không tồn tại, phát hiện tại NguoiDungService.java:38.

9. Sáu anti-pattern kinh điển

AntiPattern.java
// ❌ ① NUỐT NGOẠI LỆ — tội nặng nhất
try { xuLy(); } catch (Exception e) { }
// Lỗi biến mất hoàn toàn. Sau này ai đó sẽ mất cả ngày để tìm ra vì sao
// dữ liệu bị thiếu. Nếu THỰC SỰ muốn bỏ qua, hãy ghi rõ LÝ DO:
try {
    tam.delete();
} catch (IOException e) {
    log.debug("Không xoá được tệp tạm, hệ điều hành sẽ tự dọn", e);
}

// ❌ ② printStackTrace() trong code production
catch (Exception e) { e.printStackTrace(); }
// Ghi ra System.err, không vào hệ thống log, không có ngữ cảnh,
// không có thời gian, không lọc được theo mức độ.
catch (Exception e) { log.error("Xử lý đơn {} thất bại", donId, e); }   // ✅

// ❌ ③ BẮT QUÁ RỘNG
try { xuLy(); } catch (Exception e) { }
// Nuốt luôn cả NullPointerException — che giấu bug thật của bạn
catch (IOException | SQLException e) { }    // ✅ nêu rõ loại

// ❌ ④ DÙNG NGOẠI LỆ THAY ĐIỀU KIỆN
try {
    while (true) System.out.println(a[i++]);
} catch (ArrayIndexOutOfBoundsException e) { }   // dùng lỗi làm điều kiện dừng!
for (int x : a) System.out.println(x);              // ✅ rõ ràng và nhanh hơn nhiều

// ❌ ⑤ NÉM Exception TRẦN
public void luu() throws Exception { ... }
// Buộc người gọi bắt Exception — mất mọi thông tin về loại lỗi
public void luu() throws IOException, SQLException { ... }   // ✅

// ❌ ⑥ GHI LOG RỒI NÉM LẠI Ở MỌI TẦNG
catch (Exception e) {
    log.error("lỗi", e);
    throw e;          // tầng nào cũng log → cùng một lỗi in ra 5 lần
}
// ✅ Chỉ log ở BIÊN — nơi lỗi dừng lại (handler HTTP, main, vòng lặp worker).
//    Tầng giữa: hoặc để nó bay, hoặc bọc thêm ngữ cảnh bằng chaining.

10. Thực hành tốt

ThucHanhTot.java
// ✅ ① Kiểm tra tham số đầu vào ở ĐẦU phương thức (fail fast)
public void chuyenTien(TaiKhoan tu, TaiKhoan den, long soTien) {
    Objects.requireNonNull(tu,  "Tài khoản nguồn không được null");
    Objects.requireNonNull(den, "Tài khoản đích không được null");
    if (soTien <= 0)
        throw new IllegalArgumentException("Số tiền phải dương: " + soTien);
    if (tu.equals(den))
        throw new IllegalArgumentException("Không thể chuyển cho chính mình");
    ...
}

// ✅ ② Thông điệp lỗi có ĐỦ NGỮ CẢNH để hành động
throw new IllegalArgumentException("Sai");                       // ❌ vô dụng
throw new IllegalArgumentException(
    "Tuổi phải trong [0, 150], nhận %d cho người dùng %s"
        .formatted(tuoi, maNguoiDung));                             // ✅

// ✅ ③ Dùng Optional thay vì ném ngoại lệ cho "không tìm thấy"
public Optional<NguoiDung> tim(String id) { ... }   // "không có" là bình thường
public NguoiDung bacBuoc(String id) {                   // "phải có" — không có là lỗi
    return tim(id).orElseThrow(() ->
        new NguoiDungKhongTonTai(id));
}

// ✅ ④ Khôi phục cờ ngắt khi bắt InterruptedException (chương 12)
try {
    Thread.sleep(1000);
} catch (InterruptedException e) {
    Thread.currentThread().interrupt();   // ⚠️ BẮT BUỘC — nếu không, tín hiệu dừng bị mất
    throw new RuntimeException("Bị ngắt khi đang chờ", e);
}

// ✅ ⑤ Ghi tài liệu bằng @throws trong Javadoc
/**
 * Rút tiền khỏi tài khoản.
 * @param soTien số tiền, phải dương
 * @throws IllegalArgumentException nếu soTien <= 0
 * @throws KhongDuSoDuException nếu số dư không đủ
 */
public void rut(long soTien) { ... }

11. Cheat sheet chương 6

Tình huốngCách viết
Bắt nhiều loại, xử lý giống nhaucatch (IOException | SQLException e)
Nhiều loại, xử lý khác nhauNhiều catch — cụ thể trước, tổng quát sau
Quản lý tài nguyêntry (var r = new X()) { … } — không dùng finally
Tự làm tài nguyên đóng đượcimplements AutoCloseable
Kiểm tra nullObjects.requireNonNull(x, "thông điệp")
Tham số không hợp lệIllegalArgumentException
Object sai trạng tháiIllegalStateException
Chưa cài đặtUnsupportedOperationException
Bọc checked thành uncheckedthrow new UncheckedIOException(e)
Giữ nguyên nhân gốcnew MyException("msg", e)
Ghi log lỗilog.error("ngữ cảnh {}", id, e) — không printStackTrace()
"Không tìm thấy" là bình thườngTrả Optional<T>, không ném
Bắt InterruptedExceptionLuôn Thread.currentThread().interrupt()
Custom exceptionMặc định kế thừa RuntimeException; mang theo dữ liệu
Trong finallyKhông bao giờ return/throw

Bài tập

Cây exception cho thư viện HTTP

Thiết kế hệ thống ngoại lệ cho một HTTP client giả lập:

Cấu trúc
HttpException (gốc, extends RuntimeException)
 ├── KetNoiThatBai   — mang .host, .port, .nguyenNhan
 ├── QuaThoiGian     — mang .soMili
 └── LoiTrangThai    — mang .maTrangThai, .noiDung
      ├── LoiClient  — 4xx
      └── LoiServer  — 5xx
  • Viết static void kiemTraTrangThai(int ma, String body): không làm gì với 2xx/3xx, ném đúng loại con với 4xx/5xx.
  • Viết một client giả lập ngẫu nhiên ném các lỗi trên.
  • Viết hàm goiCoRetry: thử lại tối đa 3 lần với backoff luỹ thừa, nhưng chỉ retry QuaThoiGian, KetNoiThatBai và LoiServer — LoiClient phải ném ngay.
  • Hết số lần vẫn hỏng → ném lỗi cuối cùng, và đính kèm mọi lỗi trước đó dưới dạng suppressed.
Gợi ý

Suppressed: loiCuoi.addSuppressed(loiTruoc) cho từng lần thử thất bại trước đó — khi in stack trace bạn sẽ thấy đầy đủ lịch sử.
Backoff: Thread.sleep((long) Math.pow(2, lan) * 100), nhớ bắt InterruptedException và khôi phục cờ ngắt.
Việc phân biệt "lỗi đáng retry" và "lỗi không đáng retry" chính là lý do nên tách cây exception thành nhiều lớp con, thay vì một exception duy nhất mang mã lỗi.

Chứng minh suppressed exception

Viết class TaiNguyenXau implements AutoCloseable mà cả phương thức làm việc và close() đều ném ngoại lệ. Sau đó so sánh hai cách xử lý:

SoSanh.java
// Cách A: finally thủ công
TaiNguyenXau r = null;
try {
    r = new TaiNguyenXau();
    r.lamViec();      // ném "lỗi A"
} finally {
    if (r != null) r.close();   // ném "lỗi B"
}

// Cách B: try-with-resources
try (TaiNguyenXau r2 = new TaiNguyenXau()) {
    r2.lamViec();
}

Trả lời: với cách A, lỗi nào đến được người gọi? Lỗi kia đi đâu? Với cách B thì sao? In e.getSuppressed() để kiểm chứng.

Gợi ý

Cách A: lỗi B ghi đè lỗi A hoàn toàn — bạn chỉ thấy "lỗi khi đóng" và không bao giờ biết nguyên nhân thật là gì. Đây là một trong những lý do quan trọng nhất khiến try-with-resources ra đời, và cũng là lý do không nên quay lại dùng finally để đóng tài nguyên dù bạn viết nó "đúng".

Sửa sáu anti-pattern

Đoạn code sau mắc đủ sáu anti-pattern đã học. Tìm, sửa, và giải thích hậu quả cụ thể của từng lỗi.

CanSua.java
public class XuLyDon {
    public void xuLy(String tepDon) throws Exception {
        BufferedReader br = null;
        try {
            br = new BufferedReader(new FileReader(tepDon));
            String dong;
            while ((dong = br.readLine()) != null) {
                try {
                    luuVaoDb(phanTich(dong));
                } catch (Exception e) {
                    e.printStackTrace();
                }
            }
        } catch (Exception e) {
            throw new Exception("Lỗi");
        } finally {
            try { br.close(); } catch (Exception e) { }
            return;
        }
    }
}
Gợi ý

Danh sách kiểm: (1) throws Exception trần; (2) printStackTrace(); (3) bắt Exception quá rộng — nuốt cả NPE trong phanTich; (4) new Exception("Lỗi") mất nguyên nhân gốc và không có ngữ cảnh; (5) catch { } rỗng khi đóng; (6) return trong finally.
Còn một bug nữa: br.close() ném NPE nếu new FileReader đã thất bại. Bản sửa nên dùng try-with-resources, ghi rõ số dòng bị lỗi, và tiếp tục xử lý các dòng còn lại thay vì dừng cả tệp.

Trình phân tích cấu hình có báo lỗi tốt

Viết DocCauHinh đọc tệp .properties và trả về object cấu hình đã kiểm tra:

app.properties
server.port=8080
server.host=localhost
db.url=jdbc:mysql://localhost:3306/app
db.maxConnections=10
app.debug=true
  • record CauHinh(int port, String host, String dbUrl, int maxConn, boolean debug).
  • Thiếu khoá bắt buộc → CauHinhThieuKhoaException mang tên khoá.
  • Sai định dạng (port không phải số) → CauHinhSaiDinhDangException mang tên khoá, giá trị thô, kiểu mong đợi.
  • Giá trị ngoài phạm vi (port không trong 1-65535) → CauHinhNgoaiPhamViException.
  • Gom mọi lỗi rồi báo một lượt, thay vì dừng ở lỗi đầu tiên — dùng suppressed exception hoặc một list.
  • Tệp không tồn tại → dùng cấu hình mặc định và ghi log cảnh báo, không sập.
Gợi ý

Gom lỗi: thu thập vào List<Exception>, cuối cùng nếu không rỗng thì tạo một CauHinhKhongHopLeException và addSuppressed() từng lỗi con vào.
Đây là lý do người dùng ghét công cụ báo lỗi từng cái một: sửa một lỗi, chạy lại, gặp lỗi tiếp theo, lặp mười lần. Báo hết một lượt là trải nghiệm tốt hơn hẳn.

Đo chi phí ngoại lệ

Viết benchmark so sánh ba cách xử lý "phần tử không tồn tại", với 1.000.000 lần lặp:

  1. Kiểm tra bằng if trước khi truy cập.
  2. Bắt ArrayIndexOutOfBoundsException.
  3. Bắt ngoại lệ nhưng override fillInStackTrace() để trả về this (bỏ qua việc thu thập stack trace).

Lập bảng kết quả và trả lời: phần nào của việc ném ngoại lệ tốn kém nhất?

Gợi ý

Bạn sẽ thấy cách 2 chậm hơn cách 1 hàng trăm lần, còn cách 3 thì chỉ chậm hơn vài lần. Kết luận: chi phí chính không nằm ở việc ném/bắt, mà ở việc thu thập stack trace trong constructor của Throwable — nó phải duyệt toàn bộ ngăn xếp.
Đây là lý do kỹ thuật cho nguyên tắc "đừng dùng ngoại lệ cho luồng điều khiển thông thường", và cũng là lý do vài framework dùng exception singleton không có stack trace cho các trường hợp xảy ra rất thường xuyên.
Nhớ warm-up JIT: chạy vài lượt và bỏ kết quả lượt đầu.

Quiz

Q1

Checked và unchecked exception khác nhau thế nào? Cho ví dụ mỗi loại.

Xem đáp án
✓ Đáp án

Checked — mọi lớp con của Exception ngoại trừ nhánh RuntimeException. Trình biên dịch bắt buộc bạn phải catch hoặc khai báo throws (quy tắc "catch or specify"). Chúng biểu thị điều kiện bên ngoài mà người gọi có thể phản ứng: IOException, SQLException, ClassNotFoundException, InterruptedException.

Unchecked — RuntimeException và các lớp con. Không bắt buộc xử lý. Chúng biểu thị lỗi lập trình: NullPointerException, IllegalArgumentException, ArrayIndexOutOfBoundsException, ClassCastException.

Error (như OutOfMemoryError) cũng unchecked nhưng ở nhánh riêng — biểu thị hệ thống hỏng, không bao giờ bắt.

Cách phân biệt thực dụng: "người gọi có làm được gì có ích không?" Có → checked; không, chỉ có thể sửa code → unchecked.

Q2

try-with-resources hơn finally ở điểm nào?

Xem đáp án
✓ Đáp án

Bốn điểm, điểm cuối là quan trọng nhất:

  • Ngắn hơn nhiều — không cần khai báo biến ngoài, kiểm tra null, và try lồng cho close().
  • Không thể quên — trình biên dịch sinh code đóng tự động.
  • Đóng đúng thứ tự — nhiều tài nguyên được đóng theo thứ tự ngược với thứ tự tạo, luôn đúng.
  • ⭐ Không mất ngoại lệ gốc — nếu thân try ném lỗi A và close() ném lỗi B, thì với finally thủ công lỗi B sẽ ghi đè lỗi A và bạn mất nguyên nhân thật. try-with-resources ném A ra ngoài và đính B vào dưới dạng suppressed exception, lấy được bằng e.getSuppressed().

Điều kiện: tài nguyên phải cài đặt AutoCloseable (hoặc Closeable). Từ Java 9, dùng được cả biến effectively final đã khai báo trước: try (reader) { … }.

Q3

Đoạn này trả về gì? try { return 1; } finally { return 2; }

Xem đáp án
✓ Đáp án

Trả về 2. finally luôn chạy trước khi phương thức thực sự trả về, và return trong đó ghi đè giá trị đã chọn ở try.

Tệ hơn nhiều: nếu try đang ném ngoại lệ mà finally có return, ngoại lệ bị nuốt hoàn toàn — chương trình chạy tiếp như không có gì xảy ra:

Nuot.java
try { throw new RuntimeException("nghiêm trọng"); }
finally { return 0; }      // trả 0, ngoại lệ biến mất

Quy tắc cứng: finally chỉ chứa code dọn dẹp — không bao giờ có return, break, continue, hay throw. Trong code hiện đại, phần lớn nhu cầu của finally đã được try-with-resources thay thế.

Q4

Vì sao catch (Exception e) { } là anti-pattern tệ nhất?

Xem đáp án
✓ Đáp án

Nó mắc hai lỗi cùng lúc:

  • Nuốt ngoại lệ — lỗi biến mất không dấu vết. Chương trình chạy tiếp trong trạng thái hỏng, và triệu chứng xuất hiện ở một nơi hoàn toàn khác, rất lâu sau đó. Người debug không có manh mối nào.
  • Bắt quá rộng — Exception bao gồm cả RuntimeException, nên nó nuốt luôn NullPointerException và ClassCastException — tức là che giấu chính bug của bạn, thứ đáng lẽ phải lộ ra ngay để sửa.

Nếu thật sự cần bỏ qua một lỗi, hãy nêu rõ loại và lý do:

BoQuaDung.java
try {
    tepTam.delete();
} catch (IOException e) {
    log.debug("Không xoá được tệp tạm, hệ điều hành sẽ tự dọn", e);
}

Bắt Exception chỉ hợp lý ở biên chương trình — vòng lặp worker không được chết vì một job hỏng, handler HTTP phải trả 500 thay vì sập server — và ngay cả ở đó vẫn phải ghi log đầy đủ.

Q5

throw và throws khác nhau thế nào?

Xem đáp án
✓ Đáp án

throw là câu lệnh — nó thực sự ném một object ngoại lệ, đặt trong thân phương thức: throw new IOException("mất kết nối");. Mỗi lần chỉ ném một object.

throws là mệnh đề khai báo trong chữ ký phương thức — nó nói với người gọi rằng "phương thức này có thể ném những loại này": void f() throws IOException, SQLException. Liệt kê được nhiều kiểu.

Chỉ checked exception mới cần khai báo trong throws. Unchecked ném thoải mái mà không cần khai báo (dù vẫn nên ghi vào Javadoc bằng @throws để làm tài liệu).

Ghi nhớ: throw là hành động, throws là lời hứa.

Q6

Vì sao nhiều ngôn ngữ hiện đại bỏ checked exception?

Xem đáp án
✓ Đáp án

Ba vấn đề thực tế mà ba mươi năm sử dụng đã bộc lộ:

  • "throws" lây lan — một IOException ở tầng dưới cùng buộc mọi tầng phía trên phải khai báo lại, kể cả tầng giao diện vốn chẳng quan tâm chi tiết đó. Chi tiết cài đặt rò rỉ lên toàn bộ kiến trúc.
  • Khuyến khích nuốt lỗi — khi trình biên dịch ép viết catch, người vội sẽ viết một khối rỗng. Kết quả còn tệ hơn là để lỗi bay lên.
  • Xung đột với lambda — các functional interface trong JDK (Function, Consumer…) không khai báo throws, nên không viết được lambda ném checked exception. Điều này khiến Stream API rất khó dùng với I/O.

Vì vậy C# (2000) cố tình không có checked exception, và Kotlin, Scala, Groovy cũng vậy. Bản thân JDK cũng đi theo hướng đó: Java 8 thêm UncheckedIOException.

Thực hành khuyến nghị: API mới dùng unchecked làm mặc định; bọc checked exception từ thư viện thành unchecked ở biên giữa tầng hạ tầng và tầng nghiệp vụ — và luôn giữ nguyên nhân gốc bằng chaining.

Q7

Bắt InterruptedException thì phải làm gì thêm? Vì sao?

Xem đáp án
✓ Đáp án

Phải khôi phục cờ ngắt: Thread.currentThread().interrupt();

Interrupt.java
try {
    Thread.sleep(1000);
} catch (InterruptedException e) {
    Thread.currentThread().interrupt();   // ⚠️ BẮT BUỘC
    throw new RuntimeException("Bị ngắt khi đang chờ", e);
}

Lý do: khi InterruptedException được ném, JVM xoá cờ ngắt của luồng. Nếu bạn bắt ngoại lệ mà không khôi phục cờ, tín hiệu "hãy dừng lại" bị mất hoàn toàn — các tầng gọi phía trên không bao giờ biết có người đã yêu cầu luồng này dừng.

Hậu quả thực tế: ExecutorService.shutdownNow() không dừng được luồng, ứng dụng treo khi tắt, container không kill được tiến trình một cách êm đẹp. Đây là một trong những bug đa luồng phổ biến nhất và sẽ được đào sâu ở Chương 12.

Q8

"Caused by" trong stack trace nghĩa là gì? Cách đọc stack trace dài?

Xem đáp án
✓ Đáp án

Caused by xuất hiện khi có exception chaining: bạn bắt một ngoại lệ rồi ném một ngoại lệ khác, truyền cái cũ làm nguyên nhân (new MyException("msg", e)). Java in cả chuỗi, từ ngoài vào trong.

Cách đọc:

  1. Nhảy xuống khối Caused by cuối cùng — đó là nguyên nhân gốc thật sự.
  2. Tìm dòng đầu tiên thuộc package của bạn — bỏ qua các frame của JDK và thư viện. Đó thường là nơi cần sửa.
  3. ... N more nghĩa là N frame còn lại trùng với stack trace phía trên, đã lược cho gọn.
  4. Suppressed: là ngoại lệ phụ xảy ra khi đóng tài nguyên (try-with-resources) — thường ít quan trọng hơn nhưng đôi khi hé lộ nguyên nhân gốc.

Điều này giải thích vì sao throw new MyException("msg") (không truyền cause) là sai: nó cắt đứt chuỗi và bạn mất hoàn toàn thông tin về nơi lỗi thực sự phát sinh.

Tổng kết

Sau chương 6, bạn đã nắm:

  • Cơ chế: throw cắt ngang luồng, gỡ ngăn xếp tới khi gặp catch khớp.
  • Cây Throwable: Error (đừng bắt) / RuntimeException (unchecked) / các Exception khác (checked).
  • Checked vs unchecked và câu hỏi quyết định: "người gọi có làm được gì có ích không?"
  • try/catch/finally: cụ thể trước tổng quát sau, multi-catch, và cấm return trong finally.
  • try-with-resources: ngắn hơn, đóng đúng thứ tự, và giữ được ngoại lệ gốc nhờ suppressed.
  • throw vs throws: hành động vs lời hứa; quy tắc "catch or specify".
  • Custom exception: cây lỗi cho module, mang theo dữ liệu chứ không chỉ chuỗi, mặc định unchecked.
  • Chaining: luôn truyền nguyên nhân; đọc stack trace từ Caused by cuối cùng.
  • Sáu anti-pattern và cách sửa từng cái.
  • Thực hành tốt: fail fast, thông điệp đủ ngữ cảnh, Optional cho "không tìm thấy", khôi phục cờ ngắt.

Kết nối

  • Chương 5 (OOP nâng cao) — cây Throwable là ví dụ kế thừa lớn nhất JDK; quy tắc override không được mở rộng checked exception.
  • Chương 7-8 (Collections) — ConcurrentModificationException, UnsupportedOperationException, NoSuchElementException.
  • Chương 10 (Stream & Optional) — Optional thay ngoại lệ cho "không tìm thấy"; vì sao lambda kỵ checked exception.
  • Chương 11 (Tệp tin) — IOException ở khắp nơi; try-with-resources là bắt buộc.
  • Chương 12 (Đa luồng) — InterruptedException và cờ ngắt; ngoại lệ trong luồng con không tự nổi lên luồng cha.
  • Chương 13 (JDBC) — SQLException, rollback trong catch, try-with-resources cho Connection.
  • Chương 14 (Kiểm thử) — assertThrows() của JUnit 5 để khẳng định đúng loại ngoại lệ và đúng thông điệp.
  • Python Chương 5 — đối chiếu: Python không có checked exception; có khối else mà Java không có; with ≈ try-with-resources; raise … from … ≈ chaining.