Java OO & Functional
10/38
Bài 10 / 38~12 phútException HandlingLộ trình · chặng 04/21Miễn phí… lượt xem

try, catch, finally — cây Throwable và cơ chế throw

Cây Throwable (Error, Exception, RuntimeException), cơ chế try/catch/finally, throw, multi-catch Java 7+ và các anti-pattern cần tránh.

TL;DR: Ngoại lệ (Exception) là cơ chế xử lý sự cố có cấu trúc trong Java. Khi phát sinh lỗi, JVM sẽ ngắt dòng điều khiển bình thường và cuộn ngược Call Stack (Stack Unwinding) cho đến khi gặp khối catch phù hợp. Việc nắm rõ cây phân cấp Throwable và tránh các anti-pattern nguy hiểm như "nuốt ngoại lệ" hay return trong finally là điều kiện tiên quyết để xây dựng hệ thống ổn định.

Ở module trước, bạn đã hoàn thiện hệ thống quản lý ấn phẩm thư viện với các lớp dữ liệu bất biến và kiểm tra vét cạn. Nhưng trong quá trình vận hành, hệ thống sẽ phải đối mặt với vô số tình huống bất thường: mã vạch không tồn tại, kết nối cơ sở dữ liệu bị ngắt, hoặc tài khoản bạn đọc vi phạm. Cơ chế ngoại lệ chính là công cụ để kiểm soát các tình huống này.

Trong các ngôn ngữ như C, lập trình viên thường dùng mã lỗi trả về (ví dụ return -1 khi lỗi). Cách tiếp cận này khiến mã nguồn bị phân mảnh bởi hàng loạt khối lệnh if-else kiểm tra mã lỗi, và nếu lập trình viên quên kiểm tra dù chỉ một chỗ, lỗi sẽ âm thầm lan truyền gây sai lệch dữ liệu. Java giải quyết vấn đề này bằng mô hình ném và bắt ngoại lệ (throw / catch).

1. Cơ chế Unwind Stack khi xảy ra sự cố hoạt động như thế nào?

Hình dung quy trình ứng cứu tại một toà nhà văn phòng: Khi tầng 7 phát sinh sự cố cháy nổ, nhân viên tại đó có hai lựa chọn:

  1. Xử lý tại chỗ: Sử dụng bình chữa cháy mini dập tắt ngọn lửa (catch) rồi tiếp tục làm việc.
  2. Kích hoạt báo động: Nếu sự cố vượt tầm kiểm soát, kích hoạt chuông báo cháy (throw). Tín hiệu truyền ngược lên ban quản lý toà nhà để xử lý ở cấp độ cao hơn.

Trong Java, khi một phương thức ném ra một ngoại lệ mà không tự catch, JVM sẽ thực hiện quá trình cuộn ngược Call Stack (Stack Unwinding):

Bốn khung stack xếp dọc, mũi tên đi ngược từ level3 lên level2 rồi tới level1: level3 ném IOException, level2 không có catch nên bị pop và mất biến cục bộ, level1 khớp kiểu nên bắt được, main không bao giờ nhìn thấy exception

Thứ tự tìm kiếm đi ngược chiều với thứ tự gọi hàm và dừng lại ở khung stack đầu tiên có khối catch tương thích. Mỗi stack frame bị pop (loại bỏ) sẽ mang theo toàn bộ biến cục bộ của nó. Do đó, mọi thông tin quan trọng dùng để chẩn đoán lỗi bắt buộc phải được đóng gói vào chính đối tượng ngoại lệ.

2. Cây phân cấp Throwable gồm những nhánh nào?

Mọi đối tượng ngoại lệ trong Java đều bắt nguồn từ lớp cha java.lang.Throwable:

classDiagram
    class Throwable {
        +getMessage() String
        +getCause() Throwable
        +getStackTrace() StackTraceElement[]
    }
    class Error {
        <<JVM Fatal>>
    }
    class Exception {
        <<Application Errors>>
    }
    class RuntimeException {
        <<Unchecked>>
    }
    class CheckedException {
        <<Checked by Compiler>>
    }

    Throwable <|-- Error
    Throwable <|-- Exception
    Exception <|-- RuntimeException
    Exception <|-- CheckedException : IOException, SQLException...

Nhánh Error: Lỗi hệ thống nghiêm trọng của JVM

  • Đại diện cho các sự cố hạ tầng: OutOfMemoryError (cạn bộ nhớ heap), StackOverflowError (đệ quy vô tận), NoClassDefFoundError.
  • Quy tắc: Không bao giờ viết khối catch để bắt Error. Khi JVM rơi vào trạng thái này, tính toàn vẹn của tiến trình đã bị tổn hại nghiêm trọng.

Nhánh Exception: Sự cố ở tầng ứng dụng

Phân thành hai nhóm rõ rệt dựa trên cách ứng xử của trình biên dịch:

  1. Unchecked Exception (RuntimeException và các lớp con): Đại diện cho các lỗi logic lập trình (Programming Errors). Compiler không ép bạn phải khai báo throws hay viết catch. Ví dụ: NullPointerException, IllegalArgumentException, IndexOutOfBoundsException.
  2. Checked Exception (Các lớp con trực tiếp của Exception trừ RuntimeException): Đại diện cho các tình huống bất khả kháng của môi trường ngoại vi. Compiler bắt buộc bạn phải xử lý qua try-catch hoặc khai báo throws. Ví dụ: IOException, SQLException.
Ngoại lệ phổ biếnTình huống xuất hiệnCách phòng ngừa
NullPointerExceptionTruy cập thuộc tính hoặc gọi hàm trên biến null.Kiểm tra Objects.requireNonNull(...) hoặc dùng Optional.
IllegalArgumentExceptionTham số truyền vào không hợp lệ (ví dụ: số ngày mượn âm).Validate tham số ngay đầu hàm (Fail-fast).
IllegalStateExceptionTrạng thái đối tượng không cho phép thực hiện thao tác.Kiểm tra vòng đời đối tượng trước khi xử lý.
IndexOutOfBoundsExceptionTruy cập chỉ mục mảng hoặc danh sách ngoài phạm vi.Kiểm tra kích thước trước khi truy xuất.

3. Cú pháp try, catch, finally và quy tắc thứ tự bắt ngoại lệ

Khối lệnh try-catch-finally điều khiển luồng thực thi khi có sự cố:

Java
public class LibraryService {
    public int parseBorrowDays(String input, int defaultDays) {
        try {
            int days = Integer.parseInt(input);
            if (days <= 0) {
                throw new IllegalArgumentException("So ngay muon phai lon hon 0, nhan duoc: " + days);
            }
            return days;
        } catch (NumberFormatException e) {
            System.err.println("Dinh dang chuoi khong hop le: " + input);
            return defaultDays;
        }
    }
}

Quy tắc thứ tự ưu tiên trong nhiều khối catch

Khi một khối try có nhiều khối catch đi kèm, JVM sẽ so khớp kiểu từ trên xuống dưới và dừng lại ở khối đầu tiên phù hợp:

Java
try {
    loadCatalogFile("data/catalog.json");
} catch (FileNotFoundException e) {
    // 1. Bat ngoai le chuyen biet nhat truoc
    logger.warn("Tap tin danh muc chua ton tai, tao moi mac dinh.");
} catch (IOException e) {
    // 2. Bat ngoai le tong quat hon cua tang I/O
    logger.error("Loi doc ghi tap tin: " + e.getMessage());
} catch (Exception e) {
    // 3. Bat cac ngoai le ung dung con lai o cuoi cung
    logger.error("Su co he thong khong xac dinh", e);
}

Nếu bạn đặt catch (Exception e) lên trước catch (IOException e), trình biên dịch Java sẽ báo lỗi ngay: Unreachable code.

4. Multi-catch trong Java 7+ gom nhóm xử lý ra sao?

Từ Java 7, nếu nhiều loại ngoại lệ có cùng phương án xử lý, bạn có thể gộp chúng vào một khối catch duy nhất thông qua ký tự thanh đứng (|):

Java
try {
    processLoanRecord(recordId);
} catch (IOException | SQLException e) {
    logger.error("Loi truy xuat du lieu luu tru, recordId: " + recordId, e);
    throw new StorageException("Khong the truy xuat ho so muon", e);
}

Hai quy tắc quan trọng của Multi-catch:

  1. Các kiểu ngoại lệ trong cùng một multi-catch không được có quan hệ kế thừa với nhau (ví dụ: cấm viết IOException | FileNotFoundException).
  2. Biến tham chiếu ngoại lệ e ngầm định là effectively final, bạn không được gán lại giá trị cho biến e.

5. Khối finally bảo đảm dọn dẹp tài nguyên như thế nào?

Khối finally được thiết kế để thực thi các tác vụ thu dọn tài nguyên (đóng kết nối database, giải phóng file lock) bất kể khối try kết thúc bình thường hay ném ngoại lệ:

Java
Connection conn = null;
try {
    conn = databasePool.getConnection();
    executeTransaction(conn);
} catch (SQLException e) {
    logger.error("Loi thuc thi giao dich", e);
    throw new ServiceException(e);
} finally {
    if (conn != null) {
        try {
            conn.close();
        } catch (SQLException e) {
            logger.warn("Loi dong ket noi database", e);
        }
    }
}
Bẫy kinh điển: Ngoại lệ bị che khuất (Exception Shadowing)

Nếu phương thức close() trong finally ném ra ngoại lệ, ngoại lệ mới này sẽ ghi đè hoàn toàn ngoại lệ nghiệp vụ ban đầu phát sinh trong try. Log hệ thống lúc đó chỉ còn thấy lỗi đóng kết nối, làm mất hoàn toàn manh mối về nguyên nhân gốc rễ. Để khắc phục triệt để, bạn nên dùng try-with-resources.

Khối finally không chạy trong tình huống nào?

finally sẽ chạy trong 99.9% trường hợp, trừ hai ngoại lệ đặc thù:

  1. Tiến trình gọi lệnh dừng cưỡng bức: System.exit(status).
  2. Máy ảo JVM bị sập đột ngột (mất nguồn điện, nhân OS gửi tín hiệu SIGKILL / kill -9).

6. Chủ động ném ngoại lệ với từ khoá throw sao cho chuẩn mực?

Phương thức chủ động kiểm tra tiền điều kiện (Preconditions) và ném lỗi ngay khi dữ liệu không thoả mãn:

Java
public void registerLoan(String memberId, LibraryItem item) {
    if (memberId == null || memberId.isBlank()) {
        throw new IllegalArgumentException("memberId khong duoc de trong");
    }
    if (item == null) {
        throw new NullPointerException("item khong duoc phep null");
    }
    if (!item.isAvailable()) {
        throw new IllegalStateException("An pham " + item.getId() + " hien dang duoc muon boi nguoi khac");
    }
    // Tien hanh ghi nhan giao dich muon
}

Thông điệp lỗi truyền vào constructor của ngoại lệ cần cụ thể, mang lại giá trị chẩn đoán và bao gồm giá trị thực tế gây ra lỗi.

7. Những anti-pattern nguy hiểm nhất khi xử lý ngoại lệ

7.1 Nuốt ngoại lệ (Swallowing Exceptions)

Đây là lỗi thiết kế nghiêm trọng hàng đầu trong các dự án Java:

Java
// ANTI-PATTERN: Ngoai le bien mat hoan toan khoi he thong
try {
    syncCatalogWithCentralServer();
} catch (Exception e) {
    // Khong log, khong rethrow, khong hanh dong gi
}

Hai cột cùng đổ về một kết cục: cột trái doWork ném IOException rơi vào catch rỗng nên luồng chạy tiếp với dữ liệu sai, cột phải finally return 2 nên method trả về coi như không lỗi, cả hai đều không để lại gì trong log

Hậu quả: Hệ thống chạy tiếp với trạng thái dữ liệu sai lệch. Khi lỗi phát tác ở các tầng sau, lập trình viên hoàn toàn không có bất kỳ dòng log nào để xác định nguyên nhân. Cách sửa: Luôn ghi log kèm đối tượng ngoại lệ (logger.error("Sync failed", e)) hoặc bọc lại bằng domain exception và ném tiếp.

7.2 Lệnh return nằm trong khối finally

Java
public int calculateLateFee() {
    try {
        return computeBaseFee(); // Gia su tra ve 50000
    } finally {
        return 0; // Ghi de hoan toan gia tri tra ve cua try!
    }
}

Lệnh return trong finally không chỉ đè bẹp kết quả hợp lệ của khối try mà còn âm thầm dập tắt bất kỳ ngoại lệ nào đang được ném ra.

7.3 Bắt InterruptedException mà không khôi phục cờ ngắt

Khi một luồng đang ngủ (Thread.sleep) và nhận tín hiệu ngắt, JVM sẽ ném InterruptedException đồng thời xoá sạch cờ ngắt về false. Nếu bạn chỉ bắt lỗi mà không khôi phục:

Java
try {
    Thread.sleep(500);
} catch (InterruptedException e) {
    // Bat buoc phai khoi phuc co ngat cho thread hien tai
    Thread.currentThread().interrupt();
    return;
}

Nếu thiếu dòng Thread.currentThread().interrupt(), các tầng quản lý luồng cấp cao (như Thread Pool của Spring hoặc Executor) sẽ không nhận biết được yêu cầu dừng tiến trình.

8. Deep Dive Oracle & OpenJDK References

Tài liệu tham khảo đặc tả
  • JLS §14.20 (The try statement): Đặc tả chi tiết của Oracle về ngữ nghĩa thực thi của các khối lệnh try, catch và finally.
  • JLS §11 (Exceptions): Cơ sở lý thuyết về mô hình kiểm tra ngoại lệ của ngôn ngữ Java.
  • Effective Java (Joshua Bloch):
    • Item 69: Use exceptions only for exceptional conditions (Không lạm dụng ngoại lệ cho việc điều khiển luồng thông thường).
    • Item 77: Don't ignore exceptions (Không bao giờ bỏ qua ngoại lệ).

9. Tự kiểm tra kiến thức

Tự kiểm tra
0/5 câu đã trả lời
  1. Q1
    Đoạn mã sau sẽ in ra màn hình những gì và trả về giá trị bao nhiêu? public static int testFlow() { try { return 10; } finally { System.out.println("Executing finally"); return 20; } } System.out.println(testFlow());
  2. Q2
    Tại sao trình biên dịch Java báo lỗi khi bạn đặt catch (Exception e) đứng trước catch (IOException e)?
  3. Q3
    Hiện tượng Exception Shadowing trong khối finally truyền thống là gì và gây hậu quả ra sao?
  4. Q4
    Tại sao bắt ngoại lệ Error trong khối catch là một hành vi thiết kế tồi?
  5. Q5
    Khi bắt InterruptedException, tại sao lập trình viên bắt buộc phải gọi Thread.currentThread().interrupt() nếu không rethrow?

Bài tiếp theo: Đọc stack trace và cause chain — giữ dấu vết lỗi qua mọi layer

Bài này có giúp bạn hiểu bản chất không?

Bài này đáng gửi cho bạn học cùng?

Copy link đã gắn nguồn — dán group, chat, hoặc LinkedIn.

Lộ trình Junior Java Backend Developer

Chặng 4/21 · Exception handling — bài 1/7 của chặng

Thảo luận & góp ý

Hỏi đáp về bài này

Bài tiếp theo

Đọc stack trace và cause chain — giữ dấu vết lỗi qua mọi layer