Virtual threads — Java 21 lightweight concurrency
Project Loom, JEP 444 Java 21. Platform thread tốn 1MB, virtual thread ~300 byte. Triệu thread trong 1 JVM. Carrier thread, mount/unmount, pinning. Khi nào dùng, khi nào KHÔNG dùng.
Java 21 (tháng 9/2023) ra mắt virtual thread (JEP 444) — thay đổi lớn nhất trong concurrency Java kể từ Java 5 (2004) thêm java.util.concurrent.
Vấn đề bối cảnh: server Java truyền thống cho mỗi request 1 thread. Request chờ HTTP downstream 500ms → thread giữ nguyên trong suốt 500ms đó, không làm gì. 1000 request đồng thời → cần 1000 thread. Mỗi thread 1MB stack → 1GB RAM chỉ cho stack. OS scheduler vất vả quản 1000 thread.
Hệ quả: server Java truyền thống scale đến vài nghìn concurrent connection là hết sức. Để scale lên 100k+ connection, phải chuyển sang reactive programming (Reactor, RxJava) — viết callback chain, code khó đọc, debugger không theo được, stack trace 50 frame framework.
Virtual thread (Project Loom) đảo ngược trade-off: thread là user-space, tạo ~300 byte. 1 triệu virtual thread trong 1 JVM khả thi. Code viết sequential, blocking style như cũ — nhưng scale như reactive. Không phải ảo — platform thread thật sự không còn là bottleneck.
Bài này giải thích cơ chế virtual thread (carrier, mount/unmount), so sánh chi tiết với platform thread, pattern sử dụng, và quan trọng nhất: khi nào KHÔNG nên dùng — virtual thread không phải "magic solution" cho mọi use case.
1. Vấn đề của platform thread
Nhắc lại từ bài 10.1:
- Tạo thread: syscall OS (
pthread_create), tốn ~100μs. - Stack: 1MB mặc định (Linux
ulimit -s). - Context switch: OS scheduler tốn 1-10μs mỗi switch.
Backend workload điển hình:
- Request cần gọi 3 downstream service, mỗi service ~200ms I/O.
- 1 request giữ 1 thread 600ms (chủ yếu chờ I/O, CPU idle).
- 1000 request/second đồng thời → cần 600 thread/request × 1000 req/s / 1000ms = 600 thread sống cùng lúc. Feasible.
- 10000 req/s → cần 6000 thread. Tốn 6GB stack. Đáng kể.
- 100000 req/s → 60000 thread. 60GB RAM. Không feasible.
Workaround cũ:
- Thread pool nhỏ + blocking: pool 200 thread, 10k request xếp hàng. Latency tăng.
- Reactive: callback chain,
CompletableFuture/Reactor/RxJava. Pool nhỏ đủ vì không thread nào "chờ" — chỉ đăng ký callback. Scale tốt nhưng code complex.
Cả 2 có trade-off. Virtual thread là cách thứ 3: giữ code sequential blocking style, nhưng bỏ constraint 1 thread = 1 OS thread.
2. Virtual thread — cơ chế cốt lõi
Khái niệm
Virtual thread = thread do JVM quản, không phải OS thread. Chạy trên platform thread (gọi là carrier). JVM mount/unmount virtual thread lên carrier linh hoạt.
Khi virtual thread block (I/O, sleep, lock), JVM unmount nó khỏi carrier — carrier free để chạy virtual thread khác. Khi I/O xong, JVM mount virtual thread lại (có thể trên carrier khác), tiếp tục từ điểm dừng.
Kết quả:
- Số carrier thread ≈ số CPU core (default). Không thay đổi dù có 1 hay 1 triệu virtual thread.
- Memory: virtual thread stack lưu trong heap, grow/shrink theo cần. Trung bình ~300 byte mỗi thread.
- Không syscall tạo virtual thread — user-space object của JVM.
Diagram mount/unmount
sequenceDiagram
participant V as Virtual Thread
participant C as Carrier (Platform) Thread
participant OS as OS
V->>C: Mount (chay tren C)
V->>OS: socket.read() (block)
V->>C: Unmount (C free, chay virtual khac)
C->>C: Chay virtual thread 2, 3, 4, ...
OS-->>V: I/O data ready
V->>C: Mount lai (co the carrier khac)
V->>V: Tiep tuc code sau socket.read()Trong thời gian virtual thread V chờ I/O, carrier C free hoàn toàn — có thể chạy hàng trăm virtual thread khác. Đây là cơ chế multiplex triệu virtual thread lên vài chục carrier.
So sánh cụ thể
| Aspect | Platform thread | Virtual thread |
|---|---|---|
| Tạo | Syscall OS, ~100μs | User-space, ~1μs |
| Stack | 1MB fixed (default) | Grow/shrink trong heap, ~300 byte avg |
| Scheduling | OS kernel scheduler | JVM (ForkJoinPool carrier) |
| Max concurrent | ~10k (RAM limit) | Millions |
| Khi block I/O | Thread giữ OS slot | Unmount carrier, free |
| CPU-bound task | OK | Không giúp (vẫn CPU hạn chế) |
| Native / JNI block | OK | Pin carrier — block cả carrier |
synchronized | OK | Pin carrier — anti-pattern |
3. Cách tạo virtual thread
Thread.ofVirtual()
Thread t = Thread.ofVirtual().start(() -> {
System.out.println("Hi from " + Thread.currentThread());
});
t.join();
Output:
Hi from VirtualThread[#21]/runnable@ForkJoinPool-1-worker-3
Tên trả về hiển thị: VirtualThread[#21] (số virtual thread) / ForkJoinPool-1-worker-3 (carrier hiện tại).
Executor với virtual thread
try (var exec = Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 0; i < 10_000; i++) {
exec.submit(() -> httpGet(url));
}
}
newVirtualThreadPerTaskExecutor() — không pool! Mỗi submit tạo 1 virtual thread mới. Vì virtual thread rẻ (~300 byte), không cần pool.
Tương phản với newFixedThreadPool(n) cho platform thread — nếu tạo thread mỗi task sẽ tốn. Virtual thread bỏ hẳn concept pool.
Factory cho pattern nâng cao
ThreadFactory vtf = Thread.ofVirtual()
.name("worker-", 0) // worker-0, worker-1, worker-2, ...
.factory();
Thread t = vtf.newThread(() -> doWork());
t.start();
Set name pattern, dùng làm factory trong ExecutorService hoặc framework nhận ThreadFactory.
4. Ví dụ benchmark — 10k HTTP request
Server fake mỗi request delay 100ms.
Platform thread pool:
ExecutorService exec = Executors.newFixedThreadPool(200);
long t1 = System.currentTimeMillis();
List<Future<String>> futures = new ArrayList<>();
for (int i = 0; i < 10_000; i++) {
futures.add(exec.submit(() -> httpCall()));
}
for (Future<String> f : futures) f.get();
long t2 = System.currentTimeMillis();
System.out.println("Platform: " + (t2 - t1) + "ms");
exec.shutdown();
Output: ~5000ms. Lý do: 10k task / 200 thread = 50 batch × 100ms = 5000ms.
Virtual thread:
try (var exec = Executors.newVirtualThreadPerTaskExecutor()) {
long t1 = System.currentTimeMillis();
List<Future<String>> futures = new ArrayList<>();
for (int i = 0; i < 10_000; i++) {
futures.add(exec.submit(() -> httpCall()));
}
for (Future<String> f : futures) f.get();
long t2 = System.currentTimeMillis();
System.out.println("Virtual: " + (t2 - t1) + "ms");
}
Output: ~100ms. 10k request chạy song song thực sự — mỗi request 1 virtual thread, block I/O unmount carrier, carrier free làm việc khác.
Khác biệt 50x. Không đổi API — thay executor là xong.
5. Khi nào nên dùng virtual thread
✅ I/O-bound workload
Ứng dụng chính của virtual thread:
- HTTP client gọi downstream service.
- Database query (JDBC, JPA).
- File I/O với
InputStream/OutputStream. - Message queue consumer (Kafka, RabbitMQ).
- Socket server (mỗi connection 1 virtual thread).
Workload này 99% thời gian chờ I/O. Virtual thread unmount khi chờ → carrier free → scale hàng nghìn concurrent.
✅ Request-per-thread pattern
Web server truyền thống: 1 request → 1 thread xử lý tuần tự. Code dễ đọc, debug stack trace đầy đủ. Nhưng platform thread không scale.
Virtual thread giải quyết: giữ model "1 request 1 thread", nhưng thread là virtual → scale.
Spring Boot 3.2+ hỗ trợ: spring.threads.virtual.enabled=true → Tomcat dùng virtual thread cho request thread.
✅ Migration từ pool blocking
Code cũ:
ExecutorService exec = Executors.newFixedThreadPool(200);
Chỉ cần đổi:
ExecutorService exec = Executors.newVirtualThreadPerTaskExecutor();
API ExecutorService giống — submit(task), CompletableFuture.supplyAsync(..., exec) — chạy y nguyên. Test performance trước/sau.
6. Khi nào KHÔNG dùng virtual thread
Đây là phần quan trọng ít người đọc kỹ. Virtual thread không phải "thay hết platform thread".
❌ CPU-bound workload
Virtual thread không tăng CPU parallelism. Số carrier = số CPU core. 1 triệu virtual thread tính toán nặng → vẫn chỉ N task chạy thực sự đồng thời (N = #core).
// BAD: tinh toan nang, khong I/O
IntStream.range(0, 1_000_000).forEach(i ->
Thread.ofVirtual().start(() -> heavyCompute(i))
);
Thread không block I/O nên không bao giờ unmount. 1M thread tranh 8 core → chạy tuần tự qua 1M, overhead mount/unmount context switch cao.
Dùng platform thread pool = #core hoặc parallel stream:
IntStream.range(0, 1_000_000).parallel().forEach(i -> heavyCompute(i));
❌ synchronized hot path — pinning
Đây là trap quan trọng nhất.
// BAD
synchronized (lock) {
httpCall(); // Block I/O trong synchronized
}
Virtual thread vào synchronized block — JVM không thể unmount khỏi carrier. Lý do: monitor lock do JVM track qua thread identity, portable giữa carrier sẽ phức tạp (đang được fix trong JEP tương lai).
Hậu quả: trong synchronized, virtual thread pin carrier. Block I/O trong đó → carrier bị giữ suốt I/O → không scale. Mất hết lợi ích virtual thread.
Fix: dùng ReentrantLock:
lock.lock();
try {
httpCall(); // Virtual thread van unmount duoc
} finally {
lock.unlock();
}
ReentrantLock track qua field object, JVM unmount virtual thread bình thường.
Kiểm tra pin: chạy với -Djdk.tracePinnedThreads=full — khi virtual thread bị pin và block, JVM in stack trace → debug ra nơi synchronized gây pin.
Trước khi chuyển sang virtual thread trong production, audit code: synchronized block có gọi I/O không? Nếu có, chuyển sang ReentrantLock. JDBC driver, connection pool, library cũ thường có synchronized — kiểm tra driver version có Loom-aware không. Java 21+ JDBC driver mainstream (PostgreSQL, MySQL) đã fix.
❌ Native / JNI call block
JNI code (native library C/C++) không biết khái niệm virtual thread. Block native call → pin carrier. Không khác synchronized về mặt pinning.
Nếu app dùng native library nhiều (crypto, compression, graphics), test kỹ. Có thể cần cô lập native call vào platform thread pool riêng.
❌ Thread-local heavy
private static final ThreadLocal<byte[]> BUFFER =
ThreadLocal.withInitial(() -> new byte[1024 * 1024]); // 1MB buffer
Với pattern platform thread pool 200 thread → tốn 200MB cho buffer. Với 1 triệu virtual thread → 1TB. OOM.
Virtual thread có ThreadLocal riêng — mỗi instance. Pattern "cache expensive object trong ThreadLocal" (hash, date formatter, protocol buffer) phải re-think.
Java 21 giới thiệu ScopedValue (preview) — alternative immutable cho ThreadLocal, phù hợp virtual thread.
Trong lúc chờ ScopedValue stable, giải pháp: share resource qua parameter hoặc object field, không ThreadLocal.
❌ Thay parallel stream cho CPU task
Như đã nói, CPU-bound vẫn dùng parallel stream. Đừng tưởng "1 triệu virtual thread chắc nhanh hơn parallel stream".
7. Structured concurrency (Java 21 preview)
Virtual thread mở đường cho structured concurrency — nhóm virtual thread thành 1 scope, handle lifecycle nhất quán.
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
Future<User> userFut = scope.fork(() -> fetchUser(id));
Future<List<Order>> ordersFut = scope.fork(() -> fetchOrders(id));
scope.join(); // Cho ca hai xong
scope.throwIfFailed(); // Nem exception neu 1 fail
return new View(userFut.resultNow(), ordersFut.resultNow());
}
Đặc điểm:
scope.fork(task)— start virtual thread mới trong scope.scope.join()— chờ tất cả fork xong.ShutdownOnFailure: nếu 1 fork throw, các fork còn lại bị cancel (interrupt). Nhất quán lifecycle.try-with-resourcesbảo đảm clean up nếu exception.
Giải quyết vấn đề cancel lan toả của CompletableFuture thủ công: với chain future, cancel 1 future không cancel các future song song khác. StructuredTaskScope quản lý như "group" — fail 1, cancel tất cả.
Preview ở Java 21 (cần --enable-preview), final dự kiến Java 25.
8. Pattern migration thực tế
Web server Spring Boot
# application.yml (Spring Boot 3.2+)
spring:
threads:
virtual:
enabled: true
Một dòng config. Tomcat dùng virtual thread cho request thread. @Async method cũng dùng virtual thread executor.
Code application không đổi — vẫn viết blocking style:
@GetMapping("/user/{id}")
public View getUser(@PathVariable int id) {
User user = userService.findById(id); // JDBC block
List<Order> orders = orderService.findAll(id); // REST block
return new View(user, orders);
}
Trước virtual thread, server hit limit ở vài nghìn concurrent connection. Sau virtual thread, scale tới hàng chục nghìn — chỉ 1 config change.
Background worker
// Truoc
ExecutorService exec = Executors.newFixedThreadPool(50);
// Sau
ExecutorService exec = Executors.newVirtualThreadPerTaskExecutor();
Giữ API y hệt. Submit task với CompletableFuture.supplyAsync(task, exec) — chạy trên virtual thread.
9. Pitfall tổng hợp
❌ Nhầm 1: Pool virtual thread.
Executors.newFixedThreadPool(200, Thread.ofVirtual().factory());
// Sai tu duy - virtual thread re, khong can pool
✅ newVirtualThreadPerTaskExecutor().
❌ Nhầm 2: synchronized wrap I/O.
synchronized (lock) { httpCall(); } // Pin carrier
✅ ReentrantLock:
lock.lock();
try { httpCall(); } finally { lock.unlock(); }
❌ Nhầm 3: Virtual thread cho CPU task.
for (int i = 0; i < 1_000_000; i++) {
Thread.ofVirtual().start(() -> heavyCompute());
}
✅ CPU-bound → parallel stream hoặc platform thread pool = #core.
❌ Nhầm 4: ThreadLocal heavy với virtual thread.
private static final ThreadLocal<byte[]> BUFFER = ...; // 1MB moi thread
// 1M virtual thread = 1TB
✅ Share qua param, hoặc ScopedValue (preview).
❌ Nhầm 5: Không audit library khi migrate.
// Library cu co synchronized(lockInternal) wrap I/O
// -> virtual thread bi pin
✅ Test perf trước khi deploy production. Dùng -Djdk.tracePinnedThreads=full detect pin.
10. 📚 Deep Dive Oracle
Spec / reference chính thức:
- JEP 444: Virtual Threads — spec Java 21, motivation, design rationale.
- JEP 453: Structured Concurrency (preview) — scope-based concurrency.
- JEP 446: Scoped Values (preview) — alternative cho ThreadLocal.
- Ron Pressler - State of Loom — chief designer Project Loom, giải thích design choice chi tiết.
- Oracle tutorial: Virtual Threads — cách dùng, best practice.
- Spring Boot virtual thread support — Spring Boot 3.2+.
Ghi chú: JEP 444 mô tả rõ điều kiện "pinning" — đọc kỹ khi gặp performance anomaly sau migrate. Flag -Djdk.tracePinnedThreads=full log stack trace mỗi lần virtual thread bị pin và park — essential debug tool. Ron Pressler's "State of Loom" paper là tài liệu technical sâu nhất — giải thích rationale tại sao không dùng "goroutine" style (Go) mà dùng "user-mode thread with OS integration" style.
11. Tóm tắt
- Virtual thread = user-space thread do JVM quản. Tạo ~1μs, stack ~300 byte. Millions per JVM khả thi.
- Tạo:
Thread.ofVirtual().start(...)hoặcExecutors.newVirtualThreadPerTaskExecutor(). - Carrier = platform thread backing virtual thread. JVM mount/unmount khi block I/O.
- Số carrier default = số CPU core. Không thay đổi dù có triệu virtual thread.
- Dùng cho I/O-bound workload: HTTP client, DB, file, socket, message queue.
- Không dùng cho CPU-bound — carrier vẫn giới hạn bởi #core.
synchronizedvới I/O inside pin carrier — mất lợi ích virtual. DùngReentrantLockthay.- Native / JNI call block cũng pin carrier.
ThreadLocalheavy tốn memory với triệu virtual thread — considerScopedValue(preview) hoặc share qua param.- Structured concurrency (preview) — scope-based lifecycle, cancel lan toả khi fork fail.
- API
ExecutorServicekhông đổi — drop-in replace platform pool. - Migration: audit
synchronizedquanh I/O, check library compatibility, test perf với-Djdk.tracePinnedThreads=full.
12. Tự kiểm tra
Q1Vì sao virtual thread rẻ hơn platform thread nhiều như vậy?▸
Platform thread = 1 OS thread:
- 1MB stack fixed, kernel cấp (có thể tăng lên khi grow).
- Syscall để tạo (
pthread_create) + register với OS scheduler. - Context switch đi qua kernel (~1-10μs).
10k thread = 10GB memory stack, hạn chế bởi RAM.
Virtual thread = object JVM user-space:
- Stack lưu trong Java heap, grow/shrink theo cần. Trung bình ~300 byte cho thread đơn giản.
- Tạo không syscall — chỉ alloc object.
- Scheduling do JVM (ForkJoinPool carrier) — không kernel.
Million thread = vài trăm MB — feasible.
Trade-off: virtual thread không tăng CPU parallelism — carrier vẫn = số CPU core. Virtual thread scale concurrency (nhiều task chờ I/O), không parallelism (nhiều task tính CPU).
Q2Vì sao synchronized block có thể pin virtual thread, và làm sao fix?▸
synchronized block có thể pin virtual thread, và làm sao fix?Khi virtual thread vào synchronized block, nó giữ monitor object. JVM cần virtual thread tiếp tục trên cùng carrier thread để tôn trọng Java memory model — monitor ownership không portable giữa carrier (đang fix trong JEP tương lai).
Hậu quả: nếu virtual thread block I/O trong synchronized, carrier bị giữ suốt I/O → không scale. Gọi là "pinning". Với 1000 virtual thread cùng vào synchronized + I/O, chỉ có #core carrier — effectively giống thread pool #core.
Fix: dùng ReentrantLock:
// BAD
synchronized (lock) { httpCall(); }
// GOOD
lock.lock();
try { httpCall(); } finally { lock.unlock(); }ReentrantLock track ownership qua field object, JVM unmount virtual thread bình thường khi block I/O trong lock.
Debug pin: chạy với -Djdk.tracePinnedThreads=full — JVM log mỗi lần pin + stack trace.
Q3Khi nào KHÔNG nên dùng virtual thread?▸
- CPU-bound workload: tính toán nặng, không I/O. Carrier vẫn giới hạn bởi #core — virtual thread không tăng parallelism. Dùng platform thread pool = #core hoặc parallel stream.
- synchronized hot path wrap I/O: pin carrier, mất lợi ích. Dùng ReentrantLock thay.
- Native / JNI call block: JNI không biết virtual thread → block cả carrier.
- ThreadLocal heavy: mỗi virtual thread có ThreadLocal riêng. 1MB buffer × 1M thread = 1TB OOM.
- Short-lived CPU task trong high-throughput loop: overhead mount/unmount lớn hơn lợi ích — dùng platform thread pool tính toán.
Quy tắc: virtual thread là công cụ I/O-bound scale concurrency. Không phải "replace platform thread mọi chỗ".
Checklist migration: audit synchronized + I/O, check library compat, benchmark perf, monitor pinning.
Q4Khác biệt giữa virtual thread và reactive stream (Reactor / RxJava) là gì?▸
Cả hai scale concurrency tốt cho I/O. Khác biệt về style code và debug:
- Virtual thread: code viết sequential blocking style (giống platform thread). JVM handle scheduling. Stack trace đầy đủ, debugger theo được từng dòng. Exception flow tự nhiên (try/catch).
- Reactive stream: code viết callback chain (Flux, Mono). Framework handle scheduling. Stack trace fragmented (hàng chục frame framework). Debug phức tạp. Nhưng có backpressure signal, operator library phong phú, interop tốt với stream từ Kafka/network.
Khi nào chọn cái nào:
- New code Java 21+: mặc định virtual thread. Code dễ đọc, debug, onboard dev mới.
- Legacy reactive: giữ nguyên, không migrate ngay. Reactive vẫn đúng.
- Cần backpressure / operator tinh vi (window, group, retry): reactive vẫn thắng. Virtual thread không có built-in operator.
- Stream data đến từ reactive source (WebFlux, Kafka streams): interop với reactive dễ hơn.
Xu hướng Java 21+: mới project dùng virtual thread + blocking API; reactive giữ cho use case chuyên biệt.
Q5Đoạn sau tạo bao nhiêu OS thread? for (int i = 0; i < 1_000_000; i++) Thread.ofVirtual().start(() -> Thread.sleep(1000))▸
for (int i = 0; i < 1_000_000; i++) Thread.ofVirtual().start(() -> Thread.sleep(1000))Gần như không tạo OS thread mới nào — ngoài pool carrier có sẵn (default = số CPU core, typical 4-16 thread).
Mỗi virtual thread là object JVM, sleep là parking operation → JVM unmount virtual thread khỏi carrier, carrier chạy virtual thread khác. Tất cả 1 triệu thread sleep song song, dùng ~8-16 OS thread.
Memory math:
- Platform thread 1M: 1M × 1MB stack = 1TB — chắc chắn OOM.
- Virtual thread 1M: 1M × ~300 byte = ~300MB — khả thi trên laptop thường.
Thời gian hoàn thành: ~1 giây (sleep duration), không cộng dồn — tất cả thread sleep song song. Platform thread pool 200 thread làm task tương tự sẽ mất 5000 giây.
Đây là scale model virtual thread: từ vài nghìn lên triệu concurrent task là change tuyến tính, không tiệm cận limit RAM.
Q6Làm sao phát hiện virtual thread bị pin trong production?▸
JDK flag -Djdk.tracePinnedThreads=full log stack trace mỗi lần virtual thread bị pin và park (block I/O):
Thread[#42,VirtualThread,state=RUNNING,carrier=ForkJoinPool-1-worker-3]
at java.base/java.lang.Thread.sleep0(Native Method)
at java.base/java.lang.Thread.sleep(Thread.java:509)
at com.myapp.Service.lambda$doWork$0(Service.java:42)
- locked <0x00007f0c3c0c1000> (a java.lang.Object) <-- synchronized block day
...Log chỉ ra method nào trong synchronized gây pin khi block I/O.
Alternative: flag -Djdk.tracePinnedThreads=short log ít hơn, chỉ trace stack đến chỗ `synchronized`.
Trong production: không bật full mọi lúc (log nặng). Dùng khi debug issue performance, hoặc trong staging environment.
Kết hợp với JFR (Java Flight Recorder): event jdk.VirtualThreadPinned capture perfomance data để phân tích offline.
Best practice: audit codebase trước migrate (grep synchronized), chạy JFR profile 1 tuần staging, kiểm tra không có pinning hot path, rồi deploy production.
Bài tiếp theo: Mini-challenge: Concurrent price aggregator
Bài này có giúp bạn hiểu bản chất không?