Xử Lý Ngoại Lệ Trong Lập Trình Bất Đồng Bộ Java

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ư FutureCompletableFuture. 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 ra System.err và chấm dứt luồng.
  • Runnable.run() không khai báo throws, mọi ngoại lệ kiểm tra (checked exceptions) đều phải được bao bọc bên trong khối try-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)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 trong Future; chỉ khi gọi get() mới xuất hiện dưới dạng ExecutionException.

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ới supplyAsync).
  • 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:

  1. Không để ngoại lệ thoát khỏi thân Runnable/Callable: Luôn bao bọc bằng try-catch, kể cả với RuntimeException.
  2. 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.
  3. Tránh phụ thuộc vào bộ xử lý mặc định: UncaughtExceptionHandler chỉ là lớp phòng vệ cuối cùng, không thay thế xử lý tại nguồn.
  4. Đóng tài nguyên đúng cách: Dùng try-with-resources hoặc khối finally để giải phóng kết nối, file, hay bộ nhớ.
  5. 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.

Thẻ: Java completablefuture executor thread exception-handling

Đăng vào ngày 23 tháng 5 lúc 17:39