Backend

99 Ngày Java — Ngày 42: try-catch-finally đúng cách

SSite Admin
7 tháng 09, 2026 7 phút đọc 13 lượt xem
99 Ngày Java — Ngày 42: try-catch-finally đúng cách

Hôm qua ta xem bản đồ của exception; hôm nay đi vào cú pháp xử lý. Ba thứ đáng học kỹ: thứ tự catch là luật chứ không phải sở thích, multi-catch cho những trường hợp xử lý giống nhau, và quan trọng nhất — ba cái bẫy của finally, trong đó có một cái làm exception biến mất hoàn toàn mà không để lại dấu vết nào.

Sketchnote Ngày 42: try-catch-finally đúng cách — thứ tự catch từ con tới cha, multi-catch, ba cái bẫy của finally gồm return nuốt exception, và chuyện ném lại hay bọc lại kèm InterruptedException

Thứ tự catch và multi-catch

Nhiều người viết catch theo thứ tự nghĩ ra, rồi ngạc nhiên khi trình biên dịch từ chối. Thật ra luật rất đơn giản và hợp lý: con trước, cha sau — vì nếu cha đứng trước thì nó bắt hết, và khối phía sau vĩnh viễn không có cơ hội chạy.

// Nhiều catch cho nhiều loại lỗi — nhưng THỨ TỰ có luật:
try {
    doc(duongDan);
} catch (FileNotFoundException e) {     // CON phải đứng TRƯỚC
    log.warn("Không thấy file {}", duongDan);
} catch (IOException e) {               // CHA đứng sau
    log.error("Lỗi đọc file {}", duongDan, e);
}
// Đảo ngược lại thì KHÔNG BIÊN DỊCH ĐƯỢC: IOException đã bắt hết,
// khối FileNotFoundException phía sau vĩnh viễn không bao giờ chạy tới.

// MULTI-CATCH (Java 7+) — khi cách xử lý GIỐNG NHAU:
try {
    xuLy();
} catch (IOException | SQLException e) {      // một khối cho hai loại
    throw new AppException("Không truy cập được dữ liệu", e);
}
// Biến e ở đây là ngầm final — không gán lại được. Và kiểu của nó là
// tổ tiên chung gần nhất, nên chỉ gọi được method mà cả hai đều có.

// ĐỪNG bắt rồi phân loại bằng instanceof — đó là quay lại thời chưa có multi-catch:
catch (Exception e) {
    if (e instanceof IOException) { ... }      // ❌ trình biên dịch làm hộ được mà
}
  • Con trước cha sau là luật của trình biên dịch, không phải quy ước phong cách — viết ngược là lỗi biên dịch.

  • multi-catch dành cho lúc cách xử lý giống hệt nhau; biến bắt được là ngầm final và mang kiểu tổ tiên chung.

  • Bắt rộng rồi phân loại bằng instanceof là bỏ phí việc mà trình biên dịch làm hộ được — và dễ sót nhánh.

Ba cái bẫy của finally

finally có một lời hứa rất mạnh: nó luôn chạy. Chính lời hứa đó tạo ra ba tình huống mà hành vi của chương trình đi ngược hoàn toàn trực giác — và cái đầu tiên là một trong những cách âm thầm nhất để đánh mất một lỗi.

// finally LUÔN chạy — kể cả khi có return, break, hay exception khác.
// Chính vì "luôn chạy" mà nó sinh ra ba cái bẫy khó chịu nhất Java.

// BẪY 1 — return trong finally NUỐT MẤT exception:
static int nguyHiem() {
    try {
        throw new RuntimeException("lỗi thật");
    } finally {
        return 42;                 // ❌ exception BIẾN MẤT, hàm trả về 42!
    }
}
// Không lỗi, không log, không dấu vết. Đây là lý do IDE luôn cảnh báo
// "finally block does not complete normally".

// BẪY 2 — finally GHI ĐÈ giá trị trả về:
static int demSo() {
    int x = 1;
    try { return x; }              // ghi nhớ giá trị 1 để trả về
    finally { x = 99; }            // đổi x, nhưng KHÔNG đổi giá trị đã ghi nhớ
}                                  // → vẫn trả về 1 (dễ nhầm nhất khi đọc lướt)

// BẪY 3 — exception TRONG finally che mất exception GỐC:
try {
    throw new RuntimeException("lỗi gốc");     // ← cái bạn CẦN thấy
} finally {
    dong();                                     // nếu dong() cũng ném → lỗi gốc MẤT
}
// Đây chính là lý do try-with-resources (Ngày 44) ra đời: nó giữ lại
// lỗi gốc và đính lỗi lúc đóng vào phần "Suppressed".

// LUẬT NGÓN TAY CÁI: trong finally CHỈ dọn dẹp. Không return, không throw.
  • return trong finally nuốt mất exception: lỗi biến mất sạch, hàm trả về bình thường như chưa có chuyện gì. Đây là bẫy nguy hiểm nhất trong ba cái.

  • Sửa biến trong finally không đổi giá trị đã được ghi nhớ để trả về — đọc lướt rất dễ hiểu nhầm.

  • Exception ném ra từ trong finally sẽ che mất exception gốc — chính vấn đề mà try-with-resources của Ngày 44 sinh ra để giải quyết.

  • Luật ngón tay cái: trong finally chỉ dọn dẹp — không return, không throw.

Ném lại hay bọc lại

Khi bắt được một exception mà tầng của bạn không xử lý nổi, có hai lựa chọn, và chọn cái nào là một quyết định thiết kế: ném lại nguyên vẹn để tầng trên thấy đúng thứ đã xảy ra, hay bọc lại thành lỗi mang ngôn ngữ của tầng cao hơn.

// Ném lại hay bọc lại? Câu hỏi thiết kế, không phải cú pháp.

// NÉM LẠI NGUYÊN VẸN — khi tầng này không xử lý được, chỉ muốn ghi log:
catch (IOException e) {
    log.error("Không đọc được {}", duongDan, e);
    throw e;                    // giữ nguyên kiểu và stack trace
}

// BỌC LẠI — khi muốn đổi sang ngôn ngữ của tầng cao hơn:
catch (SQLException e) {
    throw new KhoDuLieuException("Không lưu được đơn hàng " + id, e);
}
// Vì sao bọc? Để tầng trên không phải biết bên dưới là SQL hay là file hay là API.
// Đây chính là cách Spring biến SQLException thành DataAccessException.

// QUY TẮC VÀNG: LUÔN truyền cause. Quên nó là vứt đi nơi lỗi thật sự xảy ra —
// và người sửa bug lúc 2 giờ sáng sẽ chỉ thấy "Không lưu được đơn hàng 42",
// không có gì cho biết vì sao.

// InterruptedException — trường hợp đặc biệt hay bị làm sai:
try { Thread.sleep(1000); }
catch (InterruptedException e) {
    Thread.currentThread().interrupt();     // ❗ KHÔI PHỤC cờ ngắt
    throw new AppException("Bị ngắt khi đang chờ", e);
}
// Bắt rồi bỏ qua là xóa mất tín hiệu "hãy dừng lại" mà ai đó vừa gửi cho luồng này.

// Và đừng quên: catch(Exception) cũng nuốt luôn cả RuntimeException của chính bạn.
// Bắt hẹp nhất có thể, để bug của mình vẫn nổ to và rõ.
  • Bọc lại để tầng trên không phải biết bên dưới là gì — đây chính là cách Spring biến SQLException thành DataAccessException.

  • Luôn truyền cause, không có ngoại lệ. Quên nó là biến một stack trace đầy đủ thành một câu thông báo cụt lủn.

  • InterruptedException phải khôi phục cờ ngắt bằng Thread.currentThread().interrupt() — bắt rồi bỏ qua là xóa mất tín hiệu dừng.

Bài tập nhỏ

  • Viết hàm ném exception trong try và return trong finally — chạy thử và nhìn exception biến mất.

  • Viết catch (IOException e) đứng trước catch (FileNotFoundException e) và đọc chính xác lỗi biên dịch.

  • Trong finally, gọi một method cũng ném exception — quan sát lỗi gốc bị che mất thế nào.

  • Bọc một SQLException mà cố tình quên cause, rồi so hai stack trace cạnh nhau.

  • Viết vòng lặp gọi Thread.sleep, bắt InterruptedException rồi bỏ qua, và thử ngắt luồng đó — xem nó vẫn chạy tiếp.

Kết lại

Bốn ý gói lại hôm nay: thứ tự catch là con trước cha sau, và multi-catch dành cho những nhánh xử lý giống nhau; finally luôn chạy nên đừng bao giờ return hay throw trong đó — return ở đây nuốt sạch exception; ném lại hay bọc lại là quyết định thiết kế, và dù chọn cách nào thì luôn truyền cause; còn InterruptedException thì phải khôi phục cờ ngắt chứ không được nuốt. Ngày 43 ta chuyển sang phía thiết kế: custom exception — khi nào đáng tạo lớp exception riêng, đặt tên và viết message thế nào cho người đọc log hiểu được. Hẹn gặp lại!

S

Site Admin

Engineer and writer. Building things with TypeScript and distributed systems.

Bình luận (0)

Bạn cần đăng nhập bằng Google để bình luận.

Hãy là người bình luận đầu tiên.

Bài viết liên quan

99 Ngày Spring — Ngày 90: Docker Compose cho Dev

compose.yaml cho Postgres, Redis và Kafka, spring-boot-docker-compose tự khởi động và tự nối kết, cùng ranh giới với Testcontainers.

25 thg 10, 20262 phút4
99 Ngày Java — Ngày 90: Tổ chức Project và Clean Code

Package theo feature, đặt tên nói lên ý định, hàm nhỏ với guard clause và những nguyên tắc clean code dùng được ngay.

25 thg 10, 20263 phút4
99 Ngày Spring — Ngày 89: Docker hóa Spring Boot

Dockerfile nhiều stage, layered jar để tận dụng cache, chạy không dùng root và buildpacks khi không muốn viết Dockerfile.

23 thg 10, 20263 phút7