Một dịch vụ xử lý đơn hàng điển hình viết theo kiểu "một request một thread": handler nhận request, gọi database, gọi cổng thanh toán, rồi trả kết quả. Code đơn giản, dễ đọc, dễ debug bằng stack trace. Nhược điểm: thread của hệ điều hành rất đắt, nên ta đặt chúng vào một thread pool cỡ vài trăm và chấp nhận rằng khi cả trăm thread đều chờ I/O thì request thứ 101 phải xếp hàng. Java 21 (LTS, 09/2023) chính thức đưa virtual threads vào nền tảng qua JEP 444, cho phép giữ nguyên kiểu code đồng bộ nhưng chạy hàng chục nghìn tác vụ chờ I/O cùng lúc.
Bài này giải thích virtual threads hoạt động ra sao, đo thật trên máy khi nào chúng thắng thread pool, khi nào không, và những bẫy (pinning, ThreadLocal, giới hạn tài nguyên) cần biết trước khi chuyển production.
Bài tiếp nối Concurrency patterns: Producer-Consumer, thread pool và CompletableFuture, nơi ta dùng thread pool và CompletableFuture để chạy bất đồng bộ. Virtual threads đưa ra một câu trả lời khác cho cùng bài toán: thay vì lập trình bất đồng bộ để tiết kiệm thread, ta làm cho thread trở nên rẻ.
Vấn đề: thread của hệ điều hành là tài nguyên khan hiếm
Mỗi java.lang.Thread truyền thống (platform thread) bọc một thread của hệ điều hành trong suốt vòng đời. Hệ quả:
- Số lượng bị giới hạn: mỗi thread giữ một stack (mặc định 1–2 MB địa chỉ ảo) và bị giới hạn bởi quota của OS. Vài nghìn thread là đã chạm trần trên nhiều môi trường.
- Pool phải chọn cỡ đúng: nhỏ thì throughput thấp, lớn thì tốn RAM và dồn tải xuống database.
Với tác vụ chủ yếu chờ (HTTP, SQL, message), thread của OS phần lớn thời gian đứng yên mà vẫn chiếm chỗ. Lập trình bất đồng bộ giải quyết bằng cách không giữ thread khi chờ, nhưng trả giá bằng code khó đọc và stack trace đứt đoạn. Mục tiêu của JEP 444 được phát biểu rõ: cho phép ứng dụng viết theo kiểu thread-per-request mở rộng với mức tận dụng phần cứng gần tối ưu, đồng thời để code Thread hiện có chuyển sang với thay đổi tối thiểu.
Virtual threads là gì và chạy thế nào
Theo JEP 444, một virtual thread là một thể hiện của java.lang.Thread không gắn với một thread OS cụ thể. JDK tự lập lịch chúng theo mô hình M:N lên một nhóm nhỏ platform thread gọi là carrier (mặc định là một ForkJoinPool với số carrier bằng số core). Khi virtual thread chạy, nó được mount lên một carrier. Khi gặp thao tác chặn mà JDK đã hỗ trợ (socket, Thread.sleep, BlockingQueue, khoá của java.util.concurrent...), nó unmount: stack được chép sang heap, carrier rảnh ra chạy virtual thread khác. Khi I/O xong, nó mount lại, có thể lên carrier khác.
Điều cần nhớ nhất, được cả JEP lẫn tài liệu Oracle nhấn mạnh: virtual threads mang lại quy mô (throughput), không phải tốc độ (latency). Một tác vụ đơn lẻ không chạy nhanh hơn trên virtual thread. Lợi ích chỉ xuất hiện khi có rất nhiều tác vụ đồng thời và phần lớn thời gian của chúng là chờ. Tài liệu Oracle nêu ngưỡng thực dụng: nếu ứng dụng không bao giờ có chừng 10.000 virtual threads đồng thời thì khó hưởng lợi đáng kể.
Tạo virtual thread
Có ba cách tạo, ta chạy thử cả hai loại thread để thấy khác biệt (code/s6-31/Demo0Basics.java):
Thread v = Thread.ofVirtual().name("worker-", 0).start(() ->
System.out.println("trong thread: " + Thread.currentThread()));
v.join();
System.out.println("isVirtual=" + v.isVirtual() + ", isDaemon=" + v.isDaemon() + ", priority=" + v.getPriority());
Thread p = Thread.ofPlatform().name("plat-", 0).start(() ->
System.out.println("trong thread: " + Thread.currentThread()));
p.join();
// Thread.sleep trong virtual thread: carrier được giải phóng, thread vẫn "ngủ" đúng 100 ms
try (var ex = Executors.newVirtualThreadPerTaskExecutor()) {
var f = ex.submit(() -> {
long t0 = System.nanoTime();
Thread.sleep(100);
return (System.nanoTime() - t0) / 1_000_000;
});
System.out.println("ngủ thực tế: " + f.get() + " ms");
}Kết quả chạy thật bằng java Demo0Basics.java trên JDK 21 (Apple M4, 16 GB). Đoạn trên là toàn bộ phần thân của main; số hiệu luồng (#25, #28) và thời gian ngủ có thể lệch giữa các lần chạy:
trong thread: VirtualThread[#25,worker-0]/runnable@ForkJoinPool-1-worker-1
isVirtual=true, isDaemon=true, priority=5
trong thread: Thread[#28,plat-0,5,main]
ngủ thực tế: 101 msDòng đầu cho thấy virtual thread #25 đang chạy (runnable) trên carrier ForkJoinPool-1-worker-1. Virtual threads luôn là daemon và luôn có priority 5, không đổi được. Với virtual threads, hai cách còn lại là Thread.startVirtualThread(Runnable) và Executors.newVirtualThreadPerTaskExecutor(). Cách cuối dùng nhiều nhất: mỗi task một virtual thread mới, và close() trong try-with-resources chờ mọi task kết thúc.
Đo thật: 10.000 tác vụ chờ I/O
Ta mô phỏng 10.000 request, mỗi request chờ I/O đúng 1 giây (Thread.sleep thay cho gọi database hoặc HTTP). Chương trình Demo1Throughput.java chạy cùng khối lượng trên ba executor:
run("Fixed thread pool, 200 thread", Executors.newFixedThreadPool(200), tasks, 1000);
run("Fixed thread pool, 1.000 thread", Executors.newFixedThreadPool(1000), tasks, 1000);
run("Virtual thread-per-task", Executors.newVirtualThreadPerTaskExecutor(), tasks, 1000);CPU = 10
Fixed thread pool, 200 thread 10,000 task xong trong 50,391 ms
Fixed thread pool, 1.000 thread 10,000 task xong trong 10,118 ms
Virtual thread-per-task 10,000 task xong trong 1,041 msDòng đầu mỗi demo in số hiệu bản vá JDK trên máy chạy; bài lược dòng đó, bạn xem đủ trong các file run*.log ở code/s6-31. Kết quả khớp với tính toán: pool 200 thread xử lý 200 tác vụ mỗi giây nên cần 10.000/200 = 50 giây; pool 1.000 thread cần 10 giây; virtual threads không bị giới hạn bởi số thread nên tổng thời gian xấp xỉ thời gian chờ của một tác vụ (1 giây). Đây không phải "nhanh hơn 50 lần" tuyệt đối: trên cùng phần cứng, throughput chỉ còn bị giới hạn bởi tài nguyên thật (CPU, kết nối DB), không còn bởi cỡ pool.
Để thử gần thực tế hơn, Demo6HttpServer.java dựng máy chủ com.sun.net.httpserver.HttpServer có sẵn trong JDK, handler "truy vấn DB" mất 200 ms, rồi bắn 2.000 request vào, một lần với executor 50 thread và một lần với virtual thread:
Server: pool 50 thread 2,000/2,000 request OK, 8,378 ms, ~238 req/s
Server: virtual thread 2,000/2,000 request OK, 373 ms, ~5,361 req/sPool 50 thread cho trần lý thuyết 50/0,2 s = 250 req/s, số đo 238 req/s. Virtual threads vượt trần đó vì mỗi request có thread riêng. Bài đo chỉ có thời gian chờ giả lập; database thật vẫn chỉ chịu được một số truy vấn đồng thời, xem phần giới hạn bên dưới.
Quy mô: virtual threads cùng chờ được bao nhiêu
Tiếp theo ta thử giới hạn của virtual threads. Demo5Scale.java khởi chạy 100.000 rồi 1.000.000 virtual thread, mỗi thread ngủ 5 giây và ghi lại số thread sống đồng thời:
100,000 virtual thread: peak đồng thời = 100,000, 5,415 ms, heap đang dùng ~ 191 MB
1,000,000 virtual thread: peak đồng thời = 1,000,000, 9,855 ms, heap đang dùng ~ 1123 MBMột triệu virtual threads cùng tồn tại trong khoảng 10 giây với heap khoảng 1,1 GB (số này gồm cả đối tượng tác vụ, không chỉ stack của thread). Với platform thread, cùng ý tưởng thất bại ngay: PlatformLimit.java thử mở 10.000 thread bằng newCachedThreadPool và máy dừng ở khoảng thread thứ 4.071 (giới hạn tiến trình của macOS trong cấu hình máy này):
[0.540s][warning][os,thread] Failed to start the native thread for java.lang.Thread "pool-1-thread-4071"
Exception in thread "main" java.lang.OutOfMemoryError: unable to create native thread: possibly out of memory or process/resource limits reachedCon số 4.071 phụ thuộc hệ điều hành và ulimit; điểm cần lấy là platform thread chạm trần do OS, còn virtual threads chỉ bị giới hạn bởi heap.
Khi nào virtual threads không giúp gì: tác vụ CPU
Để kiểm tra khẳng định "scale chứ không phải speed", Demo4Cpu.java chạy 64 tác vụ đếm số nguyên tố dưới 400.000 trên pool cố định bằng số core (10) và trên virtual thread. Lượt đầu để khởi động JIT, lượt sau là số đáng tin hơn:
-- lượt 0
Fixed pool = 10 thread 187 ms
Virtual thread-per-task 167 ms
-- lượt 1
Fixed pool = 10 thread 164 ms
Virtual thread-per-task 180 msHai bên ngang nhau, chênh lệch nằm trong nhiễu đo. Lý do: scheduler của virtual thread cũng chỉ dùng 10 carrier, nên tác vụ thuần CPU không thể song song hơn số core. Virtual thread cũng không bị ngắt theo lát thời gian, nên vòng lặp CPU dài không nhường carrier. Với tác vụ tính toán nặng, ta vẫn dùng pool cỡ bằng số core hoặc parallel stream; đúng như phần non-goals của JEP 444 nói, Stream API vẫn là lựa chọn cho xử lý dữ liệu song song.
Pinning: bẫy lớn nhất của virtual threads trên Java 21
Virtual threads chỉ nhường carrier khi unmount được. Theo JEP 444 và tài liệu Oracle, trong Java 21 có hai trường hợp virtual threads bị pinned (ghim), tức không thể unmount khi chặn và giữ luôn carrier:
- Đang chạy trong khối hoặc phương thức
synchronized. - Đang chạy phương thức
nativehoặc foreign function.
JEP lưu ý pinning không làm ứng dụng sai, chỉ làm giảm khả năng mở rộng, và scheduler không bù bằng cách tăng số carrier. Demo2Pinning.java ép scheduler chỉ có một carrier (-Djdk.virtualThreadScheduler.parallelism=1 -Djdk.virtualThreadScheduler.maxPoolSize=1), rồi chạy 8 tác vụ, mỗi tác vụ ngủ 300 ms trong ba bối cảnh. Mỗi tác vụ có khoá riêng nên không tranh chấp khoá:
run("sleep ngoài khoá", Demo2Pinning::sleep);
run("sleep trong synchronized", () -> { Object m = new Object(); synchronized (m) { sleep(); } });
run("sleep trong ReentrantLock", () -> {
var l = new ReentrantLock(); l.lock();
try { sleep(); } finally { l.unlock(); }
});Kết quả trên JDK 21 (chạy với -Djdk.virtualThreadScheduler.parallelism=1 -Djdk.virtualThreadScheduler.maxPoolSize=1, một carrier duy nhất):
parallelism=1, maxPoolSize=1
sleep ngoài khoá 8 task x 300 ms -> 312 ms
sleep trong synchronized 8 task x 300 ms -> 2,443 ms
sleep trong ReentrantLock 8 task x 300 ms -> 300 msCon số 2.443 ms gần bằng 8 x 300 ms: tám tác vụ chạy tuần tự vì mỗi tác vụ ghim carrier duy nhất trong lúc ngủ. Hai dòng còn lại đều khoảng 300 ms vì virtual threads nhường carrier. Thêm cờ -Djdk.tracePinnedThreads=short thì JVM in ra đúng nơi bị ghim:
VirtualThread[#35]/runnable@ForkJoinPool-1-worker-1 reason:MONITOR
Demo2Pinning.lambda$main$0(Demo2Pinning.java:26) <== monitors:1Trong thực tế có nhiều carrier hơn, nên pinning chỉ nghiêm trọng khi nhiều thread cùng bị ghim lâu, ví dụ synchronized bao quanh một lời gọi JDBC hay HTTP. Khuyến nghị chính thức: thao tác chặn dài và thường xuyên trong khối đồng bộ thì đổi sang ReentrantLock; synchronized bảo vệ đoạn ngắn, không I/O thì vẫn ổn.
JDK 24 đã sửa phần lớn pinning do synchronized
JEP 491 (JDK 24, 03/2025) cho virtual thread giữ và nhả monitor độc lập với carrier, nên chặn trong synchronized hoặc Object.wait() không còn ghim carrier. JEP cũng nói jdk.tracePinnedThreads không còn cần thiết và đặt cờ này sẽ không có tác dụng. Ta chạy cùng chương trình, cùng hai cờ trên, trên JDK 25 (LTS, 09/2025) để kiểm chứng:
parallelism=1, maxPoolSize=1
sleep ngoài khoá 8 task x 300 ms -> 307 ms
sleep trong synchronized 8 task x 300 ms -> 310 ms
sleep trong ReentrantLock 8 task x 300 ms -> 307 ms
Với virtual threads, cả ba đều khoảng 300 ms, và cờ trace không in gì. Vì vậy cách chọn thực tế là: nếu bạn ở Java 21 thì rà synchronized quanh I/O; nếu có thể lên JDK 25 LTS thì vấn đề này giảm mạnh. Pinning vẫn còn khi virtual thread chặn bên trong native frame (ví dụ code native gọi ngược vào Java) hoặc trong class initializer, và sự kiện JFR jdk.VirtualThreadPinned được giữ lại để phát hiện các trường hợp đó.
Không pool virtual thread, và giới hạn đồng thời bằng Semaphore
Quy tắc dễ sai nhất khi chuyển đổi: đừng pool virtual thread. JEP 444 viết thẳng rằng virtual thread rẻ và dồi dào nên không bao giờ nên được pool. Số virtual threads bằng số tác vụ đồng thời, mỗi tác vụ một thread, và thread chết cùng tác vụ.
Nhưng pool còn đóng vai trò thứ hai, ít ai để ý: nó giới hạn đồng thời. Nếu pool 200 thread đang bảo vệ một database chỉ chịu được 20 truy vấn song song, bỏ pool đi sẽ đẩy 10.000 truy vấn vào database cùng lúc. Cách đúng theo tài liệu Oracle là dùng Semaphore cho đúng tài nguyên cần bảo vệ. Demo3Semaphore.java chạy 2.000 tác vụ, mỗi tác vụ gọi "dịch vụ ngoài" 50 ms, nhưng chỉ cho 20 tác vụ vào cùng lúc:
Semaphore limit = new Semaphore(20);
try (var ex = Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 0; i < 2000; i++) {
ex.submit(() -> {
limit.acquireUninterruptibly();
try { /* gọi dịch vụ ngoài 50 ms */ } finally { limit.release(); }
return null;
});
}
}2.000 task, tối đa đồng thời quan sát được = 20, thời gian = 5,666 ms (lý thuyết = 2000/20*50 = 5.000 ms)Số đồng thời tối đa đúng bằng 20 và thời gian sát lý thuyết. Hàng đợi các virtual threads bị chặn ở acquire tương đương hàng đợi tác vụ của pool, chỉ khác là thread vẫn rẻ. Connection pool như HikariCP vốn đã là một semaphore, đừng chồng thêm lớp nữa. Kịch bản hết kết nối database khi tăng song song đã kể trong MySQL hết kết nối: pod 502 trên Kubernetes nhưng gốc nằm ở database; virtual threads làm kịch bản này dễ xảy ra hơn nếu ta bỏ giới hạn mà không để ý.
ThreadLocal với virtual threads: dùng được nhưng đổi cách nghĩ
Virtual threads hỗ trợ ThreadLocal và InheritableThreadLocal, nhưng JEP 444 khuyên chỉ dùng sau khi cân nhắc kỹ vì số thread có thể rất lớn. Cái bẫy kinh điển là dùng ThreadLocal làm bộ nhớ đệm cho đối tượng đắt tiền, không an toàn luồng (như SimpleDateFormat, bộ đệm byte lớn). Với pool, mỗi thread giữ một bản và tái dùng hàng nghìn lần. Với virtual threads vốn không được pool, mỗi tác vụ tạo bản riêng, tốn thời gian khởi tạo và bộ nhớ. Tài liệu Oracle gợi ý thay bằng đối tượng bất biến, chia sẻ được như DateTimeFormatter.
Đối với ngữ cảnh theo request (user, trace id, transaction) thì ThreadLocal vẫn chạy đúng, và hướng đi dài hạn là scoped values, sẽ nói ở bài sau cùng structured concurrency.
Cách Spring Boot dùng virtual threads
Để dùng virtual threads trong Spring, Spring Boot 3.2 (11/2023) thêm thuộc tính spring.threads.virtual.enabled=true. Theo tài liệu tham chiếu, khi chạy Java 21 trở lên và bật thuộc tính này, task executor tự cấu hình trở thành SimpleAsyncTaskExecutor dùng virtual thread, task scheduler trở thành SimpleAsyncTaskScheduler, và các thành phần dùng executor đó (@EnableAsync, xử lý async của Spring MVC, WebSocket, JPA bootstrap...) cũng chuyển theo. Theo release notes của Spring Boot 3.2, Tomcat và Jetty cũng xử lý request bằng virtual threads khi bật cờ, và các thuộc tính cấu hình thread pool không còn tác dụng.
Một tác dụng phụ cần nhớ: virtual thread là daemon, nên tài liệu khuyên đặt thêm spring.main.keep-alive=true để JVM không thoát khi chỉ còn các tác vụ @Scheduled. Với Spring MVC đồng bộ, đây là cách chuyển rẻ nhất: đổi một dòng cấu hình. Trước khi bật ở production hãy kiểm tra:
- Pinning ở thư viện: driver JDBC, client cũ, SDK bọc
synchronizedquanh I/O (trên Java 21). - Giới hạn hạ nguồn: connection pool, rate limit của dịch vụ ngoài. Bật virtual threads làm số request đồng thời tăng, nên điểm nghẽn dời xuống tầng dưới.
Về pattern, bài Circuit Breaker, Retry và Bulkhead với Resilience4j đã nói về bulkhead; với virtual threads, bulkhead bằng semaphore tự nhiên hơn bulkhead bằng thread pool.
Khi nào nên thay thread pool bằng virtual threads, khi nào không
| Tình huống | Khuyến nghị |
|---|---|
| Dịch vụ web đồng bộ, nhiều I/O chặn (DB, HTTP), tải cao | Chuyển sang virtual threads; giữ giới hạn đồng thời bằng semaphore hoặc connection pool |
| Tác vụ CPU thuần, chạy lâu | Giữ pool cỡ bằng số core hoặc parallel stream |
| Luôn dưới vài trăm tác vụ đồng thời | Lợi ích nhỏ; không cần chuyển chỉ vì xu hướng |
| Hệ thống reactive đang chạy tốt | Không cần viết lại; virtual threads nhắm vào code đồng bộ |
Một điểm đánh đổi nữa của virtual threads là khả năng quan sát. Thread dump thường (jstack) không liệt kê virtual threads; dùng jcmd <pid> Thread.dump_to_file -format=json <file> để có đủ, và theo dõi sự kiện JFR jdk.VirtualThreadPinned (mặc định bật, ngưỡng 20 ms).
Lỗi thường gặp với virtual threads
- Pool virtual threads: bọc
Executors.newFixedThreadPoolvớiThread.ofVirtual().factory()để "giữ cỡ pool" là sai mục đích; hãy dùng Semaphore. - Tin rằng code sẽ nhanh hơn: latency của một request không đổi; hãy đo bằng tải thật.
- Quên giới hạn hạ nguồn: database và API ngoài trở thành điểm nghẽn, đôi khi sập vì lượng request đồng thời tăng đột ngột.
- ThreadLocal lưu đối tượng lớn: bộ nhớ tăng theo số thread; dùng đối tượng bất biến chia sẻ.
Checklist chuyển sang virtual threads
- Xác nhận Java 21 trở lên và đo trước/sau trên tải thật (throughput, p95, số kết nối DB).
- Rà
synchronizedquanh I/O; đổi sangReentrantLockhoặc nâng JDK 24+. - Dùng
Executors.newVirtualThreadPerTaskExecutor(), không pool virtual threads. - Đặt
Semaphorehoặc dựa vào connection pool cho từng tài nguyên có giới hạn. - Bật
-Djdk.tracePinnedThreads=short(Java 21) và theo dõi JFRjdk.VirtualThreadPinnedkhi kiểm thử.
Tóm lại, virtual threads giúp code đồng bộ chờ I/O mở rộng tới hàng trăm nghìn tác vụ đồng thời mà không phải viết lại thành bất đồng bộ. Tài liệu tham khảo chính: JEP 444: Virtual Threads, Oracle: Virtual Threads (Java 21), JEP 491: Synchronize Virtual Threads without Pinning và Spring Boot: Task Execution and Scheduling.
Bài tiếp theo
Virtual threads làm việc tạo thread trở nên rẻ, nhưng chưa trả lời câu hỏi: khi một request tách thành nhiều tác vụ con chạy song song, ai đảm bảo chúng được hủy và dọn đúng khi một tác vụ lỗi? Mời bạn đọc tiếp Structured concurrency và scoped values: preview trong Java 21+.



