Java chia exception thành 2 loại: checked (compiler bắt buộc bạn xử lý) và unchecked (tự do). Đây là tính năng độc đáo của Java — không ngôn ngữ mainstream nào khác (C#, Python, JavaScript, Go, Rust) có khái niệm "checked exception". Ban đầu được kỳ vọng giúp code an toàn hơn, thực tế gây ra nhiều tranh cãi — và ngày nay nhiều codebase Java chọn tránh checked exception hoàn toàn.
Bài này giải thích rule compiler, vì sao checked bị chỉ trích, và pattern wrap checked thành unchecked khi bạn không muốn chịu rule.
1. Định nghĩa nhanh
| Checked | Unchecked | |
|---|---|---|
| Kế thừa | Exception (không qua RuntimeException) | RuntimeException hoặc Error |
| Compiler bắt | ✅ Phải khai throws hoặc try/catch | ❌ Tự do |
| Ví dụ | IOException, SQLException, ClassNotFoundException | NullPointerException, IllegalArgumentException, IndexOutOfBoundsException |
| Triết lý | "Lỗi caller phải xử lý" | "Lỗi thường là bug code hoặc không xử lý được" |
2. Checked exception — compiler ép bạn xử lý
import java.io.*;
public static String readFile(String path) {
FileReader r = new FileReader(path); // COMPILE ERROR — nem IOException
BufferedReader br = new BufferedReader(r);
return br.readLine();
}
FileReader(String) khai báo throws FileNotFoundException (subtype của IOException). Compiler bắt bạn phải làm 1 trong 2:
2.1 Khai throws — đẩy trách nhiệm lên caller
public static String readFile(String path) throws IOException {
FileReader r = new FileReader(path);
BufferedReader br = new BufferedReader(r);
return br.readLine();
}
// Caller:
public static void main(String[] args) throws IOException { // cung phai khai
String line = readFile("a.txt");
}
Lỗi được "đẩy" lên call chain — mỗi caller trong chain phải khai throws hoặc bắt. Nếu lên đến main vẫn không catch, JVM in stack trace rồi dừng.
2.2 Try/catch — xử lý tại chỗ
public static String readFile(String path) {
try {
FileReader r = new FileReader(path);
BufferedReader br = new BufferedReader(r);
return br.readLine();
} catch (IOException e) {
log.error("Read failed: " + path, e);
return null; // hoac throw exception khac
}
}
3. Unchecked exception — không bị compiler ép
public static int divide(int a, int b) {
if (b == 0) {
throw new ArithmeticException("Division by zero");
}
return a / b;
}
// Caller — khong can try/catch, khong can throws
int x = divide(10, 0); // compile OK, runtime nem exception
ArithmeticException là RuntimeException → unchecked → compiler không ép. Caller có thể try/catch hoặc không — tuỳ ý.
Các RuntimeException phổ biến:
NullPointerException(NPE) — dereference null.IllegalArgumentException— argument sai.IllegalStateException— state sai cho operation.IndexOutOfBoundsException— access ngoài giới hạn.ArithmeticException— chia 0, overflow exact operation.ClassCastException— cast sai.
4. Triết lý gốc của Oracle
Ý đồ khi thiết kế:
- Checked cho lỗi "ngoài kiểm soát code" mà caller phải xử lý: file không tồn tại, network rớt, DB timeout. Compiler ép caller đối diện, không thể bỏ qua.
- Unchecked cho lỗi "programmer bug" — không lường trước được, chỉ fix bằng sửa code: NPE, cast sai, index tràn. Không đáng bắt từng chỗ.
Nghe có vẻ hợp lý. Nhưng thực tế đã chỉ ra nhiều vấn đề.
5. Vì sao checked bị chỉ trích?
5.1 Boilerplate lớn
public void doStuff() throws IOException, SQLException, ParseException, InterruptedException {
// logic 5 dong
}
public void caller() throws IOException, SQLException, ParseException, InterruptedException {
doStuff();
// 5 dong logic cua rieng caller
}
public void main() throws IOException, SQLException, ParseException, InterruptedException {
caller();
// ...
}
Signature bị "ô nhiễm" bởi list exception. Mọi layer phải khai lại.
5.2 Phá compose — functional / stream
List<String> result = files.stream()
.map(f -> readFile(f)) // COMPILE ERROR — readFile throws IOException
.collect(toList());
Stream.map nhận Function<T, R> — interface có method không khai throws. Không thể dùng trực tiếp method khai checked exception trong stream. Phải wrap:
.map(f -> {
try { return readFile(f); }
catch (IOException e) { throw new UncheckedIOException(e); }
})
→ noise nhiều hơn logic.
5.3 Lazy developer wrap hết thành Exception
public void doStuff() throws Exception { // "lazy throws"
// code
}
public void caller() throws Exception { // compiler buoc throws tiep
doStuff();
}
Khai throws Exception làm compiler happy — nhưng mất hoàn toàn signal "lỗi gì có thể xảy ra". Caller không biết cần bắt gì. Pattern này phổ biến ở code cẩu thả, phản tác dụng ý đồ checked.
5.4 Không scale lên stream/async/reactive
Java 8+ stream, CompletableFuture, reactive (Reactor, RxJava) đều dựa vào functional interface không cho phép checked exception trong signature. Viết code modern đòi hỏi chuyển mọi thứ sang unchecked.
6. Modern Java — chuyển sang unchecked
Nhiều project Java hiện đại (Spring Boot, Micronaut, Quarkus) ưa chuộng unchecked exception cho domain code:
// Cu — checked
public class UserRepository {
public User findById(long id) throws SQLException { ... }
}
// Modern — unchecked
public class UserRepository {
public User findById(long id) {
try { ... }
catch (SQLException e) {
throw new DataAccessException("Failed to find user " + id, e);
}
}
}
public class DataAccessException extends RuntimeException { ... }
Spring JdbcTemplate, JPA's RuntimeException-based exception, Hibernate — đều wrap SQLException checked thành unchecked. Lý do:
- Caller không nhất thiết phải try/catch — thường ở layer trên xử lý chung (global exception handler).
- Service layer không bị "đóng dấu throws SQLException" lan toàn hệ thống.
- Compose tốt với stream/async.
6.1 UncheckedIOException (Java 8+)
JDK cũng thừa nhận xu hướng này — Java 8 đưa ra UncheckedIOException:
public class UncheckedIOException extends RuntimeException {
public UncheckedIOException(IOException cause) {
super(cause);
}
}
Wrap IOException thành unchecked để dùng trong stream/functional context:
.map(f -> {
try { return Files.readString(f); }
catch (IOException e) { throw new UncheckedIOException(e); }
})
7. Khi nào VẪN dùng checked?
✅ Nên dùng checked khi:
- Bạn thiết kế API thư viện mà caller bắt buộc phải handle — vd
IOExceptionkhi đọc file,SQLExceptionkhi DB lỗi. Caller cần biết để retry / fallback. - Exception recoverable — có phương án xử lý hợp lý.
- Rare case, không gọi trong hot loop.
❌ Tránh checked khi:
- Exception là bug programmer (null, index, cast) — luôn unchecked.
- Code domain high-level — service/controller — ưa unchecked + global handler.
- API functional/stream — không compose được.
- Exception không có phương án handle meaningful — chỉ log & fail.
8. Rethrow checked sang unchecked — pattern chuẩn
public String readConfig(String path) {
try {
return Files.readString(Path.of(path));
} catch (IOException e) {
throw new ConfigLoadException("Cannot read config: " + path, e);
}
}
public class ConfigLoadException extends RuntimeException {
public ConfigLoadException(String msg, Throwable cause) {
super(msg, cause);
}
}
Rule viết custom exception wrap:
extends RuntimeException→ unchecked.- Constructor nhận
(String msg, Throwable cause)— luôn lưu cause. - Đặt tên rõ ngữ nghĩa:
ConfigLoadException,OrderNotFoundException.
super(msg, cause) lưu cause qua getCause() — stack trace in ra cả "Caused by: IOException..." — không mất thông tin.
9. Pitfall tổng hợp
❌ Nhầm 1: throws Exception rỗng nghĩa.
public void foo() throws Exception { ... }
✅ Khai chính xác: throws IOException, SQLException. Hoặc wrap thành unchecked.
❌ Nhầm 2: Catch checked rồi nuốt.
try { ... } catch (IOException e) { /* nothing */ }
✅ Ít nhất log + throw unchecked wrap.
❌ Nhầm 3: Tạo exception extends Exception (checked) khi không cần.
public class OrderNotFoundException extends Exception { } // ep moi caller try/catch
✅ Mặc định extends RuntimeException. Chỉ extends Exception khi thật sự cần ép caller handle.
❌ Nhầm 4: Rethrow mất cause.
catch (IOException e) { throw new RuntimeException("bad"); } // mat stack trace goc
✅ throw new RuntimeException("bad", e); — truyền cause.
❌ Nhầm 5: Check exception chi tiết tỉ mỉ nhưng bỏ unchecked.
try {
parseFile(); // nem IOException (checked)
} catch (IOException e) { ... }
// Khong check bien input co null hay khong -> NPE runtime
✅ Validate input từ đầu method (Objects.requireNonNull) trước khi đi vào logic.
10. 📚 Deep Dive Oracle
ℹ️ 📚 Deep Dive Oracle (optional)
Spec / reference chính thức:
- JLS §11.1 — Kinds of Exceptions — định nghĩa checked/unchecked.
- JLS §11.2 — Compile-Time Checking — rule compiler bắt khai throws.
- UncheckedIOException — ví dụ JDK wrap checked.
- Effective Java Item 71: "Avoid unnecessary use of checked exceptions"; Item 73: "Throw exceptions appropriate to the abstraction".
- Anders Hejlsberg on Checked Exceptions — kiến trúc sư C# giải thích vì sao C# không có checked.
Ghi chú: Anders Hejlsberg (thiết kế C#) phỏng vấn năm 2003 nói về lý do C# không có checked exception — ông cho rằng checked phá tính versioning và scalability của code. Bài phỏng vấn này thường được coi là phản biện kinh điển cho checked. Trong Java community, Bloch (Effective Java) không đi xa đến mức đó nhưng khuyên giảm thiểu checked.
11. Tóm tắt
- Checked exception extends
Exception(không quaRuntimeException) → compiler bắt khaithrowshoặc catch. - Unchecked exception extends
RuntimeExceptionhoặcError→ tự do, không ép. - Triết lý ban đầu: checked cho "lỗi ngoại lai caller phải xử", unchecked cho "bug programmer".
- Thực tế: checked gây boilerplate, phá functional/stream, lazy developer wrap
throws Exception. - Modern Java (Spring, Micronaut, JPA) nghiêng về unchecked cho domain code + global exception handler.
UncheckedIOException(Java 8+) — JDK chính thức thừa nhận xu hướng unchecked.- Pattern chuẩn: catch checked → wrap thành custom
RuntimeExceptionvới cause. - Custom exception mặc định extends
RuntimeException. ExtendsExceptionchỉ khi thật sự cần ép handle. - Luôn giữ cause khi rethrow:
new X(msg, originalException).
12. Tự kiểm tra
Q1Đoạn sau compile không?public static String read(String path) {
FileReader r = new FileReader(path);
BufferedReader br = new BufferedReader(r);
return br.readLine();
}
▸
public static String read(String path) {
FileReader r = new FileReader(path);
BufferedReader br = new BufferedReader(r);
return br.readLine();
}Không compile. new FileReader(path) ném FileNotFoundException (subtype của IOException, checked). br.readLine() ném IOException. Compiler bắt method phải 1 trong 2:
- Khai
throws:public static String read(String path) throws IOException { ... }. - Try/catch trong method:
try { ... } catch (IOException e) { ... }.
Đây là rule compile-time của checked exception (JLS §11.2) — thiết kế buộc caller phải đối diện với lỗi IO.
Q2Tại sao stream sau compile error?List<String> lines = files.stream()
.map(f -> Files.readString(Paths.get(f)))
.toList();
▸
List<String> lines = files.stream()
.map(f -> Files.readString(Paths.get(f)))
.toList();Files.readString ném IOException (checked). Stream.map nhận Function<T, R> — interface apply(T) không khai throws. Lambda không thể ném checked exception mà method interface không khai → compile error.
Fix — wrap thành unchecked trong lambda:
.map(f -> {
try { return Files.readString(Paths.get(f)); }
catch (IOException e) { throw new UncheckedIOException(e); }
})Đây là lý do cộng đồng Java kêu ca nhiều về checked exception — phá compose với functional API. JDK giới thiệu UncheckedIOException (Java 8) chính là để giảm boilerplate cho pattern này.
Q3Khi tạo exception domain OrderNotFoundException, chọn extends Exception hay RuntimeException? Vì sao?▸
OrderNotFoundException, chọn extends Exception hay RuntimeException? Vì sao?Mặc định: extends RuntimeException (unchecked).
Lý do:
- Không ép caller boilerplate: service gọi repository không phải khai
throws OrderNotFoundExceptionlan ra mọi layer. - Compose với stream/async: lambda/future không cần wrap.
- Thường xử lý tại global handler:
@ExceptionHandlertrong Spring map OrderNotFoundException → HTTP 404. Caller không cần try/catch từng chỗ. - Phù hợp modern style: Spring/JPA/Hibernate đều dùng RuntimeException cho data access.
Chỉ extends Exception (checked) khi thực sự muốn ép caller handle tại chỗ — hiếm trong domain code, phổ biến hơn trong low-level API.
Q4Đoạn sau có mất thông tin gì?try {
connectDb();
} catch (SQLException e) {
throw new DataAccessException("DB failed");
}
▸
try {
connectDb();
} catch (SQLException e) {
throw new DataAccessException("DB failed");
}Mất cause chain — SQLException gốc bị bỏ. Stack trace sau cùng chỉ hiện DataAccessException: DB failed, không có "Caused by: SQLException: ...". Developer mất thông tin về lỗi thực sự (connection refused? timeout? query syntax?).
Fix: truyền cause:
throw new DataAccessException("DB failed", e);Với điều kiện DataAccessException có constructor nhận (String, Throwable):
public class DataAccessException extends RuntimeException {
public DataAccessException(String msg, Throwable cause) {
super(msg, cause);
}
}Stack trace in ra "Caused by: SQLException: connection refused" — giữ full context cho debug. Đây là rule không bao giờ được bỏ khi wrap exception.
Q5Khi nào nên dùng checked exception?▸
Checked exception vẫn có chỗ, dù ngày càng ít:
- Low-level IO/resource API:
IOExceptioncho file/network — caller thường có phương án retry/fallback rõ ràng (thử server khác, đổi path, lấy cache). - API yêu cầu caller xử lý bắt buộc: khi không handle là bug logic thực sự. Vd
ClassNotFoundExceptionkhi dynamic load class — caller cần quyết định fallback. - Recoverable exception: có phương án xử lý meaningful. Nếu không biết handle thế nào ngoài log, để unchecked.
- Legacy interface: đã khai checked, giữ backward compat.
Còn lại (domain code, business logic, service layer) — mặc định unchecked. Effective Java Item 71: "Avoid unnecessary use of checked exceptions".
Litmus test: "Caller làm được gì khác ngoài bỏ tay? Có fallback hợp lý không?". Không → unchecked. Có → cân nhắc checked, nhưng đánh giá chi phí boilerplate.
Bài tiếp theo: try-with-resources — tự đóng resource không rò rỉ