Command Palette

Search for a command to run...

[Java Basics] Xử lý exception trong Java: try, catch, finally, throw và throws

Mọi method đều có hai đường ra. Nó return một giá trị, hoặc nó throw — bỏ dở lời gọi và trả một object Throwable ngược lên trên thay cho kết quả. Xử lý exception là bộ quy tắc cho đường ra thứ hai: ai tạo ra object đó, control đi đâu tiếp theo, những dòng nào vẫn chạy trên đường đi ra, và chương trình dừng ở đâu nếu không ai nhận nó.

Java có một điểm khác thường: một phần của chuyện này là câu hỏi ở thời điểm compile. Có những exception mà compiler không cho bạn phớt lờ, và số còn lại thì nó không nói gì cả. Đúng một ranh giới đó sinh ra các clause throws, và cũng chính nó là nguồn gốc của phần lớn sự rối rắm quanh exception.

Một biểu thức với hai đường ra: return đưa 25 cho caller, throw đưa / by zero cho catch

Mọi stack trace, message của compiler và dòng output bên dưới đều lấy từ việc compile và chạy code thật trên OpenJDK 21.0.6 (arm64). Không có gì được viết theo trí nhớ.

Chuyện gì xảy ra khi không ai catch nó

Bắt đầu từ trường hợp bạn chưa viết một dòng xử lý exception nào.

public class Uncaught {
    static int half(int n) {
        return 100 / n;
    }

    static int compute(int n) {
        return half(n) + 1;
    }

    public static void main(String[] args) {
        System.out.println(compute(4));
        System.out.println(compute(0));
    }
}
26
Exception in thread "main" java.lang.ArithmeticException: / by zero
	at Uncaught.half(Uncaught.java:3)
	at Uncaught.compute(Uncaught.java:7)
	at Uncaught.main(Uncaught.java:12)

Bốn chuyện đã xảy ra, và cái nào cũng quan trọng ở phần sau.

Lời gọi đầu tiên kết thúc bình thường và in ra 26, nên exception không phải là một đặc tính compile-time của code — cùng một dòng, chạy lần này thì ổn, lần sau thì hỏng, tùy dữ liệu. Phép chia integer cho 0 tạo ra một object ArithmeticException với message / by zero. Object đó đi từ half ra compute rồi ra main, và vì không method nào trên đường đi sẵn sàng nhận nó, JVM in stack trace ra rồi giết thread. Cuối cùng, stack trace đi ra standard error chứ không phải standard output, và process thoát với status 1 — chuyển hướng bằng 2>/dev/null thì bạn chỉ còn thấy mỗi 26.

Chi tiết cuối cùng đó chính là định nghĩa thực dụng của "không được xử lý": chương trình dừng sớm và báo cho shell biết là nó thất bại.

Cây Throwable: Error, Exception và RuntimeException

Mọi thứ bạn throw hay catch được đều là subclass của java.lang.Throwable. Điều đáng quan tâm không phải danh sách class mà là vị trí các nhánh, và bạn in được cây đó bằng reflection thay vì tin vào một hình vẽ.

public class Hier {
    static void chain(Class<?> c) {
        StringBuilder sb = new StringBuilder(c.getSimpleName());
        for (Class<?> s = c.getSuperclass(); s != null; s = s.getSuperclass()) {
            sb.append(" -> ").append(s.getSimpleName());
        }
        System.out.println(sb);
    }

    public static void main(String[] args) throws Exception {
        chain(NumberFormatException.class);
        chain(Class.forName("java.io.FileNotFoundException"));
        chain(StackOverflowError.class);
        chain(ArrayIndexOutOfBoundsException.class);
    }
}
NumberFormatException -> IllegalArgumentException -> RuntimeException -> Exception -> Throwable -> Object
FileNotFoundException -> IOException -> Exception -> Throwable -> Object
StackOverflowError -> VirtualMachineError -> Error -> Throwable -> Object
ArrayIndexOutOfBoundsException -> IndexOutOfBoundsException -> RuntimeException -> Exception -> Throwable -> Object

Cây Throwable: Error và RuntimeException là hai nhánh unchecked, phần còn lại dưới Exception là checked

Throwable có đúng hai subclass trực tiếp. Error dành cho những tình huống mà bản thân JVM đang gặp rắc rối — StackOverflowError, OutOfMemoryError — và code của bạn không được kỳ vọng phục hồi từ đó. Exception là tất cả phần còn lại, và bên trong nó, RuntimeException đánh dấu nhánh mà compiler bỏ qua.

ClassNhánhCheckedThường xuất hiện khi
ArithmeticExceptionRuntimeExceptionkhôngchia integer hoặc % cho 0
NullPointerExceptionRuntimeExceptionkhôngtruy cập member qua một reference null
ArrayIndexOutOfBoundsExceptionRuntimeExceptionkhôngindex âm hoặc lớn hơn bằng length
NumberFormatExceptionRuntimeExceptionkhôngInteger.parseInt nhận thứ không phải số
ClassCastExceptionRuntimeExceptionkhôngcast sang một type mà runtime type không cho phép
IllegalArgumentExceptionRuntimeExceptionkhôngchính bạn từ chối một argument
IllegalStateExceptionRuntimeExceptionkhôngobject đang ở trạng thái không cho phép gọi
ParseExceptionExceptionchuỗi không đúng định dạng mong đợi
InterruptedExceptionExceptionmột thread đang chờ bị interrupt
StackOverflowErrorErrorkhôngđệ quy không bao giờ chạm base case

Có một hệ quả của ranh giới này nên nhìn tận mắt, vì catch (Exception e) đọc lên nghe như "bắt tất cả" nhưng không phải:

public class ErrorCatch {
    static int deep(int n) { return deep(n + 1); }

    public static void main(String[] args) {
        try {
            deep(0);
        } catch (Exception e) {
            System.out.println("catch (Exception) saw it");
        } catch (Throwable t) {
            System.out.println("catch (Throwable) saw " + t.getClass().getName());
        }
    }
}
catch (Throwable) saw java.lang.StackOverflowError

Error không nằm dưới Exception, nên block đầu tiên không bao giờ chạy. Đây là thiết kế có chủ ý, và cách phản ứng đúng là bỏ hẳn cả hai block đó đi: StackOverflowError nghĩa là đệ quy thiếu base case, catch nó chỉ che bug lại chứ không sửa được gì.

try, catch và finally

Câu lệnh này có ba phần. try bọc phần code có thể throw, mỗi catch khai báo một type mà nó sẵn sàng nhận, và finally chạy trên đường đi ra bất kể bạn ra bằng lối nào. Chỉ cần có một trong hai phần catchfinally là đủ.

public class FinallyOrder {
    static void run(int divisor) {
        try {
            System.out.println("try: before");
            int q = 100 / divisor;
            System.out.println("try: 100 / " + divisor + " = " + q);
        } catch (ArithmeticException e) {
            System.out.println(e);
        } finally {
            System.out.println("finally: always");
        }
        System.out.println("after the statement");
    }

    public static void main(String[] args) {
        run(4);
        System.out.println("---");
        run(0);
    }
}
try: before
try: 100 / 4 = 25
finally: always
after the statement
---
try: before
java.lang.ArithmeticException: / by zero
finally: always
after the statement

Cùng sáu dòng chạy hai lần: lần thành công bỏ qua catch, lần throw bỏ qua phần còn lại của try

Đọc hai lần chạy cạnh nhau. Chúng thực thi cùng sáu dòng theo cùng thứ tự và mỗi bên chỉ khác đúng một dòng. Lần chạy tốt bỏ qua block catch. Lần chạy hỏng thì phép chia throw, nên phần còn lại của block try bị bỏ dở — println thứ ba không bao giờ chạy — và control nhảy thẳng sang catch. Sau đó cả hai lần đều chạy finally, và cả hai đều đi tiếp qua câu lệnh, vì một exception đã được catch là một exception đã được xử lý, method chạy tiếp bình thường.

Cũng để ý output của lần chạy thứ hai. System.out.println(e) gọi toString() trên throwable, kết quả là tên class, rồi ": ", rồi message. Nếu chỉ dùng e.getMessage() thì bạn chỉ nhận được / by zero.

Bắt nhiều type cùng lúc với multi-catch

Khi hai kiểu hỏng không liên quan nhau nhưng xứng đáng cùng một cách phản ứng, hãy liệt kê chúng trong một catch ngăn bởi | thay vì viết lặp hai block.

public class MultiCatch {

    static void describe(String[] row, int index) {
        try {
            int value = Integer.parseInt(row[index]);
            System.out.println("value  = " + (100 / value));
        } catch (NumberFormatException | ArrayIndexOutOfBoundsException e) {
            System.out.println("input  = " + e.getClass().getSimpleName() + ": " + e.getMessage());
        } catch (ArithmeticException e) {
            System.out.println("math   = " + e.getMessage());
        }
    }

    public static void main(String[] args) {
        String[] row = { "4", "x", "0" };
        describe(row, 0);
        describe(row, 1);
        describe(row, 2);
        describe(row, 9);
    }
}
value  = 25
input  = NumberFormatException: For input string: "x"
math   = / by zero
input  = ArrayIndexOutOfBoundsException: Index 9 out of bounds for length 3

Có hai luật cho các nhánh trong multi-catch. Chúng không được là quan hệ cha con, vì nếu một cái là subclass của cái kia thì cái rộng hơn đã bao trọn rồi; catch (ArithmeticException | RuntimeException e) bị từ chối với Alternatives in a multi-catch statement cannot be related by subclassing kèm một dòng thứ hai chỉ đích danh cặp đó. Và parameter là final ngầm định — gán lại e bên trong block sẽ hỏng với multi-catch parameter e may not be assigned.

Static type của e là supertype chung gần nhất của các nhánh, nên ví dụ trên gọi getClass()getMessage() — những method có trên Throwable — chứ không gọi thứ gì riêng của một type cụ thể.

Sắp xếp các block catch từ hẹp tới rộng

Các block catch được thử từ trên xuống và block đầu tiên khớp type sẽ thắng. Đặt một supertype lên trên một subtype vì vậy làm subtype không bao giờ tới lượt, và compiler coi đó là error chứ không phải warning.

try {
    int q = 100 / Integer.parseInt(args[0]);
    System.out.println(q);
} catch (Exception e) {
    System.out.println("broad: " + e);
} catch (ArithmeticException e) {
    System.out.println("narrow: " + e);
}
Ordering.java:8: error: exception ArithmeticException has already been caught
        } catch (ArithmeticException e) {
          ^
1 error

Đổi chỗ hai block là compile được, và block nào cũng tới lượt:

public class CatchOrderOk {
    static void handle(String raw) {
        try {
            System.out.println(100 / Integer.parseInt(raw));
        } catch (ArithmeticException e) {
            System.out.println("arithmetic: " + e.getMessage());
        } catch (RuntimeException e) {
            System.out.println("runtime:    " + e);
        }
    }

    public static void main(String[] args) {
        handle("4");
        handle("0");
        handle("x");
    }
}
25
arithmetic: / by zero
runtime:    java.lang.NumberFormatException: For input string: "x"

Compiler còn kiểm chiều ngược lại, nhưng chỉ với checked exception. Catch một type mà block try không thể nào ném ra sẽ bị từ chối với exception IOException is never thrown in body of corresponding try statement. Các type unchecked được miễn, vì gần như câu lệnh nào cũng có thể ném ra một RuntimeException.

finally thực sự dùng để làm gì

finally là một lời bảo đảm về dọn dẹp, không phải về xử lý lỗi. Nó chạy khi block try kết thúc bình thường, khi một exception được catch, khi một exception không được catch và đang trên đường đi ra, và khi block bị rời bằng return, break hay continue.

static int find(String[] rows) {
    for (String r : rows) {
        try {
            return Integer.parseInt(r);
        } catch (NumberFormatException e) {
            continue;
        } finally {
            System.out.println("finally after " + r);
        }
    }
    return -1;
}
finally after a
finally after b
finally after 7
found = 7

Block finally chạy sau continue ở hai dòng đầu và sau return ở dòng thứ ba — giá trị trả về được tính trước, rồi finally chạy, rồi method mới thực sự return.

javap -c cho thấy lời bảo đảm đó được cài đặt thế nào, và nó không hề ma thuật. Compiler chép thân finally vào mọi đường ra: một bản ở cuối try, một bản ở cuối catch, và một bản nữa trong handler tự sinh đăng ký cho type any — chạy bản chép rồi throw lại. Trong bytecode của một method chỉ có một lời gọi println("finally: always") trong source lại xuất hiện ba lời gọi, và đó chính là lý do không có lối ra nào bỏ sót được nó.

Có đúng một lối ra bỏ qua nó. System.exit không unwind stack, nó dừng luôn JVM:

try {
    System.out.println("try");
    System.exit(0);
} finally {
    System.out.println("finally");
}
try

Ngày xưa finally là chỗ để đóng tài nguyên — mở trước try, dùng bên trong, đóng trong finally để việc đóng xảy ra trên cả hai đường. Cách viết đó đã lỗi thời: try-with-resources làm đúng việc ấy với ít code hơn nhiều và xử lý được những trường hợp khó mà bản viết tay thường làm sai. Bài 35 nói về nó cùng với phần file.

Đọc một stack trace

Stack trace không phải là log những gì chương trình đã làm. Nó là ảnh chụp các frame đang sống tại đúng thời điểm throw, in ra theo thứ tự sâu nhất trước.

public class Unwind {

    static int parseAge(String raw) {
        return Integer.parseInt(raw.trim());
    }

    static int readRecord(String[] row) {
        return parseAge(row[1]);
    }

    static int totalAge(String[][] rows) {
        int total = 0;
        for (String[] row : rows) {
            total += readRecord(row);
        }
        return total;
    }

    public static void main(String[] args) {
        String[][] rows = { { "ann", "34" }, { "bob", "4o" } };
        System.out.println("total = " + totalAge(rows));
    }
}
Exception in thread "main" java.lang.NumberFormatException: For input string: "4o"
	at java.base/java.lang.NumberFormatException.forInputString(NumberFormatException.java:67)
	at java.base/java.lang.Integer.parseInt(Integer.java:662)
	at java.base/java.lang.Integer.parseInt(Integer.java:778)
	at Unwind.parseAge(Unwind.java:4)
	at Unwind.readRecord(Unwind.java:8)
	at Unwind.totalAge(Unwind.java:14)
	at Unwind.main(Unwind.java:21)

Đọc theo thứ tự này. Dòng đầu cho biết type và message, và message thường là chẩn đoán nhanh nhất bạn có được — For input string: "4o" chỉ thẳng ra giá trị sai, và một chữ o nằm ở chỗ lẽ ra là số 0 chính là toàn bộ cái bug.

Rồi rà xuống tìm frame đầu tiên thuộc về bạn. Ba frame trên cùng nằm trong java.base, vì parseInt là nơi object được tạo ra, nhưng không có gì sai trong JDK cả. Dòng đầu tiên thuộc code của bạn là Unwind.parseAge(Unwind.java:4), và dòng 4 chính là lời gọi parseInt. Đó là chỗ đặt breakpoint.

Các frame bên dưới là con đường dẫn tới đó — readRecord ở dòng 8, totalAge ở dòng 14, main ở dòng 21 — và đó là thứ cho bạn biết dòng dữ liệu nào đang được xử lý lúc lời gọi diễn ra.

Vẫn lấy được đúng nội dung đó mà không cần để chương trình chết. e.printStackTrace() bên trong một catch in ra đúng định dạng này, chỉ thiếu tiền tố Exception in thread "main", và chương trình chạy tiếp:

public class Pst {
    static void inner() { throw new IllegalStateException("cache not initialised"); }
    static void outer() { inner(); }

    public static void main(String[] args) {
        try {
            outer();
        } catch (IllegalStateException e) {
            e.printStackTrace();
        }
        System.out.println("still running");
    }
}
java.lang.IllegalStateException: cache not initialised
	at Pst.inner(Pst.java:2)
	at Pst.outer(Pst.java:3)
	at Pst.main(Pst.java:5)
still running

Một lưu ý về message. NullPointerException trong Java hiện đại mô tả chính xác chỗ dereference, nhưng nó gọi được tên biến nào thì còn tùy cách bạn compile. javac Npe.java bình thường cho Cannot invoke "String.length()" because "<local1>" is null; còn javac -g Npe.java, tức là giữ lại bảng biến local, cho because "name" is null.

Exception đi như thế nào: stack unwinding

Khi một throw xảy ra, JVM nhìn vào frame hiện tại xem có try nào mà catch của nó khớp type được ném ra không. Nếu không có, nó bỏ frame đó đi và nhìn sang frame gọi, cứ thế cho tới khi hoặc một frame nhận được, hoặc hết stack.

Bảy frame đang sống bên trái và bảy dòng stack trace bên phải là cùng một danh sách, từng dòng một

Việc bỏ đi các frame đó gọi là unwinding, và cái giá phải trả là mọi thứ chúng còn dở đều bị hủy. Đặt một catch ở lưng chừng cùng chương trình đó thì khác biệt hiện ngay trong output:

public class UnwindCatch {

    static int parseAge(String raw) {
        System.out.println("  parseAge: about to parse " + raw);
        int age = Integer.parseInt(raw.trim());
        System.out.println("  parseAge: never reached for a bad row");
        return age;
    }

    static int readRecord(String[] row) {
        System.out.println(" readRecord: " + row[0]);
        int age = parseAge(row[1]);
        System.out.println(" readRecord: never reached for a bad row");
        return age;
    }

    static int totalAge(String[][] rows) {
        int total = 0;
        for (String[] row : rows) {
            try {
                total += readRecord(row);
            } catch (NumberFormatException e) {
                System.out.println("totalAge: caught " + e.getMessage() + ", skipping row");
            }
        }
        return total;
    }

    public static void main(String[] args) {
        String[][] rows = { { "ann", "34" }, { "bob", "4o" }, { "cat", "29" } };
        System.out.println("total = " + totalAge(rows));
    }
}
 readRecord: ann
  parseAge: about to parse 34
  parseAge: never reached for a bad row
 readRecord: never reached for a bad row
 readRecord: bob
  parseAge: about to parse 4o
totalAge: caught For input string: "4o", skipping row
 readRecord: cat
  parseAge: about to parse 29
  parseAge: never reached for a bad row
 readRecord: never reached for a bad row
total = 63

Với dòng dữ liệu hỏng, hai lời gọi println lẽ ra phải chạy đã không chạy: phần còn lại của parseAge và phần còn lại của readRecord. Những frame đó bị bỏ trên đường tới handler. Vòng lặp trong totalAge sau đó đi tiếp sang dòng kế, và chương trình kết thúc bình thường với total = 63 thay vì chết kèm stack trace.

Đó mới là quyết định thiết kế thật đằng sau một catch: không phải "viết ở đâu cho tiện" mà "frame nào đủ cao để còn làm được điều gì có nghĩa". parseAge không thể quyết định một tuổi hỏng nghĩa là gì; totalAge, vốn biết mình đang lặp qua các dòng, thì có.

throw: chủ động ném ra một exception

throw là một câu lệnh. Nó nhận một Throwable và chuyển control đi ngay lập tức — không dòng nào sau nó trong block được chạy. Lý do thường gặp nhất để viết nó là method vừa nhận những argument mà nó không làm việc được.

public class ThrowDemo {

    static int scoreFor(int correct, int total) {
        if (total <= 0) {
            throw new IllegalArgumentException("total must be positive, got " + total);
        }
        if (correct < 0 || correct > total) {
            throw new IllegalArgumentException("correct must be 0.." + total + ", got " + correct);
        }
        return correct * 100 / total;
    }

    public static void main(String[] args) {
        System.out.println("scoreFor(17, 20) = " + scoreFor(17, 20));
        try {
            scoreFor(3, 0);
        } catch (IllegalArgumentException e) {
            System.out.println("rejected: " + e.getMessage());
        }
        try {
            scoreFor(25, 20);
        } catch (IllegalArgumentException e) {
            System.out.println("rejected: " + e.getMessage());
        }
        System.out.println("no message  -> " + new IllegalStateException().getMessage());
    }
}
scoreFor(17, 20) = 85
rejected: total must be positive, got 0
rejected: correct must be 0..20, got 25
no message  -> null

Có hai thói quen nên tập ở đây. Hãy throw đúng type mô tả vấn đề — IllegalArgumentException cho argument sai, IllegalStateException cho lời gọi sai thời điểm — thay vì một RuntimeException trơ trọi. Và hãy đặt giá trị gây lỗi vào message: got 25 là khác biệt giữa năm giây sửa xong và một buổi debug, còn dòng cuối của output cho thấy bạn nhận được gì khi bỏ qua điều đó.

Một block catch cũng có thể throw. Ném lại chính object đó sau khi log giữ nguyên stack trace gốc, vì trace được chụp lúc object được tạo ra chứ không phải lúc nó được ném:

static int strict(String raw) {
    try {
        return Integer.parseInt(raw);
    } catch (NumberFormatException e) {
        System.out.println("logging and rethrowing: " + e.getMessage());
        throw e;
    } finally {
        System.out.println("finally runs even when the catch rethrows");
    }
}
logging and rethrowing: For input string: "42x"
finally runs even when the catch rethrows
Exception in thread "main" java.lang.NumberFormatException: For input string: "42x"
	at java.base/java.lang.NumberFormatException.forInputString(NumberFormatException.java:67)
	at java.base/java.lang.Integer.parseInt(Integer.java:662)
	at java.base/java.lang.Integer.parseInt(Integer.java:778)
	at Rethrow.strict(Rethrow.java:5)
	at Rethrow.main(Rethrow.java:16)

Stack trace vẫn chỉ vào dòng 5, tức lời gọi parseInt, chứ không phải dòng throw e trong block catch.

throws: khai báo method có thể ném ra cái gì

throws là một clause trong signature của method, không phải câu lệnh, và nó không làm gì lúc runtime. Nó khai báo những checked exception nào có thể thoát ra khỏi method này, để compiler bắt mọi caller phải xử lý.

import java.text.NumberFormat;
import java.text.ParseException;
import java.util.Locale;

public class Checked {

    static long parseAmount(String raw) throws ParseException {
        NumberFormat fmt = NumberFormat.getInstance(Locale.US);
        return fmt.parse(raw).longValue();
    }

    static long totalOf(String[] raws) throws ParseException {
        long total = 0;
        for (String raw : raws) {
            total += parseAmount(raw);
        }
        return total;
    }

    public static void main(String[] args) {
        String[] good = { "1,200", "35", "9,999" };
        String[] bad = { "1,200", "n/a" };
        try {
            System.out.println("good total = " + totalOf(good));
            System.out.println("bad total  = " + totalOf(bad));
        } catch (ParseException e) {
            System.out.println("caught     = " + e);
            System.out.println("offset     = " + e.getErrorOffset());
        }
    }
}
good total = 11234
caught     = java.text.ParseException: Unparseable number: "n/a"
offset     = 0

NumberFormat.parse ném ra một ParseException checked, nên parseAmount có hai lựa chọn: catch nó, hoặc declare nó. Nó chọn declare. totalOf gặp đúng hai lựa chọn đó tại call site của mình và cũng declare. main là nơi chuỗi này dừng lại, vì đó là chỗ đầu tiên có đủ ngữ cảnh để nói một số tiền không parse được thì nghĩa là gì, nên main catch thay vì declare.

Xóa một throws khỏi chuỗi đó là build đứng lại:

UnreportedParse.java:11: error: unreported exception ParseException; must be caught or declared to be thrown
        long n = parseAmount("1,200");
                            ^
1 error

Cũng message đó xuất hiện khi một câu lệnh throw ném ra checked exception trong một method mà signature không khai báo nó. Unchecked exception thì không bao giờ cần khai báo — scoreFor ở trên ném IllegalArgumentException mà không có clause throws nào — dù viết ra vẫn hợp lệ và thỉnh thoảng được dùng như tài liệu.

"Catch it or declare it" là toàn bộ luật, và declare it rất thường là câu trả lời tốt hơn. Một method không xử lý nổi một thất bại thì nên để nó đi qua, thay vì catch rồi trả về một giá trị giả.

Checked và unchecked, và vì sao Java có cả hai

Luật này là đặc tính của class, không phải của tình huống. Mọi thứ trong nhánh Error và mọi thứ trong nhánh RuntimeException là unchecked. Mọi Throwable còn lại — kể cả chính Exception — là checked.

Hai lời gọi giống hệt nhau về hình dạng, một cái bị javac chặn và một cái bị JVM chặn

Hai lane là cùng một kiểu code, khác nhau duy nhất ở chỗ giai đoạn nào từ chối nó.

CheckedUnchecked
Nằm ở đâuException trừ nhánh RuntimeExceptionnhánh RuntimeExceptionError
Compilerbắt catch hoặc throws ở mọi call sitekhông nói gì
Bị chặn lúccompile time, bởi javacruntime, bởi JVM
Nguyên nhân thường gặpcó gì đó bên ngoài chương trình bị saimột bug trong chương trình
Ví dụParseException, InterruptedExceptionNullPointerException, IllegalArgumentException
Phản ứng hợp lýxử lý, hoặc declare để caller xử lýsửa đoạn code đã tạo ra nó

Ranh giới này tồn tại vì hai kiểu thất bại đó không giống nhau. Unparseable number: "n/a" không phải bug — input xấu, chương trình đúng, và phải có caller nào đó quyết định làm gì, nên compiler bảo đảm quyết định ấy ít nhất được viết ra ở đâu đó. Còn For input string: "4o" từ một mảng hard-code là bug: không ai xử lý nổi nó lúc runtime, và chỗ cần sửa nằm ở đoạn code sinh ra chuỗi đó.

Java là ngôn ngữ phổ biến duy nhất đưa checked exception vào type system, và thiết kế này thực sự còn gây tranh cãi — kiểu hỏng quen thuộc của nó là một clause throws phình ra mãi cho tới khi caller bọc mọi thứ trong catch (Exception e) cho yên chuyện, tức là vứt bỏ lợi ích và rước lấy đúng cái rủi ro nói ở phần dưới. Dù vậy, biết một type nằm ở nhánh nào vẫn không phải chuyện tùy chọn: nó quyết định code của bạn có compile được hay không.

Những lỗi thường gặp

Block catch rỗng. catch (NumberFormatException e) { } compile được, chạy được, và xóa sạch bằng chứng. Trong một lần chạy của một parser nhỏ, một dòng trong { "3", "seven", "9" } lặng lẽ thành 0 và tổng in ra là 12 — một con số trông hợp lý nhưng sai. Nếu bạn thực sự muốn bỏ qua các dòng hỏng thì hãy in ra hoặc đếm chúng; một thân catch trống rỗng là quyết định vứt bỏ thông tin.

Với tay lấy catch (Exception e). Nó bắt luôn mọi unchecked exception, kể cả những cái do chính bug của bạn gây ra:

static int broadCatch(String[] row) {
    try {
        return Integer.parseInt(row[3]);
    } catch (Exception e) {
        System.out.println("bad number, using 0");
        return 0;
    }
}
bad number, using 0

Mảng chỉ có ba phần tử, nên row[3] ném ArrayIndexOutOfBoundsException — một lỗi lệch một đơn vị nằm ngay trong chính method này. Handler báo về một vấn đề parsing chưa từng xảy ra rồi trả về 0, và cái bug sống sót ra tới production. Catch NumberFormatException thôi thì lỗi index sẽ đến tay bạn dưới dạng một stack trace.

return trong finally. Nó vứt bỏ mọi thứ block try đang làm, kể cả một exception đang trên đường đi ra:

static int swallow(int n) {
    try {
        return 100 / n;
    } finally {
        return -1;
    }
}
swallow(4)  = -1
swallow(0)  = -1

swallow(4) vứt đi kết quả đúng là 25, còn swallow(0) vứt đi nguyên một ArithmeticException — caller không tài nào phân biệt được hai lần chạy. javac -Xlint:all cảnh báo bằng finally clause cannot complete normally, và cảnh báo đó nên được coi như error. Điều tương tự đúng với break, continue và một throw bên trong finally:

static void lose() {
    try {
        throw new IllegalStateException("the real problem");
    } finally {
        throw new RuntimeException("thrown by finally");
    }
}
caller sees: java.lang.RuntimeException: thrown by finally

Vấn đề thật đã biến mất. Hãy giữ cho block finally chỉ làm những việc dọn dẹp không thể tự hỏng.

Tưởng finally đổi được giá trị trả về. Với kiểu primitive thì không. return result; tính result trước, rồi chạy finally, rồi trả về giá trị đã tính xong — gán lại result thành 999 trong finally vẫn trả về 25. Chỉ một lệnh return tường minh trong finally mới thay được nó, và đó chính là cái bẫy ở trên.

Catch Throwable hoặc Error. OutOfMemoryErrorStackOverflowError nghĩa là JVM đang ở trạng thái mà handler của bạn không cải thiện được. Catch chúng chỉ biến một cú crash rõ ràng thành một chương trình không đoán được.

Dùng exception cho luồng điều khiển thông thường. Vào một block try không sinh ra lệnh nào cả — trong bytecode các handler nằm ở một exception table riêng, nên phần code được bảo vệ được compile y hệt như khi không có try. Nửa tốn kém nằm ở chỗ tạo throwable: nó phải đi hết stack để điền stack trace. Lấy đó làm lối thoát khỏi vòng lặp thì tốn hơn một câu if và khó đọc hơn nhiều. Exception dành cho đường đi bất thường.

FAQ

Checked và unchecked exception trong Java khác nhau thế nào?

Checked exception là mọi thứ dưới Exception trừ nhánh RuntimeException, và compiler không cho call site phớt lờ: bạn phải catch nó hoặc thêm throws vào signature của chính mình, nếu không sẽ nhận unreported exception X; must be caught or declared to be thrown. Unchecked exception là hai nhánh RuntimeExceptionError, và compiler không nói gì cả — chúng chỉ lộ ra lúc runtime. Ý đồ là checked exception báo một tình huống bên ngoài chương trình mà caller nên quyết định, còn unchecked exception báo một bug trong chính chương trình.

finally có luôn luôn chạy trong Java không?

Gần như luôn luôn. Nó chạy khi block try kết thúc bình thường, khi một catch xử lý exception, khi một exception đang lan ra ngoài mà chưa ai catch, và khi block bị rời bằng return, break hay continue. Nó không chạy nếu JVM dừng trước — System.exit bên trong try khiến block finally không in ra gì — hoặc khi process bị giết.

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

throw là câu lệnh ném ra một exception ngay lúc đó: throw new IllegalArgumentException("total must be positive");. throws là một phần của signature, khai báo những checked exception nào có thể thoát khỏi method: static long parseAmount(String raw) throws ParseException. throw làm một việc lúc runtime; throws không làm gì lúc runtime và tồn tại thuần túy để compiler áp luật "catch it or declare it" lên mọi caller.

Đọc stack trace trong Java thế nào?

Bắt đầu từ dòng đầu tiên — nó cho biết type của exception và message, và message thường chỉ thẳng ra giá trị sai. Rồi đọc xuống các dòng at, đó là các frame đang sống theo thứ tự sâu nhất trước. Những frame trên cùng thường nằm trong java.base và không phải chỗ có bug; hãy tìm frame đầu tiên thuộc class của bạn cùng số dòng của nó và bắt đầu từ đó. Các frame bên dưới là đường gọi dẫn tới đó, cho bạn biết dữ liệu nào đang được xử lý.

Nên catch Exception hay catch một type cụ thể?

Một type cụ thể, ở mọi chỗ bạn gọi được tên nó ra. catch (Exception e) bắt luôn mọi RuntimeException, nên một ArrayIndexOutOfBoundsException do chính lỗi lệch một đơn vị của bạn sẽ bị báo cáo thành thứ mà handler giả định, và bug bị che đi. Nếu hai kiểu hỏng không liên quan nhau xứng đáng cùng một phản ứng, hãy dùng multi-catch — catch (NumberFormatException | ArrayIndexOutOfBoundsException e) — thay vì nới rộng lên Exception.

Vì sao javac báo exception ArithmeticException has already been caught?

Vì một block catch trước đó trong cùng câu lệnh khai báo một supertype của nó — thường là Exception hoặc RuntimeException — nên block cho ArithmeticException không bao giờ tới lượt. Các block catch được thử theo thứ tự trong source và cái khớp đầu tiên thắng, nên subtype phải đứng trước. Chuyển block cụ thể lên trên block tổng quát là hết error.

Có nên return giá trị từ trong block finally không?

Được về mặt cú pháp, nhưng không nên. Một return trong finally thay thế mọi thứ block try tạo ra, kể cả exception đang trên đường tới caller, nên một method chia cho 0 lại trả về một giá trị trông rất bình thường và không ai biết nó đã hỏng. javac -Xlint:all đánh dấu chuyện này bằng finally clause cannot complete normally. Hãy dùng finally để dọn dẹp và để giá trị hoặc exception đi ra từ block try.

Kết luận

Xử lý exception là một cơ chế gồm hai nửa. Nửa runtime là unwinding: một throw bỏ dở frame hiện tại, JVM bỏ dần các frame cho tới khi tìm được một catch khớp type, và các block finally chạy trên đường đi qua. Nếu không frame nào khớp, JVM in stack trace ra rồi thoát với status 1. Đúng cơ chế đó giải thích vì sao những dòng sau một throw không bao giờ chạy, vì sao stack trace liệt kê method theo thứ tự sâu nhất trước, và vì sao chỗ đúng để đặt một catch là frame đầu tiên biết đủ nhiều để quyết định.

Nửa compile-time là ranh giới checked và unchecked, và nó chỉ là câu hỏi class đó nằm ở đâu trong cây Throwable. ErrorRuntimeException là unchecked, compiler bỏ qua chúng; mọi thứ còn lại dưới Exception là checked và mọi call site phải catch hoặc declare bằng throws. Cả hai nửa đều thưởng cho sự cụ thể: gọi tên type hẹp nhất có thể trong catch, đặt giá trị gây lỗi vào message khi throw, và giữ finally cho những việc dọn dẹp không thể tự hỏng.

Thứ còn thiếu tới lúc này là một type của riêng bạn. Mọi exception trong bài đều đến từ JDK, và IllegalArgumentException không nói được gì về nghiệp vụ của bạn.

Bài tiếp theo trong series: custom exception class — viết subclass ExceptionRuntimeException của riêng mình, chọn nhánh nào để extends, và bọc một thất bại tầng thấp thành một thất bại có ý nghĩa mà không mất nguyên nhân gốc.

Bài viết liên quan

[Java Basics] Custom Exception trong Java: tự viết exception type của bạn

Cách viết một custom exception class trong Java: chọn extends Exception hay extends RuntimeException và cái giá thật sự của mỗi lựa chọn, bốn constructor của Throwable, mang dữ liệu có cấu trúc bằng field, chain cause và đọc phần Caused by cùng dòng ... N more, translate exception tại ranh giới API, và khi nào IllegalArgumentException đã nói đủ rồi.

[Java Basics] Phạm vi biến trong Java: biến cục bộ, field và static

Scope của variable trong Java: local variable, parameter, instance field và static field, block scope, shadowing, definite assignment và lifetime, cùng mọi lỗi cannot find symbol chạy thật trên JDK 21.

[Java Basics] Câu lệnh điều kiện trong Java: if, else if, else và switch

Câu lệnh điều kiện trong Java: chuỗi if / else if / else, vì sao phải có ngoặc nhọn, bẫy dangling else, điều kiện chỉ nhận boolean, switch với break và fall-through, label mũi tên, switch expression cùng yield và pattern matching của Java 21.

[Java Basics] Vòng lặp trong Java: for, while và do-while

Vòng lặp trong Java giải thích bằng cách chạy thật: thứ tự thực thi chính xác của header for, while so với do-while, enhanced for và lý do nó không ghi ngược vào array, lỗi off-by-one với length, và ba cách tạo ra vòng lặp vô hạn.