Spring Production-Ready/Virtual threads trong Boot — một dòng config đổi những gì
3/26
Bài 3 / 26~12 phútModern Java trong SpringMiễn phí lượt xem

Virtual threads trong Boot — một dòng config đổi những gì

spring.threads.virtual.enabled bật lên thì Tomcat, @Async và scheduler đổi ra sao, và hồ sơ tải nào thật sự hưởng lợi từ virtual threads.

TL;DR: spring.threads.virtual.enabled=true (yêu cầu Java 21+, Spring Boot 3.2+) đổi ba chỗ cùng lúc: Tomcat xử lý mỗi request trên một virtual thread thay vì lấy từ pool 200, @Async chuyển sang chạy trên SimpleAsyncTaskExecutor dùng virtual thread, và taskScheduler cũng vậy — mọi cấu hình pool cũ (max-threads, core-size...) hết tác dụng. Nó đáng bật khi phần lớn thời gian xử lý request là chờ I/O (gọi API ngoài, query DB) và code có blocking call thật; không đáng bật khi việc nặng là tính toán CPU, vì số core vẫn là trần cứng. Hiểu nhầm phổ biến nhất: virtual thread không làm một request đơn lẻ nhanh hơn — nó chỉ giúp hệ thống nhận nhiều request đồng thời hơn.

Một service gọi 3 API bên ngoài cho mỗi request chạy ổn ở tải thấp rồi nghẽn cứng khi traffic tăng, trong khi CPU server gần như rảnh. Bạn thêm spring.threads.virtual.enabled=true theo hướng dẫn tìm được, request qua nhanh hơn thật, nhưng không rõ cái gì vừa đổi và liệu nó có đáng bật cho service khác không.

Bài này giải thích dòng config đó chạm vào đâu trong runtime Boot, vì sao thread pool truyền thống là tài nguyên khan hiếm còn virtual thread thì không, và cách bạn tự quyết bật hay không cho một service cụ thể.

1. Kịch bản — TaskFlow cạn pool trong khi CPU gần như rảnh

Giả sử bạn vận hành TaskFlowOrchestrator — mỗi request xử lý một đơn hàng bằng cách gọi tuần tự ba service ngoài: thanh toán, kiểm tra tồn kho, đặt lịch giao hàng.

@Service
public class TaskFlowOrchestrator {

    private final PaymentClient paymentClient;
    private final InventoryClient inventoryClient;
    private final ShippingClient shippingClient;

    public TaskFlowOrchestrator(PaymentClient paymentClient,
                                 InventoryClient inventoryClient,
                                 ShippingClient shippingClient) {
        this.paymentClient = paymentClient;
        this.inventoryClient = inventoryClient;
        this.shippingClient = shippingClient;
    }

    public OrderResult process(OrderRequest request) {
        var payment = paymentClient.charge(request.orderId());
        var inventory = inventoryClient.reserve(request.orderId());
        var shipping = shippingClient.schedule(request.orderId());
        return new OrderResult(payment, inventory, shipping);
    }
}

Ba lời gọi đều là blocking call đồng bộ, mỗi call chờ mạng trả về trước khi gọi call tiếp theo — tổng cộng khoảng 300 đến 500 mili giây chờ ròng cho một request, gần như không tính toán CPU đáng kể.

server.tomcat.threads.max mặc định là 200 — Tomcat nhúng trong Boot chỉ có tối đa 200 platform thread xử lý request đồng thời. Ở tải thấp việc này ổn: thread trả về pool ngay sau vài trăm mili giây. Nhưng khi traffic tăng lên vài trăm request mỗi giây, mỗi request giữ một thread suốt lúc chờ, pool 200 cạn sạch trước khi request đầu tiên kịp xong. Request mới xếp hàng, timeout tăng, trong khi top trên server báo CPU idle khoảng 5%. Không phải máy yếu — chỉ hết "chỗ ngồi" để chờ, dù chẳng chỗ nào đang bận tính toán thật sự.

Đây chính xác là hình mẫu tải virtual thread sinh ra để giải: nhiều tác vụ đồng thời, phần lớn thời gian là chờ.

2. Một dòng config chạm ba chỗ

spring.threads.virtual.enabled=true không phải cờ tối ưu chung chung — nó bật ba mảnh cấu hình riêng biệt trong Boot cùng lúc, với điều kiện cứng: Java 21 trở lên và Spring Boot 3.2 trở lên. Thiếu một trong hai, property này không có tác dụng gì.

Chỗ chạm 1 — Tomcat protocol handler. Boot đăng ký TomcatVirtualThreadsWebServerFactoryCustomizer, nhiệm vụ chính thức là "kích hoạt VirtualThreadExecutor trên protocol handler của Tomcat". Tomcat không còn lấy thread từ pool 200 cố định — mỗi request nhận một virtual thread mới, dùng xong thì bỏ, không tái sử dụng như phần tử trong pool.

Chỗ chạm 2 — @Async. Bean applicationTaskExecutor đứng sau @Async đổi từ ThreadPoolTaskExecutor sang SimpleAsyncTaskExecutor chạy trên virtual thread. Method @Async không đổi dòng code nào, nhưng từ giờ mỗi lời gọi sinh một virtual thread riêng thay vì mượn thread từ pool.

Chỗ chạm 3 — taskScheduler. Tương tự, bean chạy @Scheduled đổi từ ThreadPoolTaskScheduler sang SimpleAsyncTaskScheduler dùng virtual thread.

Chỗ chạmTrước (mặc định)Sau khi bật
Request HTTP vào TomcatPlatform thread từ pool server.tomcat.threads.max (200)Virtual thread mới cho mỗi request
@AsyncThreadPoolTaskExecutor (applicationTaskExecutor)SimpleAsyncTaskExecutor dùng virtual thread
@ScheduledThreadPoolTaskScheduler (taskScheduler)SimpleAsyncTaskScheduler dùng virtual thread
Cấu hình pool cũ không còn tác dụng

Một khi bật virtual threads, mọi property cấu hình pool (server.tomcat.threads.max, spring.task.execution.pool.*, spring.task.scheduling.pool.*) bị bỏ qua — lý do đơn giản: không còn pool cố định nào để cấu hình kích thước. Đặt lại các property này sau khi bật virtual threads là công vô ích, không phải bug.

3. Vì sao thread pool là tài nguyên khan hiếm?

Pool 200 không phải con số tuỳ tiện — nó phản ánh chi phí thật của platform thread. Mỗi platform thread ánh xạ 1-1 với một OS thread, kèm vùng stack riêng mặc định khoảng 1MB, giữ nguyên suốt vòng đời thread kể cả lúc block chờ mạng — không làm gì nhưng vẫn chiếm chỗ. Context switch giữa các platform thread cũng đi qua kernel, tốn vài nghìn cycle CPU mỗi lần. Cộng dồn: 200 thread × 1MB là 200MB bộ nhớ dành cho việc chờ, và 200 là giới hạn thực tế trước khi RAM và chi phí context-switch thành vấn đề.

Virtual thread phá đúng giả định đó. Cơ chế chi tiết (mount/unmount, continuation, carrier thread) đã mổ kỹ ở bài Virtual Threads của java-internals — ở đây chỉ cần nhớ lại một câu: khi virtual thread gặp blocking I/O, nó tự tháo khỏi carrier thread (unmount), giải phóng carrier ngay cho việc khác, rồi mount lại khi I/O xong. Carrier thread không bao giờ ngồi im chờ.

flowchart TB
    A["Request den"] --> B["Lay 1 platform thread tu pool 200"]
    B --> C["Goi API ngoai, cho I/O ~ 400ms"]
    C --> D["Thread giu nguyen cho toi khi xong, khong lam gi khac duoc"]
    D --> E{"Con thread ranh trong pool?"}
    E -- "khong, het 200" --> F["Request moi xep hang cho"]
    E -- "co" --> G["Nhan request moi"]
flowchart TB
    A2["Request den"] --> B2["JVM tao 1 virtual thread rieng"]
    B2 --> C2["Goi API ngoai, cho I/O ~ 400ms"]
    C2 --> D2["Virtual thread unmount khoi carrier"]
    D2 --> E2["Carrier thread ranh ngay, nhan request khac"]
    E2 --> F2["I/O xong, virtual thread mount lai de tra ket qua"]

Từ đây suy ra đúng lằn ranh thắng-thua. Tải I/O-bound (phần lớn thời gian là chờ — gọi API, query DB, đọc file) thắng đậm, vì phần chờ vốn không cần CPU, chỉ cần một chỗ giữ trạng thái rẻ. Tải CPU-bound (phần lớn thời gian là tính toán thật) không hưởng lợi gì, vì carrier thread vẫn bận suốt lúc tính và số core vật lý vẫn là trần cứng. Virtual thread không tạo thêm CPU, nó chỉ ngừng lãng phí vào việc chờ.

4. Bật hay không — bảng quyết định

Hồ sơ tảiBật virtual threads?Vì sao
Tỉ lệ thời gian chờ I/O cao trên tổng thời gian xử lý (gọi API ngoài, JDBC/RestClient đồng bộ)BậtCarrier thread rảnh ngay khi virtual thread unmount, nhận request khác thay vì ngồi chờ
Việc nặng là tính toán CPU (mã hoá, xử lý ảnh, thuật toán numeric) chiếm phần lớn thời gianĐừng bậtCarrier thread vẫn bận suốt lúc tính — không có gì để unmount; lợi ích bằng 0, chỉ thêm chi phí tạo virtual thread
Đã dùng WebFlux với driver reactive thật sự (Mono/Flux, R2DBC, WebClient non-blocking)Không cầnRequest vốn đã không giữ thread nào khi chờ I/O — virtual thread không cộng thêm gì cho mô hình đã non-blocking
Có blocking call bên trong khối synchronized hoặc gọi native code (JNI)Cân nhắc kỹDễ dính pinning — virtual thread không unmount được, mất hết lợi ích. Cơ chế và cách phát hiện thuộc bài sau

Trục quyết định thật sự chỉ có một câu hỏi: phần lớn thời gian request của bạn dành cho chờ, hay dành cho tính? Nếu chưa chắc, đo — đừng đoán.

5. Đo thế nào cho đúng?

Sai lầm phổ biến nhất: gửi một request, đo thời gian phản hồi, thấy y hệt lúc chưa bật, rồi kết luận "không có tác dụng". Tài liệu Oracle nói thẳng: virtual thread tồn tại để tăng thông lượng (throughput) — số request hệ thống xử lý đồng thời mỗi đơn vị thời gian.

Đo cho đúng là đo throughput ở mức concurrency cao:

  1. Load test tăng dần số kết nối đồng thời — ví dụ 50, 200, 500, 2000 — cùng kịch bản gọi TaskFlowOrchestrator.process.
  2. Ở mỗi mức, ghi số request thành công mỗi giây và tỉ lệ lỗi/timeout, không chỉ latency trung bình.
  3. Pool 200 platform thread sẽ thấy throughput đi ngang rồi tụt ngay khi kết nối đồng thời vượt qua vùng lân cận 200; virtual thread tiếp tục leo vì không còn trần đó.
  4. Khác biệt chỉ lộ rõ ở vùng tải vượt xa kích thước pool cũ — tải thấp thì hai cấu hình gần như không khác nhau.

Pitfall thường gặp

Bật spring.threads.virtual.enabled=true không phải phép màu an toàn — có ba hỏng hóc thật sự hay gặp: pinning (virtual thread bị "kẹt" vào carrier khi gặp synchronized hoặc native call), giả định sai về ThreadLocal (mỗi request giờ là một thread mới toanh, không tái sử dụng như platform thread trong pool), và mất backpressure tự nhiên mà pool cũ vô tình cung cấp (pool cạn từng là một dạng giới hạn tải, virtual thread thì gần như không giới hạn). Cả ba thuộc phạm vi bài sau — xem Virtual threads: ba cạm bẫy khi bật trước khi đưa cấu hình này lên production.

Đào sâu

📚 Đào sâu — spec và tài liệu chính thức

Tài liệu chính thức:

Ghi chú: ba nguồn trên là chỗ tra chính xác nhất khi bạn cần biết version nào bắt đầu hỗ trợ gì, hoặc muốn đọc đúng tên bean/class Boot dùng thay vì nhớ theo trí nhớ.

Liên hệ các bài khác

Tóm tắt

  • Bật virtual threads đổi runtime ở ba nơi cùng lúc — Tomcat, @Async, scheduler — không phải một cấu hình rời rạc.
  • Thiếu Java 21 hoặc Boot 3.2 thì property này lặng lẽ vô nghĩa, không báo lỗi gì.
  • Câu hỏi quyết định duy nhất: phần lớn thời gian xử lý một request là chờ hay là tính?
  • Hệ thống đã dùng WebFlux thật sự (non-blocking driver) thì virtual thread không cộng thêm gì.
  • Muốn thấy khác biệt phải đo throughput ở tải vượt xa pool cũ, không phải latency một request.

Tự kiểm tra

Tự kiểm tra
Q1
Vì sao đặt lại server.tomcat.threads.max sau khi đã bật spring.threads.virtual.enabled=true không có tác dụng gì?
Vì khi virtual threads bật, Tomcat không còn dùng pool platform thread cố định nữa — mỗi request nhận một virtual thread mới toanh do JVM tạo, không lấy từ pool có kích thước khai báo trước. server.tomcat.threads.max chỉ có nghĩa khi còn pool để giới hạn kích thước; không còn pool thì property đó bị bỏ qua, không phải bug mà là hệ quả trực tiếp của việc đổi executor.
Q2
Một service chủ yếu làm việc mã hoá ảnh (CPU nặng, gần như không gọi mạng) có nên bật virtual threads không? Giải thích bằng đúng cơ chế mount/unmount.
Không nên — hoặc bật cũng không mang lại lợi ích đáng kể. Lợi ích của virtual thread đến từ việc unmount khỏi carrier thread lúc gặp blocking I/O, nhường carrier cho việc khác. Việc mã hoá ảnh là tính toán CPU liên tục, không có điểm chờ I/O nào để unmount — virtual thread gần như dính chặt vào carrier suốt thời gian xử lý, hành vi thực tế không khác gì chạy trên platform thread. Trong khi đó số core vật lý vẫn là trần cứng, virtual thread không tạo thêm năng lực tính toán.
Q3
Bạn benchmark bằng cách gửi một request duy nhất, đo response time trước và sau khi bật virtual threads, thấy hai con số gần như bằng nhau. Kết luận 'virtual threads không có tác dụng' có đúng không?
Kết luận đúng cho đúng phép đo, nhưng phép đo sai mục tiêu. Virtual thread được thiết kế để tăng throughput (số request xử lý đồng thời) ở mức concurrency cao, không phải để giảm latency của một request đơn lẻ — tài liệu Oracle nói rõ virtual thread không chạy code nhanh hơn platform thread. Muốn thấy khác biệt phải load test với số kết nối đồng thời vượt xa kích thước pool cũ (ví dụ trên 200 nếu pool mặc định là 200), rồi so throughput và tỉ lệ lỗi giữa hai cấu hình.
Q4
Service của bạn đã chạy WebFlux với R2DBC và WebClient non-blocking hoàn toàn. Bật spring.threads.virtual.enabled=true có giúp gì thêm không? Vì sao?
Gần như không giúp gì. Mô hình reactive vốn đã không giữ thread nào khi chờ I/O — request được xử lý qua event loop, không có thread nào bị "block" để unmount cả. Virtual thread giải quyết đúng vấn đề của mô hình blocking thread-per-request (platform thread bị giữ trong lúc chờ); một hệ thống đã non-blocking từ gốc không có vấn đề đó để giải. Bật property này trên WebFlux thuần không gây hại, nhưng cũng không mang lại lợi ích nào đáng kể.
Q5
Vì sao pool 200 platform thread mặc định của Tomcat lại trở thành nút thắt đúng ở vùng tải mà CPU vẫn còn rảnh, thay vì lúc CPU đã bận?
Vì giới hạn của pool 200 là giới hạn về số "chỗ chờ", không phải giới hạn về năng lực tính toán. Mỗi platform thread giữ một vùng stack khoảng 1MB và bị chiếm dụng suốt thời gian chờ I/O dù không làm gì — 200 request đang chờ mạng trả lời đã đủ lấp đầy pool, bất kể CPU có đang tính toán hay không. Nút thắt xuất hiện khi số request đồng thời (đang chờ) vượt qua 200, chứ không liên quan gì đến việc CPU bận hay rảnh — đó là dấu hiệu nhận ra tải I/O-bound đang bị giới hạn sai chỗ.

Bài tiếp theo: Virtual threads: ba cạm bẫy khi bật

Bài này đáng gửi cho bạn học cùng?

Copy link đã gắn nguồn — dán group, chat, hoặc LinkedIn.

Bài này có giúp bạn hiểu bản chất không?

Hỏi đáp về bài này

Chưa có câu hỏi

Đặt câu hỏi

Có gì chưa rõ trong bài? Đặt câu hỏi đầu tiên — câu trả lời từ cộng đồng giúp bạn (và người sau).

Đặt câu hỏi đầu tiên

Bài tiếp theo

Ba thứ gãy sau khi bật virtual threads