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.

Ví 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?
- 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?
- Q3newVirtualThreadPerTaskExecutor có phải là một thread pool không? Vì sao cách gọi tên lại quan trọng?
- Q4Mount và unmount là gì? Continuation đóng vai trò gì trong đó?
- 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?
- 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?
Bài tiếp theo: Virtual Threads: pinning, khi nào dùng và di cư thread pool
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
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