Virtual Threads: vì sao rẻ hơn platform thread nhiều bậc
Virtual thread (final Java 21) rẻ hơn platform thread nhiều bậc nhờ mount/unmount cuộn stack vào heap, multiplex hàng triệu thread lên vài carrier thread.
TL;DR: Virtual thread (final từ Java 21, JEP 444) phá giả định mỗi java.lang.Thread chiếm trọn một OS thread: JVM multiplex hàng triệu virtual thread lên một nhóm nhỏ carrier thread, mount khi chạy và unmount khi gặp blocking I/O — stack cuộn vào heap (continuation) nên mỗi thread khởi đầu chỉ tốn vài trăm byte. Hệ quả: mô hình thread-per-request quay lại ở quy mô triệu kết nối, và newVirtualThreadPerTaskExecutor không còn là thread pool mà là cổng quản lý vòng đời cho task. Lợi ích đến hoàn toàn từ việc unmount khi block — một virtual thread không bao giờ block thì chẳng khác gì chạy trên platform thread.
1. Vì sao thread-per-request từng là điều không thể?
Bài trước khép lại Phần B với Fork/Join: work-stealing vắt kiệt CPU cho bài toán chia để trị, nhưng nó sợ nhất task blocking — một worker ngồi chờ I/O là một core bỏ phí, phải lôi ManagedBlocker ra vá. Đó không phải lỗi của Fork/Join, mà là triệu chứng của một giả định sâu hơn, đi cùng Java từ bài Process và Thread: mỗi java.lang.Thread chiếm trọn một OS thread suốt vòng đời.
Giả định ấy đặt ra một cái trần. Một OS thread tốn vài megabyte stack và một slot trong scheduler của kernel; tạo vài nghìn cái là hệ thống bắt đầu rên. Nên ta gom thread vào pool (Executor và thread pool). Nhưng pool chỉ khấu hao chi phí tạo/huỷ thread, không xoá cái trần — nó dời cái trần thành kích thước pool: khi cả 200 thread đều nằm chờ database, request thứ 201 phải xếp hàng dù CPU gần như rảnh. Ta giữ tài nguyên đắt đỏ chỉ để chờ.
Virtual thread phá đúng giả định đó: tạo một triệu cái cho một triệu task blocking I/O là chuyện bình thường. Khi thread không còn khan hiếm, ta không cần pool nó, không cần đếm nó, không cần né việc nó block — mô hình lập trình đơn giản nhất, mỗi request một thread, code tuần tự, gặp I/O thì cứ chặn, quay lại làm được ở quy mô mà mười năm trước buộc ta viết callback hoặc CompletableFuture chỉ để né cái trần. Virtual thread là tính năng final từ Java 21; series này lấy bản LTS Java 25 làm baseline.
Virtual thread vẫn là một java.lang.Thread: cùng API, cùng Thread.currentThread(), cùng ThreadLocal, cùng cách ném và bắt exception — code nghiệp vụ chạy y nguyên, không sửa một dòng. Khác biệt nằm ở cách JVM lập lịch nó, không ở bề mặt API. Ba lối tạo: builder Thread.ofVirtual() (đối xứng Thread.ofPlatform() khi cần platform thread tường minh), lối tắt Thread.startVirtualThread(...), và cách dùng đúng quy mô — một executor sinh đúng một virtual thread mới cho mỗi task submit:
Thread vt = Thread.ofVirtual().name("booking-worker").start(() -> handleRequest(0));
vt.join();
Thread.startVirtualThread(() -> handleRequest(1)); // one-liner shortcut
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 0; i < 10_000; i++) {
int id = i;
executor.submit(() -> handleRequest(id));
}
} // try-with-resources: close() blocks until every task finishes
Cái tên nói thẳng triết lý, và đây là chỗ trực giác cũ phải gỡ bỏ: đây không phải thread pool. Nó không giữ tập thread chờ tái sử dụng; mỗi task nhận một virtual thread mới tinh, chạy xong thì chết hẳn. Với platform thread làm vậy là tự sát vì chi phí tạo thread; với virtual thread, tạo rẻ đến mức không đáng tái sử dụng. ExecutorService giờ là cổng quản lý vòng đời và lan truyền lỗi cho một đám task, không còn là cơ chế giới hạn số thread — gốc rễ của nhiều lời khuyên ở các phần sau. Nó cài AutoCloseable: close() chặn tới khi mọi task đã submit chạy xong, nên vào block thì fan-out, ra khỏi block thì chắc chắn xong hết — cây cầu nối sang structured concurrency ở bài sau.
2. Vì sao virtual thread rẻ hơn platform thread nhiều bậc?
Lời khẳng định "virtual thread rẻ" nghe trừu tượng cho tới khi ta đặt hai thử nghiệm cạnh nhau. Cùng một kịch bản: mỗi task ngủ một giây để giả lập chờ I/O — đúng kiểu workload web server gặp hàng ngày, thread không tính toán gì, chỉ chờ.
Trước khi đọc tiếp, hãy đoán: trên một laptop bình thường, tạo 10.000 platform thread cùng ngủ một giây — chuyện gì xảy ra? Còn 1 triệu virtual thread cùng kịch bản? Viết câu trả lời ra giấy rồi mới đối chiếu.
// Experiment 1: 10,000 platform threads, each just waits 1 second
try (var pool = Executors.newThreadPerTaskExecutor(Thread.ofPlatform().factory())) {
IntStream.range(0, 10_000).forEach(i -> pool.submit(() -> {
Thread.sleep(Duration.ofSeconds(1)); // simulate blocking I/O
return i;
}));
}
// Experiment 2: identical body, but 1,000,000 tasks and the executor becomes
// Executors.newVirtualThreadPerTaskExecutor()
Thử nghiệm thứ nhất thường chết. Mỗi platform thread đòi OS cấp một vùng stack riêng — mặc định reserve cỡ 1MB trên hệ 64-bit, chỉnh qua -Xss — cộng một entry trong scheduler của kernel. Mười nghìn thread là cỡ chục gigabyte address space và mười nghìn slot kernel phải lập lịch; nhiều máy chết với OutOfMemoryError: unable to create native thread, hoặc đụng giới hạn thread per-process của OS. Toàn bộ chi phí ấy tiêu chỉ để... ngủ.
Thử nghiệm thứ hai chạy được trên một máy phát triển bình thường. Mỗi virtual thread khởi đầu chỉ là một object trên heap cỡ vài trăm byte; stack của nó không phải vùng nhớ OS cấp sẵn mà lớn dần theo độ sâu lời gọi thực tế, và sống trong heap — thứ GC quản lý như mọi object khác. Một triệu virtual thread đang ngủ là vài trăm megabyte heap, không chiếm OS thread nào ngoài nhóm carrier cỡ số core. Đừng đọc hai con số như benchmark chính xác (bộ nhớ thật phụ thuộc độ sâu stack, phiên bản JVM, hệ điều hành); điều cần mang theo là bậc độ lớn: platform thread đếm bằng nghìn thì hệ thống đã rên, virtual thread đếm bằng triệu vẫn là chuyện thường.
Chạy thử nghiệm thứ hai rồi đếm OS thread của process ở terminal khác: ps -o nlwp <pid> trên Linux, hoặc cột Threads trong Activity Monitor/Task Manager. Con số chỉ vài chục — nhóm carrier cộng vài thread nội bộ của JVM — trong khi chương trình đang "chạy" một triệu thread.
Vì sao? Nhắc lại mô hình ánh xạ từ bài Process và Thread: platform thread là one-to-one, mỗi Java thread ăn một OS thread riêng suốt đời; virtual thread gần với many-to-many. JVM giữ một nhóm nhỏ platform thread gọi là carrier thread — mặc định một ForkJoinPool riêng, số worker cỡ số CPU core — và multiplex rất nhiều virtual thread lên nhóm đó.
Cơ chế khoá ở hai động tác. Khi một virtual thread sẵn sàng chạy, JVM mount nó lên một carrier; carrier chạy code của nó như code bình thường, và OS lập lịch carrier như mọi platform thread. Mấu chốt là lúc virtual thread gặp một thao tác blocking đã được Loom trang bị lại — chờ socket, chờ kết quả query, Thread.sleep, chờ BlockingQueue. Thay vì chặn nguyên cái carrier, JVM unmount virtual thread: cuộn stack của nó cất vào heap (cấu trúc gọi là continuation), rồi trả carrier về nhóm cho một virtual thread khác mount lên. Khi thao tác blocking hoàn tất, virtual thread chờ tới lượt mount lên một carrier nào đó — không nhất thiết carrier cũ — và chạy tiếp từ đúng chỗ đã dừng.
sequenceDiagram
participant A as Virtual thread A
participant C1 as Carrier 1
participant IO as Tang I/O (socket, DB)
participant C2 as Carrier 2
A->>C1: mount - carrier chay code cua A
A->>IO: goi blocking I/O (doc socket)
Note over A,C1: JDK unmount A - cuon stack vao heap (continuation)
Note over C1: Carrier 1 ranh - mount virtual thread B khac
IO-->>A: I/O hoan tat - A san sang chay tiep
A->>C2: remount (co the la carrier khac)
Note over A,C2: chay tiep tu dung lenh da dungVí von cho dễ hình dung: carrier thread là nhân viên tổng đài, virtual thread là từng cuộc gọi. Mô hình cũ cấp cho mỗi cuộc gọi một nhân viên riêng — khách đặt máy đi pha cà phê, nhân viên vẫn phải áp tai vào ống nghe ngồi chờ, nên số nhân viên là cái trần. Mô hình virtual thread: hễ khách bảo "chờ tôi chút", nhân viên ghi chỗ đang dở vào một tờ giấy, đặt sang bên và bắt ngay cuộc khác; khách quay lại thì bất kỳ nhân viên rảnh nào cũng cầm tờ giấy lên tiếp tục.
| Tổng đài | Virtual thread |
|---|---|
| Nhân viên tổng đài | Carrier thread (nhóm nhỏ, cỡ số core) |
| Cuộc gọi của khách | Virtual thread (rất nhiều, rẻ) |
| Khách bảo "chờ tôi chút" | Thao tác blocking I/O |
| Tờ giấy ghi chỗ đang dở | Continuation - stack cuộn vào heap |
| Nhân viên bắt cuộc gọi khác | Carrier mount virtual thread khác |
| Bất kỳ nhân viên rảnh nào tiếp tục | Remount lên carrier bất kỳ |
Cái rẻ đến từ chính chỗ đó: virtual thread đang chờ I/O không giữ OS thread nào, và tạo/huỷ/chuyển ngữ cảnh nó đều diễn ra trong JVM ở user space chứ không lội xuống kernel.
Hệ quả thực dụng: lợi ích của virtual thread đến hoàn toàn từ việc unmount khi block. Một virtual thread chẳng bao giờ unmount thì chẳng khác gì chạy trên platform thread, chỉ tốn thêm một lớp gián tiếp — nó chỉ phát huy khi workload thật sự dành phần lớn thời gian để chờ. Và có những tình huống virtual thread muốn unmount mà không được — đó là pinning, chủ đề bài kế tiếp.
3. Liên hệ các bài khác
- Process và Thread — cái giá của một OS thread (stack, slot kernel, context switch) là toàn bộ lý do virtual thread tồn tại.
- Thread API và vòng đời — virtual thread vẫn là
Thread, nên toàn bộ API,ThreadLocal, interrupt và cooperative cancellation áp dụng nguyên vẹn. - Executor và thread pool — cái trần "kích thước pool" mà virtual thread phá;
newVirtualThreadPerTaskExecutorthay chỗ nhưng không còn là pool. - Fork/Join — chính
ForkJoinPoollà engine lập lịch nhóm carrier thread đứng sau virtual thread. - Virtual Threads: pinning, khi nào dùng và di cư — phần vận hành: khi nào phép màu unmount tắt (pinning), workload nào thật sự hợp, và cách di cư an toàn từ thread pool.
4. 📚 Deep Dive Oracle
Spec / reference chính thức:
- JEP 444: Virtual Threads — JEP final hoá virtual thread ở Java 21; phần "Goals" và "Description" giải thích trọn vẹn mô hình mount/unmount và các lựa chọn thiết kế.
Ghi chú: JEP 444 nên đọc trọn một lần — nó ngắn hơn tưởng tượng và trả lời gần hết các câu "vì sao thiết kế như vậy", từ chuyện vì sao không làm async/await tới vì sao giữ nguyên API Thread.
5. Tóm tắt
- Virtual thread vẫn là
java.lang.Thread— cùng API, cùng ngữ nghĩa; khác biệt ở scheduling: JVM multiplex nhiều virtual thread lên một nhóm nhỏ carrier (ForkJoinPoolcỡ số core). - Cơ chế lõi là mount/unmount: gặp blocking I/O, JDK cuộn stack vào heap (continuation), trả carrier cho virtual thread khác; I/O xong thì remount, có thể lên carrier khác.
- Rẻ vì stack sống trên heap, khởi đầu vài trăm byte — một triệu virtual thread đang chờ là khả thi, một triệu platform thread thì không.
- Lợi ích đến hoàn toàn từ unmount khi block: virtual thread không bao giờ block thì chỉ thêm một lớp gián tiếp so với platform thread.
newVirtualThreadPerTaskExecutorsinh một virtual thread mới mỗi task — không phải pool; nó là cổng vòng đời (AutoCloseable) cho một đám task.
Nhưng biết vì sao virtual thread rẻ mới là một nửa câu chuyện. Phần vận hành còn lại: khi nào phép màu unmount tắt (pinning ghim chết carrier), workload nào thật sự thắng, và làm sao di cư an toàn từ thread pool mà không đánh sập downstream — chủ đề bài kế tiếp.
6. Tự kiểm tra
Q1Vì sao một triệu virtual thread không cần một triệu OS thread?▸
Vì virtual thread không gắn cố định với OS thread. JVM giữ một nhóm nhỏ carrier thread (cỡ số core) và mount virtual thread lên đó chỉ khi nó thực sự chạy. Khi virtual thread gặp blocking I/O, JDK unmount nó: stack được cuộn vào heap dưới dạng continuation, carrier được trả lại để chạy virtual thread khác. Một virtual thread đang chờ vì thế không chiếm OS thread nào - nó chỉ là vài trăm byte tới vài KB trên heap. Một triệu virtual thread đang chờ I/O = một triệu object nhỏ trên heap + vài chục carrier, chứ không phải một triệu stack megabyte và một triệu slot scheduler kernel.
Q2Vì sao mô hình thread-per-request từng buộc lập trình viên phải viết callback hoặc CompletableFuture?▸
Vì mỗi java.lang.Thread chiếm trọn một OS thread suốt đời, mà OS thread tốn vài megabyte stack cộng một slot scheduler kernel - tạo vài nghìn cái là hệ thống bắt đầu rên. Pool thread không xoá cái trần đó, chỉ dời nó thành kích thước pool: khi cả 200 thread đều nằm chờ database, request thứ 201 phải xếp hàng dù CPU gần như rảnh. Để phục vụ nhiều kết nối đồng thời hơn số thread cho phép, lập trình viên buộc phải bỏ mô hình mỗi-request-một-thread mà chuyển sang callback hoặc CompletableFuture - viết code async chỉ để né cái trần OS thread, không phải vì bài toán tự nó cần async.
Q3newVirtualThreadPerTaskExecutor có phải là một thread pool không? Vì sao cách gọi tên lại quan trọng?▸
Không. Nó không giữ một tập thread chờ tái sử dụng; mỗi task submit nhận một virtual thread mới tinh, chạy xong thì thread chết hẳn. Với platform thread làm vậy là tự sát vì chi phí tạo thread, nhưng virtual thread tạo rẻ đến mức không đáng tái sử dụng. Cách gọi tên quan trọng vì nó gỡ trực giác cũ "thread thì phải pool": ExecutorService ở đây là cổng quản lý vòng đời và lan truyền lỗi cho một đám task (cài AutoCloseable, close() chặn tới khi mọi task xong), không còn là cơ chế giới hạn số thread. Hiểu sai điểm này chính là gốc của anti-pattern gom virtual thread vào một pool cố định.
Q4Mount và unmount là gì? Continuation đóng vai trò gì trong đó?▸
Mount là khi JVM gắn một virtual thread lên một carrier thread (platform thread trong nhóm nhỏ cỡ số core) để carrier chạy code của nó; OS chỉ thấy và lập lịch carrier. Unmount xảy ra khi virtual thread gặp một thao tác blocking đã được Loom trang bị lại (chờ socket, chờ query, Thread.sleep): thay vì chặn nguyên carrier, JVM cuộn stack của virtual thread cất vào heap - cấu trúc đó gọi là continuation - rồi trả carrier về nhóm cho virtual thread khác mount lên. Khi blocking xong, virtual thread remount lên một carrier bất kỳ và chạy tiếp từ đúng chỗ đã dừng, nhờ stack đã lưu trong continuation.
Q5Một virtual thread chạy tác vụ không bao giờ block thì có lợi gì so với chạy trên platform thread?▸
Gần như không lợi gì, chỉ thêm một lớp gián tiếp. Toàn bộ cái rẻ của virtual thread đến hoàn toàn từ việc unmount khi block: lúc chờ I/O nó không giữ OS thread nào, nên hàng triệu thread cùng chờ vẫn khả thi. Một virtual thread không bao giờ block thì không bao giờ unmount - nó gắn trên một carrier và chiếm carrier đó y như một platform thread chiếm OS thread, cộng thêm chi phí lập lịch của JVM. Vì vậy virtual thread chỉ thật sự phát huy khi tác vụ dành phần lớn thời gian để chờ thứ gì đó bên ngoài, chứ không phải khi nó tính toán liên tục.
Q6Vì sao tạo 10.000 platform thread cùng ngủ một giây thường chết, còn một triệu virtual thread cùng kịch bản thì chạy được trên máy thường?▸
Mỗi platform thread đòi OS cấp một vùng stack riêng - mặc định reserve cỡ 1MB trên hệ 64-bit - cộng một entry trong scheduler kernel; mười nghìn thread là cỡ chục gigabyte address space, nhiều máy chết với OutOfMemoryError: unable to create native thread hoặc đụng giới hạn thread per-process, và toàn bộ chi phí đó tiêu chỉ để ngủ. Mỗi virtual thread ngược lại chỉ là một object heap cỡ vài trăm byte; stack của nó lớn dần theo độ sâu lời gọi thực và sống trong heap do GC quản lý. Một triệu virtual thread đang ngủ là vài trăm megabyte heap và không chiếm OS thread nào ngoài nhóm carrier cỡ số core. Điểm cần mang theo là bậc độ lớn: platform thread đếm bằng nghìn đã rên, virtual thread đếm bằng triệu vẫn thường.
Bài tiếp theo: Virtual Threads: pinning, khi nào dùng và di cư thread pool
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
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