Java Virtual Threads: hướng dẫn thực hành

1. Tổng quan

Virtual threads là tính năng chính thức từ Java 21 (JEP 444), giúp bạn viết code đồng bộ (blocking style) nhưng vẫn đạt throughput cao như non-blocking I/O. Bài này đi từ khái niệm, cách tạo virtual thread, so sánh hiệu năng với platform thread, đến cách tích hợp vào Spring Boot. Đọc xong, bạn sẽ biết khi nào nên dùng virtual threads và khi nào không.

2. Vấn đề với Platform Thread truyền thống

Mỗi platform thread trong Java ánh xạ 1-1 với một OS thread. OS thread tốn bộ nhớ (thường 1MB stack) và việc tạo/chuyển ngữ cảnh (context switch) có chi phí cao. Vì vậy, các ứng dụng xử lý hàng chục nghìn request đồng thời (ví dụ server chờ I/O) thường bị giới hạn bởi số lượng thread pool có thể tạo.

ExecutorService executor = Executors.newFixedThreadPool(200);
for (int i = 0; i < 10_000; i++) {
    executor.submit(() -> {
        try {
            Thread.sleep(1000); // giả lập gọi I/O
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
    });
}
executor.shutdown();

Với pool 200 thread, 10.000 task phải xếp hàng chờ, tổng thời gian hoàn thành kéo dài vì mỗi thread chỉ xử lý được một task tại một thời điểm, dù phần lớn thời gian là chờ (blocked), không phải tính toán.

3. Virtual Thread là gì

Virtual thread là thread do JVM quản lý (không phải OS), được “mount” lên một platform thread (gọi là carrier thread) chỉ khi đang thực thi code. Khi virtual thread gọi blocking operation (I/O, Thread.sleep, lock…), JVM tự động “unmount” nó khỏi carrier thread, giải phóng carrier thread để xử lý virtual thread khác.

Kết quả: bạn có thể tạo hàng triệu virtual thread mà không lo cạn tài nguyên, trong khi code vẫn viết theo kiểu blocking quen thuộc — không cần CompletableFuture, không cần reactive.

3.1. Tạo virtual thread đơn giản

Thread vThread = Thread.ofVirtual()
    .name("order-processor")
    .start(() -> {
        System.out.println("Chạy trên: " + Thread.currentThread());
    });
vThread.join();

Thread.ofVirtual() trả về builder để cấu hình thread ảo. .start() tạo và chạy ngay. In ra thread sẽ thấy dạng:

Chạy trên: VirtualThread[#21,order-processor]/runnable@ForkJoinPool-1-worker-1

Chú ý phần @ForkJoinPool-... — đó chính là carrier thread đang “cõng” virtual thread này.

4. Executor dành cho Virtual Thread

Cách phổ biến nhất để dùng virtual threads trong ứng dụng thực tế là qua ExecutorService.

try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
    List<Future<String>> futures = new ArrayList<>();
    for (int i = 0; i < 10_000; i++) {
        int id = i;
        futures.add(executor.submit(() -> {
            Thread.sleep(1000); // giả lập gọi I/O (DB, HTTP…)
            return "Task " + id + " xong";
        }));
    }
    for (Future<String> f : futures) {
        f.get();
    }
}

newVirtualThreadPerTaskExecutor() tạo một virtual thread mới cho mỗi task, không tái sử dụng thread như thread pool truyền thống. Vì chi phí tạo virtual thread rất thấp (vài trăm byte, không phải 1MB), việc này hoàn toàn hợp lý. Khối try-with-resources đảm bảo executor được đóng, chờ các task hoàn thành khi thoát block.

So với ví dụ ở mục 2 (pool 200 platform thread), đoạn code này chạy xong toàn bộ 10.000 task gần như cùng lúc 1 giây (vì không còn giới hạn pool), thay vì phải xếp hàng nhiều vòng.

5. So sánh hiệu năng bằng test thực tế

class VirtualThreadBenchmarkTest {

    @Test
    void virtualThreadsShouldHandleManyBlockingTasksFast() throws InterruptedException {
        int taskCount = 10_000;
        CountDownLatch latch = new CountDownLatch(taskCount);
        Instant start = Instant.now();

        try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
            for (int i = 0; i < taskCount; i++) {
                executor.submit(() -> {
                    try {
                        Thread.sleep(100);
                    } catch (InterruptedException e) {
                        Thread.currentThread().interrupt();
                    } finally {
                        latch.countDown();
                    }
                });
            }
        }

        latch.await();
        Duration elapsed = Duration.between(start, Instant.now());

        assertThat(elapsed).isLessThan(Duration.ofSeconds(5));
    }
}

Test này tạo 10.000 task, mỗi task ngủ 100ms. Nếu chạy tuần tự sẽ mất 1000 giây, nhưng với virtual threads, toàn bộ hoàn thành trong vài giây vì các task chạy song song thực sự. CountDownLatch dùng để đợi tất cả task xong trước khi assert.

Lưu ý: Benchmark thực tế nên đo thêm CPU, memory bằng JFR (Java Flight Recorder) thay vì chỉ tính thời gian, vì thời gian còn phụ thuộc vào máy chạy test.

6. Pitfall: Pinning khi dùng synchronized

Virtual thread sẽ không unmount khỏi carrier thread nếu nó đang ở trong block synchronized — hiện tượng này gọi là pinning. Khi đó, blocking I/O bên trong synchronized sẽ chiếm luôn carrier thread, làm mất lợi ích của virtual thread.

public class InventoryService {

    private final Object lock = new Object();

    public void reserveStock(String sku) {
        synchronized (lock) {
            callExternalInventoryApi(sku); // blocking I/O bên trong synchronized -> pinning!
        }
    }
}

Dòng synchronized (lock) giữ carrier thread trong suốt thời gian gọi callExternalInventoryApi, dù đó là thao tác I/O. Nếu có hàng nghìn virtual thread cùng pin, carrier thread pool (mặc định bằng số CPU core) sẽ nhanh chóng cạn kiệt.

Cách khắc phục: thay synchronized bằng java.util.concurrent.locks.ReentrantLock, vốn tương thích tốt với virtual thread (không gây pinning).

public class InventoryService {

    private final ReentrantLock lock = new ReentrantLock();

    public void reserveStock(String sku) {
        lock.lock();
        try {
            callExternalInventoryApi(sku);
        } finally {
            lock.unlock();
        }
    }
}

ReentrantLock cho phép virtual thread unmount bình thường khi gặp blocking I/O, giữ đúng tinh thần “nhẹ” của virtual thread.

7. Tích hợp Virtual Thread vào Spring Boot

Từ Spring Boot 3.2, bạn chỉ cần bật một property để Spring Web (Tomcat) dùng virtual thread cho mỗi request.

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
</dependency>
spring.threads.virtual.enabled=true

Property này bật virtualThreadExecutor cho Tomcat (và các executor khác trong Spring Boot hỗ trợ). Mỗi request HTTP đến sẽ được xử lý trên một virtual thread riêng, thay vì chia sẻ pool platform thread cố định (mặc định 200 thread của Tomcat).

@RestController
@RequestMapping("/orders")
class OrderController {

    private final OrderService orderService;

    OrderController(OrderService orderService) {
        this.orderService = orderService;
    }

    @GetMapping("/{id}")
    public OrderResponse getOrder(@PathVariable Long id) {
        return orderService.fetchOrderWithDetails(id); // gọi DB + API khác, blocking bình thường
    }
}

Code controller không cần thay đổi gì — bạn vẫn viết blocking style như cũ. Khi test tải với công cụ như ab hoặc k6, bạn sẽ thấy throughput tăng rõ rệt với các endpoint có độ trễ I/O cao (gọi DB, gọi service khác), vì Tomcat không còn bị giới hạn bởi 200 thread mặc định.

8. Khi nào KHÔNG nên dùng Virtual Thread

  • CPU-bound task: Nếu công việc chủ yếu là tính toán (không có I/O chờ), virtual thread không mang lại lợi ích — số lượng carrier thread vẫn bị giới hạn bởi số CPU core. Dùng ForkJoinPool hoặc parallelStream() phù hợp hơn.
  • Code dùng ThreadLocal nặng: Virtual thread tương thích ThreadLocal, nhưng nếu bạn tạo hàng triệu virtual thread, mỗi cái giữ ThreadLocal lớn sẽ gây tốn bộ nhớ đáng kể — nên cân nhắc ScopedValue (preview từ Java 21) thay thế.
  • Thư viện cũ dùng synchronized nhiều nơi: Như mục 6 đã nói, pinning có thể âm thầm làm giảm hiệu năng mà bạn không nhận ra ngay.
  • Connection pool giới hạn: Virtual thread giúp tạo nhiều request song song, nhưng nếu DB connection pool chỉ có 20 connection, bạn vẫn bị nghẽn ở đó — cần tăng pool size tương ứng hoặc dùng connection pool hỗ trợ tốt concurrency cao.

Lưu ý: Virtual thread giải quyết bài toán số lượng thread, không giải quyết bài toán tốc độ xử lý từng task. Đừng kỳ vọng nó làm CPU-bound code chạy nhanh hơn.

9. Kết luận

Virtual threads giúp bạn viết code blocking đơn giản nhưng vẫn mở rộng tốt cho hàng nghìn request đồng thời, đặc biệt phù hợp với ứng dụng I/O-bound như web service, microservice gọi nhiều API/DB. Bạn chỉ cần đổi executor hoặc bật một property trong Spring Boot, không cần viết lại logic theo kiểu reactive. Tuy nhiên, hãy để ý pitfall về pinning với synchronized và nhớ rằng virtual thread không giúp ích gì cho CPU-bound task.

javavirtual-threadsconcurrencyspring-bootjava21