Welcome to MikeDaDev's Blog – notes on Java, backend & system design.

Explore
MikeDaDev's Blog
in java

cạm bẫy Virtual Threads trong production

Tung Bui Thanh (TungDaDev)12 mins read

Virtual threads pitfalls.webp

Không có bữa trưa nào miễn phí. Khi số đơn vị xử lý tăng từ vài trăm lên hàng triệu, mấy giả định ngầm của hệ sinh thái Java suốt 30 năm qua bắt đầu lộ ra từng cái một.

Java 21 ra mắt Virtual Threads (Project Loom), cả cộng đồng Java hào hứng thấy rõ. Với Spring Boot 3.2+, bật Virtual Threads dễ như đùa:

spring:
  threads:
    virtual:
      enabled: true

Chỉ một dòng cấu hình, mỗi request trong ứng dụng Tomcat truyền thống được chạy trên một virtual thread riêng, không còn bị chặn ở con số server.tomcat.threads.max (mặc định 200). Ứng dụng chờ I/O nhiều có thể phục vụ hàng nghìn request đồng thời mà không cần viết code reactive với WebFlux. Tomcat vẫn có giới hạn riêng là server.tomcat.max-connections (mặc định 8192), nên đừng nghĩ trần là vô hạn.

Thế nhưng lên production chịu tải cao thì không ít team đã gặp chuyện:

  • Ứng dụng đơ cứng (freeze) dù CPU chỉ chạy khoảng 15%.
  • RAM phình đột biến rồi dính java.lang.OutOfMemoryError.
  • PostgreSQL/MySQL bị cạn connection (Connection Starvation), timeout liên tục.

Sao một công nghệ được khen hết lời lại thành "quả bom nổ chậm" được? Bài này đi qua 4 cạm bẫy của Virtual Threads trên Production và cách xử lý. Ví dụ và hành vi trong bài lấy JDK 21 làm mốc, chỗ nào JDK mới hơn đã đổi thì mình ghi chú riêng.


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

Muốn hiểu lỗi thì phải hiểu Virtual Thread chạy thế nào bên dưới runtime đã:

  • Platform Thread (OS Thread): nặng (mặc định dành sẵn ~1MB stack trên Linux 64-bit), mỗi thread là một thread của hệ điều hành. Một JVM thường chạy ổn tới vài nghìn thread, lên chục nghìn thì bộ nhớ và chi phí context switch bắt đầu thành vấn đề.
  • Virtual Thread: rất nhẹ (vài trăm byte tới vài KB lúc mới tạo), stack nằm trên Heap và lớn lên hay co lại theo nhu cầu, do JVM quản lý.
  • Carrier Thread: là các OS Thread trong ForkJoinPool ngầm của JVM, có việc "cõng" (carry) Virtual Thread để chạy code trên CPU.
  • Unmount: khi Virtual Thread gặp thao tác bị chặn (chờ socket, query database qua JDBC driver thuần Java, sleep, chờ lock trong java.util.concurrent), JVM unmount nó khỏi Carrier Thread và cất stack vào Heap. Carrier Thread rảnh ngay để cõng Virtual Thread khác. Riêng đọc/ghi file thì hệ điều hành không có API non-blocking tương ứng, nên JVM giữ carrier lại và tạm thời thêm một carrier khác để bù.

Vậy nếu Virtual Thread không unmount được thì sao?


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

Đây là cái bẫy âm thầm và hay gặp nhất trên Production:

# bản chất kỹ thuật

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

  • Trên JDK 21 đến 23, JVM không unmount được Virtual Thread khỏi Carrier Thread trong trường hợp này. Hiện tượng đó gọi là Thread Pinning. Lý do là monitor của synchronized được JVM gắn với chính platform thread đang chạy, nên virtual thread không thể rời carrier mà vẫn giữ lock.
  • Nếu trong khối synchronized có 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 bên dưới cũng bị khóa suốt 2 giây đó.
  • Số Carrier Thread mặc định bằng số core CPU (máy 4 vCPU thì có 4 Carrier Threads), nên chỉ cần 4 request cùng rơi vào synchronized có I/O là cả ứng dụng đứng hình (Complete Starvation). Tệ hơn, nếu virtual thread đang giữ lock mà lại phải chờ một virtual thread khác chưa có carrier để chạy, ứng dụng có thể deadlock thật sự.

Tin vui: JEP 491 trong JDK 24 đã sửa tận gốc: virtual thread chờ trong synchronized (kể cả Object.wait()) giờ unmount được như bình thường. Nếu bạn chạy JDK 24 trở lên (JDK 25 là bản LTS hiện tại), cạm bẫy này coi như hết. Những trường hợp vẫn còn pin là native method/foreign function, và lúc chặn bên trong class initializer hoặc lúc đang load class.

# giải pháp khắc phục

Cách tốt nhất là nâng lên JDK 24+ (thực tế là JDK 25 LTS). Còn kẹt ở JDK 21 thì thay synchronized bọc I/O bằng ReentrantLock trong package java.util.concurrent.locks, kể cả trong thư viện bạn tự viết; thư viện bên thứ ba thì nâng lên bản đã bỏ synchronized quanh I/O (nhiều driver JDBC và HTTP client đã làm việc này sau khi Java 21 ra):

// ❌ 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();
    }
}

Một lưu ý nữa: ReentrantLock chỉ giải quyết chuyện pinning. Giữ lock trong lúc gọi API chậm 2 giây thì mọi request vẫn phải xếp hàng qua cái lock đó, chỉ là không chiếm carrier nữa. Nếu được, hãy thiết kế để không giữ lock trong lúc làm I/O.

Mẹo chẩn đoán: trên JDK 21–23 có flag JVM để phát hiện Thread Pinning: -Djdk.tracePinnedThreads=full JVM sẽ in full stack trace của đoạn code nào gây pinning. Flag này in ra ngay trên thread bị pin nên không hợp để bật lâu dài ở production, và đã bị bỏ từ JDK 24. Cách bền hơn là dùng JFR: event jdk.VirtualThreadPinned bật sẵn trong cấu hình mặc định (ghi lại lần pin kéo dài quá 20ms), xem bằng jfr print --events jdk.VirtualThreadPinned recording.jfr.


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

25 năm nay, ai code Java cũng thuộc lòng câu: "Thread đắt lắm, cứ dùng ThreadPoolExecutor để tái sử dụng".

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 kỹ thuật

  • Tạo pool cho Platform Thread là vì tạo và hủy OS Thread rất tốn.
  • Còn Virtual Thread thì rẻ cỡ một object nhỏ trên Heap: tạo mới tốn chưa tới một micro giây, không có system call nào.
  • Gom Virtual Thread vào pool cố định là tự trói tay mình: pool 100 thread nghĩa là tối đa 100 việc chạy cùng lúc, y như thread pool cũ, mất luôn lợi ích của Project Loom. Thread trong pool còn sống lâu, nên ThreadLocal của việc trước có thể rò sang việc sau.

Nếu mục đích thật sự của pool là giới hạn số việc chạy đồng thời (ví dụ không muốn gọi đối tác quá 100 request cùng lúc), thì cứ dùng thread-per-task và giới hạn bằng Semaphore như ở cạm bẫy 4.

# chuẩn mực kỹ thuật

Cứ tạo mới một Virtual Thread cho từng tác vụ, xong việc thì bỏ:

// ✅ 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: memory bloat với threadlocal

Các framework như Spring Security hay logging MDC dùng ThreadLocal để lưu User Context hoặc Transaction ID.

Tính nhẩm một chút:

  • Với Platform Thread: pool có 200 threads, mỗi thread giữ một object cache/buffer 2MB trong ThreadLocal. Tổng RAM: 200 * 2MB = 400MB, hoàn toàn bình thường.
  • Với Virtual Thread: mở 100,000 Virtual Threads đồng thời, mỗi thread cũng giữ một bản 2MB đó. Tổng RAM: 100,000 * 2MB = 200GB RAM. Máy chủ chết ngay vì OutOfMemoryError.

Vấn đề thứ hai ít người để ý: kiểu ThreadLocal dùng làm cache (giữ sẵn một SimpleDateFormat, một buffer...) chỉ có lợi khi thread được tái sử dụng. Virtual thread tạo mới cho mỗi task nên cache này không bao giờ được dùng lại, lần nào cũng tạo object mới rồi bỏ. Gặp trường hợp này thì chuyển sang object thread-safe dùng chung (vd DateTimeFormatter) hoặc một pool object riêng. Còn ThreadLocal nhỏ kiểu MDC hay SecurityContextHolder của Spring Security thì vẫn dùng được bình thường với virtual thread, chỉ cần giữ nó nhỏ.

# giải pháp với ScopedValue trong java 21+

Dùng ScopedValue: preview từ Java 21 (phải bật --enable-preview), chính thức từ Java 25 (JEP 506). ScopedValue gắn một giá trị bất biến (immutable) vào một phạm vi thực thi: mọi code chạy bên trong run() đọc được, ra khỏi phạm vi là giá trị tự hết, không cần remove() như ThreadLocal. Thread con tạo bằng StructuredTaskScope được kế thừa chính tham chiếu đó, không sao chép như InheritableThreadLocal:

// 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: đè bẹp database connection pool

Khi chuyển Tomcat sang Virtual Threads:

  1. Web server nhận được rất nhiều request đồng thời (giả sử 50,000, khi đã nâng max-connections của Tomcat hoặc chạy nhiều instance) mà không 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) chỉ cấu hình tối đa 30 connections (vì bản thân PostgreSQL/MySQL không chịu nổi hàng chục ngàn transaction song song).
  4. 49,970 threads còn lại xếp hàng tranh nhau 30 connections đó. Mỗi thread chờ tối đa connectionTimeout của HikariCP (mặc định 30 giây) rồi ném SQLTransientConnectionException, dẫn tới timeout hàng loạt và cascade failure kéo sập cả hệ thống.

Với platform thread, maxThreads = 200 của Tomcat vô tình làm luôn việc chặn tải. Bỏ giới hạn đó đi thì phải tự đặt lại giới hạn ở đúng chỗ tài nguyên khan hiếm.

TIP

Backpressure: Virtual Threads giúp tăng throughput với việc chờ I/O, chứ không làm database xử lý nhanh hơn. Dùng Semaphore để giới hạn số luồng đồng thời được đụng vào tài nguyên hữu hạn (DB connection, third-party API), tránh quá tải.

# giải pháp dùng Semaphore để backpressure

Virtual Threads giúp ứng dụng chờ I/O được nhiều việc cùng lúc (chứ không làm phần tính toán CPU nhanh hơn), nhưng với tài nguyên hữu hạn (DB bên ngoài, giới hạn socket của bên thứ 3) thì bạn cần Semaphore để kiểm soát số luồng 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();
        }
    }
}

acquire() như trên chỉ chuyển hàng đợi từ HikariCP sang Semaphore: request vẫn chờ, chỉ là chờ ở chỗ bạn kiểm soát được. Muốn hệ thống từ chối sớm thay vì để request treo, dùng semaphore.tryAcquire(200, TimeUnit.MILLISECONDS) và trả về lỗi 503/429 khi hết lượt. Với database thì bản thân HikariCP đã là một semaphore, nên việc chính là đặt connectionTimeout ngắn hơn mặc định để fail nhanh, và giới hạn tải ở tầng trên (rate limit, max-connections của Tomcat).


# tổng kết

Virtual Threads là một bước tiến lớn của Java, nhưng nó không xóa được giới hạn của phần cứng:

  1. Tránh Thread Pinning: tốt nhất là lên JDK 24+ (JDK 25 LTS). Còn ở JDK 21 thì soát lại synchronized có I/O bên trong, theo dõi event JFR jdk.VirtualThreadPinned.
  2. Bỏ tư duy Thread Pool: dùng newVirtualThreadPerTaskExecutor(), đừng tạo pool cho Virtual Thread.
  3. Cẩn thận với ThreadLocal: giảm dung lượng object lưu trong ThreadLocal, bỏ kiểu dùng ThreadLocal làm cache, hoặc chuyển sang ScopedValue (chính thức từ Java 25).
  4. Bảo vệ tài nguyên hữu hạn: dùng Semaphore bọc các chỗ gọi Database hoặc API bên ngoài, đừng để hạ tầng phía sau bị sập.

Muốn đọc kỹ hơn thì JEP 444 (Virtual Threads) có hẳn phần hướng dẫn dùng đúng cách, và JEP 491 giải thích vì sao synchronized từng pin và đã được sửa ra sao.


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 😎 👍🏻 🚀 🔥.