TungDaDev's Blog

cạm bẫy virtual threads trong production: khi project loom không phải viên đạn bạc

Virtual threads pitfalls.jpg
Published on
/9 mins read/

Không có bữa trưa nào là miễn phí trong kỹ thuật phần mềm. Khi bạn tăng số lượng đơn vị xử lý từ vài trăm lên hàng triệu, những giả định ngầm 30 năm qua của hệ sinh thái Java sẽ lần lượt bị phá vỡ.

Khi Java 21 chính thức phát hành với Virtual Threads (Project Loom), cả cộng đồng Java toàn cầu như vỡ òa trong hưng phấn. Với Spring Boot 3.2+, việc kích hoạt Virtual Threads dường như chỉ là một trò đùa trẻ con:

spring:
  threads:
    virtual:
      enabled: true

Chỉ với một dòng cấu hình duy nhất, ứng dụng web Tomcat truyền thống của bạn bỗng nhiên có thể phục vụ hàng chục ngàn kết nối đồng thời mà không cần phải viết code reactive phức tạp với WebFlux.

Thế nhưng, khi đưa tính năng này lên các môi trường chịu tải thực tế (High Concurrency Production), không ít đội ngũ kỹ thuật đã phải nếm trái đắng:

  • Ứng dụng bất ngờ bị đơ cứng (freeze) toàn diện dù CPU chỉ chạy ở mức 15%.
  • Bộ nhớ RAM phình to đột biến gây lỗi java.lang.OutOfMemoryError.
  • Cơ sở dữ liệu PostgreSQL/MySQL bị "bão hòa kết nối" (Connection Starvation) và liên tục bắn lỗi timeout.

Tại sao một công nghệ được ca tụng là cuộc cách mạng lại có thể trở thành "quả bom nổ chậm"? Bài viết này sẽ mổ xẻ 4 cạm bẫy chết người của Virtual Threads trong Production và cách khắc phục chuẩn mực từ các bài học thực chiến của cộng đồng vnJUG.


# ôn lại cơ chế m:n scheduling: virtual thread vs carrier thread

Để hiểu được lỗi, trước tiên ta phải hiểu cách Virtual Thread vận hành bên dưới nắp capô:

  • Platform Thread (OS Thread): Nặng (~1MB stack), tốn kém, số lượng bị giới hạn bởi hệ điều hành (thường chỉ vài trăm thread).
  • Virtual Thread: Cực nhẹ (~vài KB), nằm trên Heap, do JVM quản lý.
  • Carrier Thread: Là các OS Thread nằm trong ForkJoinPool ngầm của JVM, có nhiệm vụ "cõng" (carry) Virtual Thread để thực thi code trên CPU vật lý.
  • Kỳ quan Unmount: Khi Virtual Thread thực hiện một thao tác I/O bị chặn (chờ socket, đọc file, query database), JVM sẽ tháo gỡ (unmount) Virtual Thread đó ra khỏi Carrier Thread và cất trạng thái vào Heap. Carrier Thread lập tức rảnh tay để cõng một Virtual Thread khác!

Nhưng điều gì sẽ xảy ra nếu Virtual Thread không thể unmount được?


# cạm bẫy 1: thread pinning với từ khóa synchronized

Đây là "kẻ hủy diệt thầm lặng" phổ biến nhất trên Production:

Bản chất:

Khi một Virtual Thread đang chạy bên trong một khối synchronized block (hoặc synchronized method), hoặc đang gọi một Native Method (JNI/C++):

  • Cơ chế của JVM hiện tại không thể tháo gỡ (unmount) Virtual Thread ra khỏi Carrier Thread được. Hiện tượng này gọi là Thread Pinning.
  • Nếu bên trong khối synchronized đó có một thao tác I/O chậm (ví dụ gọi REST API hoặc query Database mất 2 giây), thì Carrier Thread vật lý bên dưới cũng bị khóa cứng suốt 2 giây đó!
  • Vì số lượng Carrier Thread mặc định chỉ bằng đúng số core CPU máy chủ (ví dụ máy chủ 4 vCPU chỉ có 4 Carrier Threads): Chỉ cần 4 request cùng rơi vào synchronized I/O, toàn bộ ứng dụng của bạn sẽ bị tê liệt hoàn toàn (Complete Starvation)!

Giải pháp khắc phục:

Thay thế toàn bộ synchronized bằng ReentrantLock từ package java.util.concurrent.locks:

// ❌ SAI LẦM: Làm ghim chặt Carrier Thread khi có I/O
public synchronized String fetchExternalData() {
    return restClient.get().uri("/slow-api").retrieve().body(String.class);
}
 
// ✅ CHUẨN MỰC: ReentrantLock cho phép Virtual Thread unmount an toàn
private final ReentrantLock lock = new ReentrantLock();
 
public String fetchExternalDataSafe() {
    lock.lock();
    try {
        return restClient.get().uri("/slow-api").retrieve().body(String.class);
    } finally {
        lock.unlock();
    }
}

Tuyệt chiêu chẩn đoán: Hãy luôn bật flag JVM này trên môi trường Staging/Production để phát hiện Thread Pinning: -Djdk.tracePinnedThreads=full JVM sẽ in ra toàn bộ Stack Trace của bất kỳ dòng code nào gây hiện tượng Pinning.


# cạm bẫy 2: sai lầm "gom thread vào pool" (pooling anti-pattern)

Trong suốt 25 năm qua, câu thần chú của mọi Java Developer là: "Thread rất đắt, hãy luôn dùng ThreadPoolExecutor để tái sử dụng!".

Khi chuyển sang Virtual Threads, nhiều người vẫn giữ thói quen đó:

// ❌ ANTI-PATTERN CHÍ MẠNG: Đừng bao giờ tạo Pool cho Virtual Threads!
ExecutorService pool = Executors.newFixedThreadPool(100, Thread.ofVirtual().factory());

Bản chất:

  • Bạn tạo pool cho Platform Thread vì việc tạo và hủy OS Thread rất tốn kém.
  • Nhưng Virtual Thread thì rẻ như một đối tượng new Object() trong RAM.
  • Gom Virtual Thread vào một pool có kích thước cố định chẳng khác nào bạn tự trói tay mình, tước bỏ hoàn toàn khả năng mở rộng không giới hạn của Project Loom!

Chuẩn mực mới:

Hãy tạo mới một Virtual Thread cho từng tác vụ riêng biệt và vứt bỏ nó ngay khi hoàn thành:

// ✅ CHUẨN MỰC: Tạo Virtual Thread theo nhu cầu (Per-task Executor)
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
    executor.submit(() -> handleRequest(request));
}

# cạm bẫy 3: thảm họa memory bloat với threadlocal

Trong các framework như Spring Security hay logging MDC, chúng ta dùng ThreadLocal để lưu thông tin User Context hoặc Transaction ID.

Hãy làm một phép toán vật lý đơn giản:

  • Thời kỳ Platform Thread: Bạn có 200 threads trong pool. Mỗi thread giữ một đối tượng cache/buffer nặng 2MB trong ThreadLocal. Tổng RAM tiêu tốn: 200 \times 2\text{MB} = 400\text{MB} (Hoàn toàn bình thường).
  • Thời kỳ Virtual Thread: Bạn tự tin mở 100.000 Virtual Threads đồng thời. Mỗi thread cũng nhân bản đối tượng 2MB đó. Tổng RAM tiêu tốn: 100.000 \times 2\text{MB} = \mathbf{200\text{GB}} RAM! \to Máy chủ crash ngay lập tức vì OutOfMemoryError.

Giải pháp hiện đại trong Java 21+:

Sử dụng ScopedValue (tính năng Preview trong Java 21 và hoàn thiện trong các bản mới hơn). ScopedValue cho phép chia sẻ dữ liệu bất biến (immutable) giữa các luồng cha và con mà không cần nhân bản bộ nhớ:

// Chia sẻ context an toàn, zero-copy bộ nhớ giữa hàng trăm ngàn Virtual Threads
public final static ScopedValue<SecurityContext> CURRENT_USER = ScopedValue.newInstance();
 
ScopedValue.where(CURRENT_USER, loggedInUser).run(() -> {
    // Toàn bộ logic bên trong đều truy cập được CURRENT_USER.get()
    processBusinessLogic();
});

# cạm bẫy 4: thảm họa "đè bẹp" database connection pool

Khi bạn chuyển Tomcat sang Virtual Threads:

  1. Máy chủ web của bạn bây giờ có thể tiếp nhận 50.000 request đồng thời mà không bị nghẽn ở tầng HTTP.
  2. Cả 50.000 request này cùng lao vào gọi Database.
  3. Nhưng Connection Pool (HikariCP) của bạn chỉ được cấu hình tối đa 30 connections (vì bản thân database PostgreSQL/MySQL vật lý không thể chịu nổi hàng chục ngàn transaction song song).
  4. Kết quả: 49.970 threads còn lại cùng đứng xếp hàng tranh chấp 30 connections đó \to Gây ra hiện tượng HikariCP Connection Timeout, cascade failure làm sập toàn bộ hệ thống!

Giải pháp: Dùng Semaphore để Backpressure

Virtual Threads cho phép mở rộng ở tầng tính toán (Compute), nhưng với các tài nguyên hữu hạn (External DB, giới hạn kết nối socket bên thứ 3), bạn bắt buộc phải dùng Semaphore để kiểm soát số lượng luồng được phép truy cập đồng thời:

@Service
public class PaymentGatewayService {
 
    // Giới hạn tối đa 50 cuộc gọi đồng thời tới cổng thanh toán đối tác
    private final Semaphore semaphore = new Semaphore(50);
 
    public PaymentResult callPartnerGateway(PaymentRequest request) throws InterruptedException {
        semaphore.acquire(); // Virtual Thread sẽ unmount an toàn khi phải chờ semaphore!
        try {
            return executeHttpCall(request);
        } finally {
            semaphore.release();
        }
    }
}

# tổng kết

Virtual Threads là một bước nhảy vọt phi thường của ngôn ngữ Java, nhưng nó không phải là chiếc đũa thần xóa bỏ mọi giới hạn vật lý của máy tính:

  1. Tránh Thread Pinning: Kiểm tra và loại bỏ synchronized khi bên trong có I/O; bật flag -Djdk.tracePinnedThreads=full.
  2. Bỏ tư duy Thread Pool: Dùng newVirtualThreadPerTaskExecutor(), không tạo pool cho Virtual Thread.
  3. Cẩn trọng với ThreadLocal: Giảm dung lượng object lưu trong ThreadLocal hoặc chuyển sang dùng ScopedValue.
  4. Bảo vệ tài nguyên hữu hạn: Dùng Semaphore để bọc các điểm gọi Database hoặc API bên ngoài, tránh làm sập hạ tầng phía sau.

Nắm vững những nguyên tắc cốt lõi này, bạn sẽ tự tin khai mở 100% sức mạnh của Java 21 trên môi trường Production mà không bao giờ phải thức trắng đêm vì những sự cố bí ẩn!


Chỉ là những ghi chép cá nhân với hy vọng mang lại chút giá trị. Nếu thấy hữu ích, đừng ngại chia sẻ cho bạn bè & đồng nghiệp nhé!

Happy coding 😎 👍🏻 🚀 🔥.