Virtual threads: khi 200 thread Tomcat không đủ
Tiếp đi. App Spring Boot của mình gọi payment API bên ngoài mất 2 giây. Lúc cao điểm, CPU chỉ 10% mà request vẫn chờ rất lâu, có cái timeout. Sao vậy?
Vẫn Order Service trên Tomcat. Đây là nút thắt kinh điển của mô hình thread-per-request.
1. Bài toán
Tomcat mặc định có 200 thread (server.tomcat.threads.max=200). Mỗi request giữ một thread từ đầu đến cuối. API POST /orders/{id}/pay gọi payment gateway mất 2 giây.
200 thread / 2s mỗi request ≈ 100 request/giây tối đa
Request thứ 201 → xếp hàng, dù CPU 10%
2. Vấn đề
http-nio-8080-exec-1 [---- chờ payment 2s ----]
http-nio-8080-exec-2 [---- chờ payment 2s ----]
...
http-nio-8080-exec-200 [---- chờ payment 2s ----]
request 201.. ⏳ xếp hàng → timeout
Thủ phạm không phải CPU mà là thread bị block vì IO. Các cách cũ đều có giá:
- Đẩy sang
@Async/CompletableFuture (Bài 3.9): Tomcat pool 200 và app executor 20 là hai pool khác nhau. Lời gọi blocking vẫn chiếm một worker; nút thắt chỉ chuyển chỗ. - Tăng max threads lên vài nghìn: platform thread đắt (xem câu hỏi phụ bên dưới).
- Reactive (WebFlux): ít thread, không block. Hiệu quả nhưng code khó viết, khó debug.
3. Giải pháp: Virtual thread
Virtual thread (chính thức từ Java 21) là thread do JVM quản lý, rất nhẹ. Nhiều virtual thread chạy trên một nhóm nhỏ platform thread gọi là carrier (mặc định ≈ số core).
Điểm mấu chốt: khi virtual thread gặp IO blocking (đọc socket, sleep, lock...), JVM tháo nó khỏi carrier (unmount), lưu stack lên heap, cho carrier chạy virtual thread khác. IO xong thì gắn lại (mount) chạy tiếp.
Virtual thread không làm code chạy nhanh hơn. Nó giúp chạy được nhiều việc chờ cùng lúc hơn (throughput), mà vẫn viết code blocking dễ đọc.
4. Code từng bước
Bước 1: Java 21 + Spring Boot 3.2+
<properties>
<java.version>21</java.version>
</properties>
Bước 2: Bật trong application.yml
spring:
threads:
virtual:
enabled: true
datasource:
hikari:
maximum-pool-size: 20 # nút thắt mới, xem bên dưới
Khi đó Tomcat xử lý mỗi request trên một virtual thread; @Async, @Scheduled, listener Kafka/RabbitMQ cũng dùng virtual thread. Controller/service giữ nguyên.
Bước 3: Code Java. Tự dùng virtual thread và giới hạn lời gọi ra ngoài:
@Service
public class PaymentService {
private final PaymentClient client;
private final Semaphore gatewayLimit = new Semaphore(50); // gateway chỉ chịu 50 call đồng thời
public PaymentService(PaymentClient client) { this.client = client; }
public PaymentResult pay(PaymentRequest req) throws InterruptedException {
gatewayLimit.acquire();
try {
return client.charge(req); // blocking, nhưng chỉ unmount
} finally {
gatewayLimit.release();
}
}
// ví dụ 10 payment: mỗi payment một virtual thread
public List<PaymentResult> payAll(List<PaymentRequest> reqs) throws Exception {
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
List<Future<PaymentResult>> fs = reqs.stream()
.map(r -> executor.submit(() -> pay(r)))
.toList();
List<PaymentResult> out = new ArrayList<>();
for (var f : fs) out.add(f.get());
return out;
} // close() chờ tất cả xong
}
}
Thread.ofVirtual().name("vt-", 0).start(() -> doWork()); // tạo lẻ
Bước 4: Test
# Bắn 1000 request đồng thời, payment giả lập chờ 2s
seq 1 1000 | xargs -P 1000 -I{} curl -s -o /dev/null -w "%{time_total}\n" \
-X POST localhost:8080/orders/{}/pay | sort -n | tail -1
# Platform thread (200): request chậm nhất ≈ 10s
# Virtual thread: ≈ 2s (nếu Semaphore/gateway cho phép)
# Log thread name
# VirtualThread[#52]/runnable@ForkJoinPool-1-worker-3
5. Cách hoạt động
Carrier threads (≈ số core, ví dụ 4)
carrier-1: [VT1 chạy]─unmount─[VT7 chạy]─[VT1 mount lại]...
carrier-2: [VT2 chạy]─unmount─[VT8 chạy]...
carrier-3: ...
carrier-4: ...
Heap: VT1 (stack đang chờ socket), VT2, ..., VT1000 ← vài KB mỗi cái
VT1: charge() → socket read → block?
→ JVM: lưu stack VT1 lên heap, nhả carrier-1
→ payment trả lời → VT1 được mount lên carrier rảnh bất kỳ
Nhớ ví dụ 10 payment / 2 worker ở Bài 3.9: với platform thread, muốn 10 flow cùng chờ bank thì hoặc cần 10 thread, hoặc viết non-blocking bằng future. Với virtual thread, cứ viết blocking, mỗi payment một virtual thread, 10 flow chờ song song ≈ 2s mà không giữ 10 thread OS.
6. So sánh
| Platform thread | Virtual thread | |
|---|---|---|
| Quản lý bởi | OS | JVM |
| Bộ nhớ | ~1MB stack (reserve) | Vài trăm byte đến vài KB, lớn dần |
| Số lượng | Vài nghìn | Hàng triệu |
| Khi block IO | Chiếm thread OS | Nhả carrier cho việc khác |
| Pool? | Nên pool | Không pool, tạo mới mỗi task |
| Hợp với | CPU-bound | IO-bound |
7. Khi nào nên dùng / không nên dùng
- Nên: service nhiều IO (gọi API, DB, message) trên Java 21+, muốn tăng throughput mà giữ code MVC blocking.
- Không nên kỳ vọng: CPU-bound nhanh hơn (carrier ≈ số core); latency từng request giảm.
- Nút thắt chuyển chỗ: HikariCP mặc định 10 connection. Virtual thread không tạo thêm connection, chúng xếp hàng ở pool. Giới hạn đồng thời bằng
Semaphore, không bằng pool size.
Sao không tăng max threads lên vài nghìn?
Làm được, nhưng sẽ trả giá. Platform thread là thread của hệ điều hành, khá đắt:
- Mỗi thread có stack riêng, mặc định khoảng 1MB (reserve). Vài nghìn thread là vài GB.
- Context switch do OS làm; nhiều thread thì CPU tốn thời gian chuyển qua lại.
- Tạo/hủy chậm, nên mới phải dùng pool (Bài 3.3).
5000 platform thread × ~1MB ≈ 5GB reserve, context switch nặng
5000 virtual thread × ~KB ≈ vài chục MB, chạy trên 4–8 carrier
Pinning là gì? Có cạm bẫy nào khác không?
Có, đây là cạm bẫy quan trọng nhất. Pinning: virtual thread bị "ghim" vào carrier, block IO mà không unmount được, carrier bị chiếm như platform thread thường. Ở Java 21, nguyên nhân chính:
- Block IO bên trong khối
synchronized. - Gọi native method / foreign function.
// Java 21: pinning
synchronized (this) {
paymentClient.charge(req); // carrier bị giữ suốt 2s
}
// Cách tránh trên Java 21: dùng ReentrantLock (Bài 3.7)
lock.lock();
try { paymentClient.charge(req); } finally { lock.unlock(); }
Java 24 (JEP 491) đã sửa: synchronized không còn gây pinning. Phát hiện pinning trên 21 bằng -Djdk.tracePinnedThreads=full hoặc JFR event jdk.VirtualThreadPinned.
Cạm bẫy khác:
- Đừng pool virtual thread. Nó rẻ, tạo mới cho mỗi task. Muốn giới hạn đồng thời thì dùng
Semaphore. - ThreadLocal (Bài 3.8): mỗi virtual thread có bản riêng. Triệu thread × object nặng trong ThreadLocal là tốn RAM. Context nhỏ thì ổn; Java mới có
ScopedValuethay thế. - CPU-bound không lợi gì.
Virtual threads với WebFlux, chọn cái nào?
Cả hai đều giải quyết "nhiều request chờ IO", nhưng theo hai hướng khác nhau:
| Virtual threads (MVC) | WebFlux (reactive) | |
|---|---|---|
| Kiểu code | Blocking, tuần tự, dễ đọc | Mono/Flux, chuỗi operator |
| Debug, stack trace | Bình thường | Khó |
| Thư viện | JDBC, JPA, RestClient như cũ | Cần driver non-blocking (R2DBC, WebClient) |
| Backpressure, streaming | Phải tự làm | Có sẵn |
| Hợp với | Đa số CRUD/microservice nhiều IO | Streaming, SSE, hệ thống đã reactive sẵn |
Với dự án mới như Order Service, đa số team chọn Spring MVC + virtual threads: được phần lớn lợi ích throughput mà giữ code đơn giản.
Phỏng vấn hỏi "virtual thread là gì, khi nào dùng?" thì trả lời sao?
Q1. Virtual thread là gì, giải quyết vấn đề gì?
🇻🇳 Tiếng Việt:
Virtual thread là thread nhẹ do JVM quản lý, chính thức từ Java 21, chạy trên vài carrier thread. Khi gặp IO blocking nó nhả carrier cho việc khác, nên có thể có hàng trăm nghìn thread mà vẫn viết code blocking bình thường. Nó giải quyết bài toán thread-per-request: Tomcat 200 thread bị chiếm hết vì chờ IO dù CPU rảnh. Trong Spring Boot 3.2+ bật bằng
spring.threads.virtual.enabled=true.
🇬🇧 English:
A virtual thread is a lightweight thread managed by the JVM, final since Java 21, running on a small set of carrier threads. When it blocks on I/O it releases its carrier, so we can have hundreds of thousands of threads while keeping simple blocking code. It solves the thread-per-request bottleneck where all 200 Tomcat threads wait on I/O while the CPU is idle. In Spring Boot 3.2 and later we enable it with spring.threads.virtual.enabled=true.
Q2. Cần lưu ý gì khi dùng virtual thread?
🇻🇳 Tiếng Việt:
Chỉ có lợi cho IO-bound, không làm từng request nhanh hơn. Không pool virtual thread, giới hạn tài nguyên bằng Semaphore. Trên Java 21 tránh IO trong
synchronizedvì bị pinning, dùng ReentrantLock; Java 24 đã sửa. Cẩn thận ThreadLocal nặng. Và nút thắt sẽ chuyển sang connection pool DB hoặc rate limit của API ngoài.
🇬🇧 English:
It only helps I/O-bound work and does not make a single request faster. We should not pool virtual threads; we limit resources with a Semaphore instead. On Java 21 we avoid blocking I/O inside synchronized blocks because of pinning and use ReentrantLock instead; Java 24 fixes this. We should be careful with heavy ThreadLocals. And the bottleneck usually moves to the database connection pool or the rate limit of external APIs.
Q3. Virtual threads hay WebFlux?
🇻🇳 Tiếng Việt:
Với service CRUD nhiều IO, em chọn Spring MVC + virtual threads vì giữ code blocking dễ đọc, dùng được JDBC/JPA. WebFlux hợp khi cần streaming, backpressure hoặc hệ thống đã reactive end-to-end.
🇬🇧 English:
For I/O-heavy CRUD services I choose Spring MVC with virtual threads, because the code stays blocking and readable and we keep JDBC and JPA. WebFlux fits better when we need streaming, backpressure, or the whole stack is already reactive.
Cách nhớ ngắn gọn
- Block IO → virtual thread unmount, carrier làm việc khác.
- Tăng throughput, không giảm latency; chỉ lợi cho IO-bound.
- Không pool, dùng Semaphore; nút thắt mới là HikariCP.
- Java 21:
synchronized+ IO = pinning → ReentrantLock.
Tự kiểm tra
Vì sao CPU thấp mà request vẫn chờ lâu?
Tất cả thread Tomcat đang block chờ IO, request mới không có thread để chạy.
Virtual thread có làm một request nhanh hơn không?
Không. Nó tăng throughput (số việc chờ đồng thời), không giảm latency của từng việc.
Pinning là gì, tránh thế nào trên Java 21?
Virtual thread không unmount được khi block, thường do IO trong synchronized. Dùng ReentrantLock, hoặc lên Java 24.
Vì sao không nên pool virtual thread?
Chúng rẻ, pool chỉ thêm phức tạp. Giới hạn tài nguyên bằng Semaphore hoặc connection pool.
Bật virtual thread rồi, nút thắt tiếp theo thường ở đâu?
Connection pool DB, rate limit của API ngoài. Cần cấu hình và giới hạn đồng thời phù hợp.