Cơ Chế Xử Lý Ngoại Lệ Theo Mô Hình Thực Thi
Khi phát triển ứng dụng bất đồng bộ trong Java, việc xử lý ngoại lệ không còn đơn thuần là try-catch thông thường — mà phụ thuộc mạnh vào ngữ cảnh thực thi: luồng độc lập, nhóm luồng (thread pool), hay các đối tượng đại diện cho công việc chưa hoàn tất như Future và CompletableFuture. Mỗi mô hình có cơ chế lan truyền và bắt ngoại lệ riêng, đòi hỏi cách tiếp cận chủ động và có chủ đích.
1. Luồng đơn (Thread)
Khi khởi tạo một Thread trực tiếp, ngoại lệ không được bắt trong thân hàm run() sẽ không lan ra ngoài luồng gọi — thay vào đó, chúng được chuyển tới bộ xử lý ngoại lệ chưa bắt (uncaught exception handler) của luồng đó.
- Nếu không thiết lập bộ xử lý tùy chỉnh, JVM sử dụng
ThreadGroup.uncaughtException(), dẫn đến in stack trace raSystem.errvà chấm dứt luồng. - Vì
Runnable.run()không khai báothrows, mọi ngoại lệ kiểm tra (checked exceptions) đều phải được bao bọc bên trong khốitry-catch.
Dưới đây là ba cách tiếp cận phổ biến:
// Cách 1: Bắt ngay trong thân luồng — đảm bảo không bỏ sót
Thread worker = new Thread(() -> {
try {
performCriticalTask();
} catch (IOException e) {
logger.warn("Lỗi I/O khi xử lý tác vụ", e);
} catch (Exception e) {
logger.error("Ngoại lệ không mong đợi", e);
}
});
// Cách 2: Thiết lập bộ xử lý mặc định cho toàn bộ ứng dụng
Thread.setDefaultUncaughtExceptionHandler((t, e) ->
logger.error("Luồng {} gặp sự cố: {}", t.getName(), e.getMessage())
);
// Cách 3: Dùng FutureTask để lấy lại ngoại lệ qua .get()
FutureTask<String> task = new FutureTask<>(() -> {
if (shouldFail()) throw new IllegalStateException("Thất bại có chủ đích");
return "thành công";
});
new Thread(task).start();
try {
String result = task.get(); // Chặn cho đến khi hoàn tất
} catch (ExecutionException ex) {
Throwable cause = ex.getCause();
logger.error("Tác vụ trả về ngoại lệ gốc", cause);
}
2. Nhóm luồng (ExecutorService)
Các phương thức execute(Runnable) và submit(Runnable/Callable) hành xử khác nhau với ngoại lệ:
execute(): Ngoại lệ không bắt sẽ làm luồng chết; thread pool tự động tạo luồng mới nếu cần — nhưng logic nghiệp vụ không biết điều này, dẫn đến mất dấu lỗi.submit(): Ngoại lệ được đóng gói trongFuture; chỉ khi gọiget()mới xuất hiện dưới dạngExecutionException.
Một ví dụ minh họa cách xử lý an toàn:
ExecutorService pool = Executors.newFixedThreadPool(3);
// Gửi tác vụ không trả kết quả — nên bao bọc ngoại lệ
pool.execute(() -> {
try {
processData();
} catch (Throwable t) { // Bắt cả Error (ví dụ OutOfMemoryError)
logger.error("Xử lý dữ liệu thất bại", t);
// Có thể gửi thông báo, ghi log vào hệ thống giám sát, v.v.
}
});
// Gửi tác vụ có giá trị trả về — dùng Future để kiểm soát
Future<Integer> outcome = pool.submit(() -> {
return computeValue(); // có thể ném RuntimeException
});
try {
int value = outcome.get(5, TimeUnit.SECONDS); // timeout rõ ràng
} catch (TimeoutException e) {
logger.warn("Tính toán mất quá lâu, hủy tác vụ");
outcome.cancel(true);
} catch (ExecutionException e) {
logger.error("Tính toán gặp lỗi", e.getCause());
}
3. CompletableFuture — Xử Lý Ngoại Lệ Theo Chuỗi
CompletableFuture hỗ trợ xử lý ngoại lệ theo kiểu "chuỗi phản ứng": ngoại lệ từ một giai đoạn sẽ lan tới các giai đoạn sau trừ khi bị chặn bởi một trình xử lý chuyên biệt.
exceptionally(Function<Throwable, T>): Chỉ kích hoạt khi có ngoại lệ, trả về giá trị thay thế (phù hợp vớisupplyAsync).handle(BiFunction<T, Throwable, R>): Luôn chạy — dù thành công hay thất bại — giúp thống nhất xử lý đầu ra.whenComplete(BiConsumer<T, Throwable>): Không thay đổi kết quả, chỉ dùng để ghi log hoặc dọn dẹp.
Ví dụ kết hợp nhiều tác vụ:
CompletableFuture<String> first = CompletableFuture.supplyAsync(() -> "Dữ liệu A");
CompletableFuture<String> second = CompletableFuture.supplyAsync(() -> {
if (Math.random() < 0.3) throw new RuntimeException("Lỗi ngẫu nhiên");
return "Dữ liệu B";
});
// Kết hợp hai tác vụ — nếu một trong hai thất bại, toàn bộ thenCombine thất bại
first.thenCombine(second, (a, b) -> a + " & " + b)
.exceptionally(ex -> {
logger.error("Kết hợp dữ liệu thất bại", ex);
return "Dữ liệu mặc định";
})
.thenAccept(logger::info);
// Hoặc dùng handle để phân nhánh xử lý rõ ràng
CompletableFuture.supplyAsync(this::fetchFromDatabase)
.handle((data, error) -> {
if (error != null) {
logger.warn("Truy vấn DB thất bại, dùng cache", error);
return loadFromCache();
}
return data;
})
.thenApply(this::transform);
4. Nguyên Tắc Thiết Kế An Toàn
Để tránh tình trạng "ngoại lệ im lặng" — nơi lỗi xảy ra nhưng không được phát hiện, ghi nhận hay phục hồi — cần tuân thủ các nguyên tắc sau:
- Không để ngoại lệ thoát khỏi thân
Runnable/Callable: Luôn bao bọc bằngtry-catch, kể cả vớiRuntimeException. - Ghi log đầy đủ trước khi xử lý: Ghi cả stack trace, context (ID yêu cầu, tên luồng, thời điểm), và mức độ nghiêm trọng.
- Tránh phụ thuộc vào bộ xử lý mặc định:
UncaughtExceptionHandlerchỉ là lớp phòng vệ cuối cùng, không thay thế xử lý tại nguồn. - Đóng tài nguyên đúng cách: Dùng
try-with-resourceshoặc khốifinallyđể giải phóng kết nối, file, hay bộ nhớ. - Cung cấp giá trị mặc định hoặc hành vi dự phòng: Nhất là trong môi trường bất đồng bộ, nơi người gọi không chờ kết quả tức thì.
5. So Sánh Các Cơ Chế Xử Lý
| Ngữ Cảnh | Cách Khuyến Nghị | Lý Do |
|---|---|---|
Luồng đơn (new Thread(...)) |
Dùng try-catch nội bộ hoặc thiết lập setUncaughtExceptionHandler |
Ngăn luồng chết đột ngột, đảm bảo giám sát và phục hồi |
Thread Pool (ExecutorService) |
Ưu tiên submit() + Future.get(), hoặc bao bọc execute() |
Giữ luồng sống, kiểm soát timeout, bắt được nguyên nhân gốc |
CompletableFuture |
Dùng exceptionally() hoặc handle() ở mỗi bước quan trọng |
Hạn chế lan truyền lỗi, duy trì tính toàn vẹn của chuỗi xử lý |
Tác vụ tổ hợp (allOf, anyOf, thenCombine) |
Đặt bộ xử lý ngoại lệ ngay sau phép tổ hợp | Một phần tử thất bại có thể làm sụp đổ toàn bộ chuỗi |
Việc lựa chọn cơ chế phù hợp không chỉ ảnh hưởng đến độ ổn định mà còn quyết định khả năng mở rộng, dễ bảo trì và khả năng chẩn đoán sự cố trong hệ thống phân tán hoặc đa luồng.