Tất cả bài viết
JavaVirtual ThreadsConcurrencySpring BootJava 25

Virtual Threads chuyên sâu: cách hoạt động, pinning và những cái bẫy khi lên production

30/09/20260 lượt xem12 phút đọc

Giới hạn của thread-per-request

Phần lớn ứng dụng Java backend — Spring MVC, Servlet, JDBC — theo mô hình thread-per-request: mỗi request được một thread phục vụ từ đầu đến cuối. Mô hình này dễ viết, dễ debug, stack trace rõ ràng. Vấn đề là thread truyền thống (platform thread) chính là thread của hệ điều hành: mỗi thread giữ sẵn một vùng stack (mặc định 1MB trên Linux 64-bit), việc tạo và chuyển ngữ cảnh đều phải đi qua kernel. Vì vậy số thread bị giới hạn ở mức vài trăm đến vài nghìn; Tomcat mặc định dùng tối đa 200.

Với ứng dụng I/O-bound, đó là nút thắt. Theo định luật Little: số request đồng thời = throughput × độ trễ. Nếu mỗi request mất 100ms chủ yếu để chờ database hay API khác, 200 thread chỉ phục vụ được tối đa khoảng 2.000 request/giây — trong khi CPU gần như rảnh rỗi, vì các thread đều đang ngồi chờ.

Lập trình reactive (WebFlux, RxJava) giải quyết bằng cách không bao giờ block thread, nhưng đổi lại là code dạng callback/chuỗi operator, stack trace khó đọc, và mọi thư viện phải hỗ trợ non-blocking. Virtual threads (JEP 444, chính thức từ Java 21) đưa ra lựa chọn thứ ba: giữ nguyên phong cách code blocking quen thuộc, nhưng khả năng mở rộng tương đương async.

Virtual thread hoạt động như thế nào

Virtual thread vẫn là một java.lang.Thread, nhưng do JDK quản lý thay vì hệ điều hành:

  • Carrier thread: JDK dùng một ForkJoinPool nhỏ gồm các platform thread — mặc định số lượng bằng số core CPU — để "chở" virtual thread chạy.
  • Mount/unmount: khi virtual thread gọi một thao tác blocking trong JDK (đọc socket, Thread.sleep, BlockingQueue.take, ReentrantLock.lock...), nó tự nhả carrier. Các stack frame được chuyển lên heap dưới dạng một continuation, và carrier rảnh tay chạy virtual thread khác. Khi I/O hoàn tất, virtual thread được đặt lại lên một carrier (có thể là carrier khác) và chạy tiếp.
  • Stack trên heap: stack của virtual thread nằm trên heap, co giãn theo nhu cầu, khởi đầu chỉ vài trăm byte. Tạo một triệu virtual thread là chuyện bình thường.

In Thread.currentThread() từ bên trong một virtual thread, bạn thấy rõ cặp virtual thread và carrier:

Thread vt = Thread.ofVirtual().name("worker-1").start(() -> {
    System.out.println(Thread.currentThread());
});
VirtualThread[#26,worker-1]/runnable@ForkJoinPool-1-worker-1

Phần trước dấu @ là virtual thread, phần sau là carrier đang chở nó ở thời điểm đó.

100.000 thread trong 1,4 giây

Instant start = Instant.now();
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
    IntStream.range(0, 100_000).forEach(i ->
        executor.submit(() -> {
            Thread.sleep(Duration.ofSeconds(1));   // giả lập chờ I/O 1 giây
            return i;
        })
    );
} // close() chờ mọi task hoàn thành
System.out.println("100.000 task xong sau " + Duration.between(start, Instant.now()).toMillis() + "ms");
100.000 task xong sau 1369ms

100.000 task, mỗi task chờ 1 giây, hoàn thành trong khoảng 1,4 giây trên một container 8 core. Với một thread pool 200 platform thread, cùng khối lượng này cần 100.000 / 200 × 1 giây = 500 giây. Lưu ý ExecutorService implement AutoCloseable từ Java 19, nên khối try-with-resources tự chờ mọi task xong.

Virtual threads không làm code chạy nhanh hơn

Đây là hiểu lầm phổ biến nhất. Một request mất 100ms với platform thread vẫn mất 100ms với virtual thread. Thứ virtual thread cải thiện là throughput — số request xử lý được đồng thời — và chỉ khi thời gian chủ yếu dành cho việc chờ.

  • Phù hợp: HTTP server, service gọi nhiều API/database, xử lý message từ queue — những việc I/O-bound với độ đồng thời cao.
  • Không có lợi: tính toán nặng CPU (mã hóa video, xử lý ảnh, thuật toán). Số carrier bằng số core, nên 10.000 virtual thread tính toán cũng chỉ chạy song song được đúng bằng số core. Với loại việc này, dùng ForkJoinPool hoặc parallel stream.

Pinning: khi virtual thread "dính" vào carrier

Cơ chế unmount chỉ hoạt động khi JDK có thể chuyển stack của virtual thread lên heap. Có những tình huống nó không làm được; khi đó virtual thread bị pin vào carrier: nếu nó block, carrier cũng bị block theo. Với ít carrier, vài virtual thread bị pin là đủ làm cả ứng dụng đứng hình.

Trước Java 24, nguyên nhân pinning phổ biến nhất là block bên trong synchronized. Đoạn code dưới đây tạo 5 virtual thread; mỗi thread lấy một lock riêng (không hề tranh chấp) rồi sleep 200ms. Chạy với 1 carrier để thấy rõ hiệu ứng:

List<Thread> threads = new ArrayList<>();
for (int i = 0; i < 5; i++) {
    Object lock = new Object();             // mỗi thread một lock riêng
    threads.add(Thread.ofVirtual().start(() -> {
        synchronized (lock) {
            try { Thread.sleep(200); } catch (InterruptedException e) {}
        }
    }));
}
for (Thread t : threads) t.join();
$ java -Djdk.virtualThreadScheduler.parallelism=1 Pinning.java
21: 1021ms
25: 207ms

Trên Java 21, cả 5 thread chạy tuần tự (5 × 200ms): thread đầu tiên sleep trong synchronized, bị pin, chiếm luôn carrier duy nhất. Trên Java 25, chúng chạy song song như mong đợi. Thay đổi đến từ JEP 491 (Java 24): virtual thread giờ có thể giữ và nhả monitor độc lập với carrier, kể cả khi gọi Object.wait().

Pinning vẫn còn khi virtual thread đi qua native code (JNI, Foreign Function API) và trong một số trường hợp hiếm khác, nhưng nguồn gây pinning lớn nhất trong thực tế đã được xử lý. Đây là lý do mạnh nhất để chọn Java 25 LTS thay vì Java 21 khi đưa virtual threads lên production. Nếu buộc phải ở lại Java 21, hãy thay synchronized bao quanh các thao tác I/O bằng ReentrantLock.

Phát hiện pinning

JFR có sẵn event jdk.VirtualThreadPinned, được bật trong cấu hình mặc định với ngưỡng 20ms (chỉ ghi lại những lần bị pin lâu hơn 20ms):

java -XX:StartFlightRecording=filename=app.jfr,settings=default -jar app.jar
# ... chạy tải một lúc, rồi:
jfr print --events jdk.VirtualThreadPinned app.jfr

Mỗi event kèm stack trace chỉ ra chính xác dòng code gây pin. Trên Java 21, bạn cũng có thể chạy với -Djdk.tracePinnedThreads=full để in stack trace ra console mỗi khi thread bị pin lúc block. Với ví dụ ở trên, nó chỉ thẳng vào khối synchronized:

VirtualThread[#25]/runnable@ForkJoinPool-1-worker-1 reason:MONITOR
    Pinning.lambda$main$0(Pinning.java:12) <== monitors:1

Những cái bẫy khi lên production

1. Đừng pool virtual thread

Thread pool tồn tại vì platform thread đắt. Virtual thread thì rẻ, nên cách dùng đúng là tạo mới cho mỗi task (newVirtualThreadPerTaskExecutor) và bỏ đi khi xong. Tạo một newFixedThreadPool(100) với factory sinh virtual thread là giữ nguyên nhược điểm của cách cũ mà không nhận được lợi ích của cách mới.

2. Giới hạn tài nguyên bằng Semaphore

Trước đây, kích thước thread pool vô tình đóng vai trò giới hạn: tối đa 200 request đồng thời thì tối đa 200 truy vấn database đồng thời. Với virtual threads, giới hạn đó biến mất. 10.000 request đồng thời sẽ cùng lao vào HikariCP — vốn mặc định chỉ có 10 connection — rồi xếp hàng chờ, và sau connectionTimeout mặc định 30 giây thì ném exception. Tương tự với API bên thứ ba có rate limit.

Giới hạn tài nguyên một cách tường minh bằng Semaphore:

private final Semaphore permits = new Semaphore(20);

public Quote fetchQuote(String symbol) throws InterruptedException {
    permits.acquire();
    try {
        return pricingClient.getQuote(symbol); // tối đa 20 request đồng thời tới pricing API
    } finally {
        permits.release();
    }
}

Khi chờ acquire(), virtual thread unmount như mọi thao tác blocking khác, nên việc chờ gần như không tốn gì.

3. ThreadLocal nhân theo số thread

Virtual thread hỗ trợ ThreadLocal đầy đủ, nhưng mỗi virtual thread có bản sao riêng. Mẹo cũ "cache object nặng trong ThreadLocal để tái sử dụng giữa các request" không còn tác dụng — vì thread không còn được tái sử dụng — và có thể tốn rất nhiều bộ nhớ khi có hàng trăm nghìn thread. Để truyền ngữ cảnh (request ID, user hiện tại), hãy dùng Scoped Values ở phần dưới.

4. Thread dump kiểu cũ không thấy virtual thread

jstack và jcmd Thread.print chỉ liệt kê platform thread. Để xem cả virtual thread, dùng định dạng dump mới:

jcmd <pid> Thread.dump_to_file -format=json threads.json

Scoped Values: thay ThreadLocal để truyền ngữ cảnh (chính thức ở Java 25)

ScopedValue (JEP 506, chính thức từ Java 25) cho phép gắn một giá trị vào một phạm vi thực thi: giá trị có hiệu lực trong suốt lời gọi run, với mọi method được gọi sâu bên trong, rồi tự động biến mất khi phạm vi kết thúc.

static final ScopedValue<String> REQUEST_ID = ScopedValue.newInstance();

public static void main(String[] args) {
    try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
        for (String id : new String[]{"req-1", "req-2"}) {
            executor.submit(() -> ScopedValue.where(REQUEST_ID, id).run(Scoped::handleRequest));
        }
    }
    System.out.println("Ngoài scope, isBound = " + REQUEST_ID.isBound());
}

static void handleRequest() {
    log("bắt đầu xử lý");
    loadUser();
}

static void loadUser() {
    log("query user");  // không cần truyền requestId qua tham số
}

static void log(String message) {
    System.out.println("[" + REQUEST_ID.get() + "] " + message);
}
[req-2] bắt đầu xử lý
[req-2] query user
[req-1] bắt đầu xử lý
[req-1] query user
Ngoài scope, isBound = false

So với ThreadLocal:

  • Bất biến: không có set(). Muốn đổi giá trị cho một đoạn con, lồng một where(...).run(...) mới.
  • Không rò rỉ: không cần nhớ gọi remove(); giá trị không bao giờ "sống sót" sang request tiếp theo như lỗi kinh điển của ThreadLocal trong thread pool.
  • Tự kế thừa sang các subtask tạo bởi StructuredTaskScope.

Structured Concurrency (preview ở Java 25)

Khi một request cần gọi song song nhiều service, cách truyền thống là submit nhiều Future rồi get() từng cái. Vấn đề: nếu một lời gọi lỗi, các lời gọi còn lại vẫn chạy tiếp một cách vô ích — và nếu thread cha bị ngắt, các task con có thể thành "mồ côi". Structured concurrency ràng buộc vòng đời task con vào một khối code, giống như try-with-resources ràng buộc vòng đời tài nguyên.

record Dashboard(User user, int orderCount) {}

static Dashboard loadDashboard(long userId) throws InterruptedException {
    try (var scope = StructuredTaskScope.open()) {
        Subtask<User> user = scope.fork(() -> findUser(userId));        // ~300ms
        Subtask<Integer> orders = scope.fork(() -> countOrders(userId)); // ~200ms
        scope.join();   // chờ cả hai; một cái lỗi thì cái còn lại bị hủy
        return new Dashboard(user.get(), orders.get());
    }
}
Dashboard[user=User[name=Khang], orderCount=12] sau 312ms

Hai lời gọi chạy song song nên tổng thời gian bằng lời gọi chậm nhất (300ms), không phải tổng (500ms). Điều thú vị hơn là khi có lỗi: cho countOrders ném exception sau 100ms, trong khi findUser cần 2 giây:

findUserSlow bị hủy (interrupted)
Lỗi: java.lang.IllegalStateException: order-service timeout sau 111ms

Ngay khi một subtask thất bại, scope ngắt subtask còn lại, và join() ném StructuredTaskScope.FailedException chỉ sau 111ms — không lãng phí 2 giây chờ một kết quả không còn dùng được.

Lưu ý: structured concurrency vẫn là preview ở Java 25 (JEP 505), cần chạy với --enable-preview, và API đã thay đổi đáng kể qua các phiên bản (Java 21–24 dùng new StructuredTaskScope.ShutdownOnFailure(), Java 25 chuyển sang StructuredTaskScope.open()). Chỉ nên dùng thử nghiệm, chưa nên đưa vào production.

Bật virtual threads trong Spring Boot

Từ Spring Boot 3.2 (yêu cầu Java 21 trở lên), chỉ cần một dòng cấu hình:

spring:
  threads:
    virtual:
      enabled: true

Tomcat/Jetty sẽ xử lý mỗi request trên một virtual thread; @Async, @Scheduled và các listener của Kafka/RabbitMQ cũng chuyển sang virtual threads. Giới hạn server.tomcat.threads.max không còn là trần của số request đồng thời — nên hãy xem lại cấu hình connection pool và các giới hạn đã nói ở trên.

Checklist trước khi bật virtual threads

  • Dùng Java 25 LTS để hưởng JEP 491 (không còn pinning do synchronized).
  • Xác định mọi tài nguyên có giới hạn — connection pool, API có rate limit — và bảo vệ chúng bằng Semaphore hoặc cấu hình pool phù hợp.
  • Rà soát các ThreadLocal chứa object nặng; chuyển phần truyền ngữ cảnh sang ScopedValue.
  • Chạy load test so sánh trước và sau, bật JFR để theo dõi jdk.VirtualThreadPinned.
  • Giữ platform thread (hoặc ForkJoinPool) cho các tác vụ nặng CPU.

Kết luận

Virtual threads không phải phép màu tăng tốc, mà là một thay đổi về mô hình: thread không còn là tài nguyên khan hiếm cần tiết kiệm. Điều đó cho phép quay lại phong cách code blocking đơn giản mà vẫn phục vụ được lượng request đồng thời lớn. Cái giá là phải tư duy lại những giới hạn trước đây được thread pool "che giấu" — connection pool, rate limit, bộ nhớ của ThreadLocal. Với Java 25 LTS, JEP 491 và Scoped Values đã chính thức, đây là thời điểm hợp lý để đưa virtual threads vào production.

# Java
# Virtual Threads
# Concurrency
# Spring Boot
# Java 25