Virtual threads: khi 200 thread Tomcat không đủ

5 câu hỏi~22 phútTừ khóa: platform thread, virtual thread, carrier, mount/unmount, pinning, spring.threads.virtual.enabled, Semaphore, WebFlux

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á:

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 threadVirtual thread
Quản lý bởiOSJVM
Bộ nhớ~1MB stack (reserve)Vài trăm byte đến vài KB, lớn dần
Số lượngVài nghìnHàng triệu
Khi block IOChiếm thread OSNhả carrier cho việc khác
Pool?Nên poolKhông pool, tạo mới mỗi task
Hợp vớiCPU-boundIO-bound

7. Khi nào nên dùng / không nên dùng

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:

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:

// 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:

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 codeBlocking, tuần tự, dễ đọcMono/Flux, chuỗi operator
Debug, stack traceBình thườngKhó
Thư việnJDBC, JPA, RestClient như cũCần driver non-blocking (R2DBC, WebClient)
Backpressure, streamingPhải tự làmCó sẵn
Hợp vớiĐa số CRUD/microservice nhiều IOStreaming, 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 synchronized vì 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


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.