Virtual Threads Java 21: Xử lý Hàng Triệu Request Đồng Thời với Tài Nguyên Thấp

Development tutorial - IT technology blog
Development tutorial - IT technology blog

Bài toán thực tế: Khi thread pool bị nghẹt lúc peak hours

Cuối năm ngoái mình tham gia optimize một service Java Spring Boot đang nhận khoảng 50,000 request/giây. Triệu chứng quen thuộc: CPU không cao, RAM không đầy, nhưng latency tăng vọt và Tomcat liên tục báo “No threads available”. Thêm thread vào pool thì heap bắt đầu rên — mỗi platform thread mặc định ngốm ~512KB–1MB stack.

Vấn đề cốt lõi không phải thiếu CPU. Mô hình thread-per-request vốn không phù hợp với I/O-bound workload — 80% thời gian thread chỉ ngồi chờ database trả kết quả hoặc chờ response từ downstream API, không làm gì cả.

So sánh 3 cách tiếp cận xử lý đồng thời trong Java

1. Platform Threads — Đơn giản nhưng không scale được

Model này có từ Java 1.0. Mỗi request nhận một OS thread riêng, code chạy tuần tự, dễ đọc, debug thẳng không vòng vèo. Nhưng OS thread tốn tài nguyên — tạo/hủy chậm, context switch có chi phí, và số lượng bị giới hạn cứng bởi kernel.

// Thread pool truyền thống
ExecutorService executor = Executors.newFixedThreadPool(200);

Future<String> future = executor.submit(() -> {
    String dbResult = queryDatabase();     // block ~50ms
    String apiResult = callExternalApi();  // block ~100ms
    return dbResult + apiResult;
});

Với 200 threads và average latency 150ms, throughput tối đa chỉ khoảng 1,300 req/s. Tăng lên 2,000 threads thì đã tốn ~2GB RAM chỉ cho stack — chưa tính heap của ứng dụng.

2. Reactive Programming (WebFlux / Project Reactor)

Reactive Programming nổi lên từ khoảng 2018 như câu trả lời cho bài toán scalability này. Dùng non-blocking I/O với event loop, ít thread xử lý được nhiều request hơn rất nhiều.

// Reactive với Spring WebFlux
@GetMapping("/data")
public Mono<ResponseData> getData(@RequestParam String id) {
    return webClient.get()
        .uri("/external/{id}", id)
        .retrieve()
        .bodyToMono(ExternalData.class)
        .flatMap(ext -> repository.findByKey(ext.getKey()))
        .map(entity -> new ResponseData(entity));
}

Throughput cao, memory hiệu quả — nhưng cái giá phải trả không nhỏ. Mình từng mất 2 ngày debug một bug trong reactive chain mà nếu code sync thì 15 phút xong. Stacktrace trong reactive toàn lambda$0, onNext, không biết lỗi từ đâu. Và quan trọng hơn: toàn bộ codebase phải reactive từ đầu đến cuối — một chỗ block là chặn cả event loop.

3. Virtual Threads (Project Loom) — GA từ Java 21

Virtual Threads là lightweight threads do JVM quản lý, không phải OS. Tạo hàng triệu cái cũng được — stack của chúng nhỏ, lưu trên heap, không cấp phát bộ nhớ ở tầng OS.

Cơ chế hoạt động như thế này: khi virtual thread bị block bởi I/O, JVM tự động tháo (unmount) nó khỏi OS thread để OS thread đó phục vụ virtual thread khác. Khi I/O xong, JVM gắn (mount) virtual thread trở lại và tiếp tục. Với platform thread, bước tháo/gắn này không tồn tại — thread block là OS thread block.

// Virtual Thread — code vẫn viết blocking bình thường
try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
    Future<String> future = executor.submit(() -> {
        String dbResult = queryDatabase();     // JVM tự handle, không block OS thread
        String apiResult = callExternalApi();  // tương tự
        return dbResult + apiResult;
    });
    System.out.println(future.get());
}

Code trông y hệt platform thread. Nhưng JVM lo toàn bộ scheduling phía dưới — bạn không cần callback, không cần chain, không cần reactive mindset.

Phân tích ưu nhược: Chọn approach nào phù hợp?

Mình tổng hợp từ kinh nghiệm thực chiến:

  • Platform Threads: Phù hợp khi concurrent thấp (<500 req đồng thời), hoặc CPU-bound workload (tính toán, image processing). Đừng dùng khi I/O-heavy.
  • Reactive: Phù hợp khi team đã quen reactive mindset, cần throughput cực cao, và ứng dụng mới hoàn toàn. Migration legacy codebase sang reactive gần như là rewrite.
  • Virtual Threads: Phù hợp nhất cho I/O-bound services (REST API, microservices gọi DB/downstream), đặc biệt khi muốn tăng throughput mà không rewrite code. Migration từ platform thread rất ít đau đớn.

Tiêu chí đơn giản: nếu service của bạn dành phần lớn thời gian chờ (DB query, HTTP call, file I/O) — Virtual Threads là câu trả lời thực tế nhất bạn có thể áp dụng ngay.

Hướng dẫn triển khai Virtual Threads thực tế

Bước 1: Đảm bảo Java 21+

java -version
# Cần: openjdk 21.0.x hoặc cao hơn

# Nếu dùng SDKMAN
sdk install java 21.0.3-tem
sdk use java 21.0.3-tem

Bước 2: Standalone — không cần framework

import java.util.concurrent.Executors;
import java.util.concurrent.ExecutorService;

public class VirtualThreadDemo {
    public static void main(String[] args) throws Exception {
        // Tạo 100,000 virtual threads — thử với platform thread và xem OOM
        try (ExecutorService vte = Executors.newVirtualThreadPerTaskExecutor()) {
            for (int i = 0; i < 100_000; i++) {
                final int taskId = i;
                vte.submit(() -> {
                    Thread.sleep(1000); // simulate I/O wait
                    System.out.println("Task " + taskId + " done on: " + Thread.currentThread());
                    return null;
                });
            }
        } // auto-shutdown và await termination
    }
}

Bước 3: Tích hợp Spring Boot 3.2+

Spring Boot 3.2 hỗ trợ virtual thread native, chỉ cần 1 dòng config:

# application.yml
spring:
  threads:
    virtual:
      enabled: true

Hoặc khai báo bean thủ công nếu cần kiểm soát nhiều hơn:

@Configuration
public class ThreadConfig {

    @Bean
    public TomcatProtocolHandlerCustomizer<?> virtualThreadTomcatCustomizer() {
        return protocolHandler -> protocolHandler
            .setExecutor(Executors.newVirtualThreadPerTaskExecutor());
    }

    // Cho @Async tasks
    @Bean
    public AsyncTaskExecutor applicationTaskExecutor() {
        return new TaskExecutorAdapter(
            Executors.newVirtualThreadPerTaskExecutor());
    }
}

Với Spring Boot < 3.2 hoặc không dùng Spring, set executor trực tiếp lên server:

// Với HttpServer (JDK built-in)
HttpServer server = HttpServer.create(new InetSocketAddress(8080), 0);
server.setExecutor(Executors.newVirtualThreadPerTaskExecutor());
server.start();

Bước 4: Pinning — cái bẫy cần tránh

Virtual thread bị pinned (không thể unmount khỏi carrier thread) trong 2 trường hợp: bên trong synchronized block, và khi gọi native method. Nhiều người bỏ qua điểm này rồi thắc mắc tại sao performance không cải thiện dù đã bật virtual thread.

// BAD — synchronized pin virtual thread, mất hết lợi thế
public synchronized String getFromCache(String key) {
    return cache.get(key); // nếu block ở đây, carrier thread cũng bị block
}

// GOOD — dùng ReentrantLock thay synchronized
private final ReentrantLock lock = new ReentrantLock();

public String getFromCache(String key) {
    lock.lock();
    try {
        return cache.get(key);
    } finally {
        lock.unlock();
    }
}

Phát hiện pinning bằng JVM flag:

java -Djdk.tracePinnedThreads=full -jar your-app.jar

Log sẽ in ra stacktrace đầy đủ mỗi khi virtual thread bị pin — dùng để identify và fix chính xác.

Bước 5: Thread-local variables — dùng cẩn thận

Hàng triệu virtual thread đồng nghĩa với hàng triệu ThreadLocal instance tiềm năng. Dữ liệu lớn trong ThreadLocal dễ thành memory leak khi scale. Cân nhắc ScopedValue (Java 21 preview) cho các use case mới:

// ScopedValue — thay thế ThreadLocal cho virtual thread-friendly
static final ScopedValue<User> CURRENT_USER = ScopedValue.newInstance();

// Set value cho scope
ScopedValue.where(CURRENT_USER, user).run(() -> {
    processRequest(); // CURRENT_USER.get() trả về user trong scope này
});

Kết quả benchmark và những con số thực tế

Sau khi migrate service trên từ platform thread pool 400 threads sang virtual thread, kết quả đo trên production (load test với k6):

  • Throughput: từ ~2,600 req/s lên ~47,000 req/s (workload chủ yếu là DB query + downstream HTTP)
  • P99 latency: giảm từ 3.2s xuống 180ms ở cùng mức load
  • Heap usage: giảm ~40% vì không còn stack của 400 platform thread
  • Migration effort: <2 giờ, không đụng business logic

Trong quá trình debug và validate config trước khi deploy, mình hay dùng toolcraft.app để test nhanh các đoạn JSON config hay format response từ API — cụ thể là toolcraft.app/vi/tools/developer/json-formatter. Tiện hơn cài extension rất nhiều, đặc biệt khi đang SSH vào server và cần check response format nhanh.

Virtual Threads không thay thế được cho CPU-bound tasks như encryption hay image resize — bottleneck ở đó là CPU, không phải I/O. Nhưng với phần lớn web service thông thường (CRUD, API gateway, microservice gọi DB), đây có lẽ là thay đổi đơn giản nhất với tác động lớn nhất bạn có thể làm khi upgrade lên Java 21.

Share: