Virtual Threads: pinning, khi nào dùng và di cư thread pool
Pinning ghim carrier khi virtual thread không unmount được (synchronized trên Java 21, native frame); khi nào nên dùng virtual thread và di cư từ thread pool.
TL;DR: Virtual thread rẻ không có nghĩa dùng sao cũng thắng. Scheduler của nó là một ForkJoinPool FIFO của JVM không preempt theo lát thời gian: một virtual thread giữ carrier tới khi tự block hoặc xong, nên task CPU-bound chiếm carrier hệt platform thread. Khi virtual thread block mà không unmount được — pinning — carrier bị ghim chết: nguồn còn lại trên Java 25 là native frame; synchronized chỉ còn là vấn đề trên Java 21 (JEP 491 ở Java 24 đã gỡ). Lợi ích chỉ đến với workload I/O-bound. Di cư từ thread pool chỉ đổi một dòng tạo executor, nhưng phải thay cái trần pool cũ bằng Semaphore tường minh để không đánh sập downstream.
1. Lập lịch và pinning: khi phép màu tắt
Bài trước dựng cơ chế lõi: 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 thread khởi đầu chỉ tốn vài trăm byte. Toàn bộ cái rẻ đến từ việc unmount được khi block. Bài này mổ phần vận hành: khi nào unmount không xảy ra (pinning), workload nào hợp, và cách di cư an toàn.
Virtual thread rẻ không có nghĩa là dùng sao cũng thắng. Có những lúc phép màu unmount tắt lịm: một service chạy mượt trên hàng nghìn virtual thread bỗng throughput sụp và độ trễ tăng vọt dưới tải, trong khi CPU vẫn gần như nhàn rỗi — nghịch lý kinh điển của carrier bị ghim. Ba câu hỏi cần trả lời: ai lập lịch virtual thread, khi nào nó ghim chết carrier, và workload nào thật sự hợp.
Ai lập lịch virtual thread? Không phải OS — OS chỉ thấy các carrier. Scheduler là chính JVM: một ForkJoinPool chuyên dụng chạy chế độ FIFO (khác common pool LIFO + work-stealing của bài Fork/Join), số worker mặc định bằng số core, chỉnh qua system property jdk.virtualThreadScheduler.parallelism.
Nó khác kernel ở một điểm nền tảng: không preempt theo lát thời gian. OS cắt ngang một platform thread sau mỗi time slice vài millisecond, bất kể thread đó muốn hay không; JVM thì không — một virtual thread đã mount giữ carrier cho tới khi tự nó nhả ra, bằng cách gặp thao tác blocking (unmount), kết thúc, hoặc gọi Thread.yield() tường minh. Không có chuông báo hết giờ. Hãy nhìn một virtual thread "tham ăn":
// Never blocks, so it never unmounts: holds one carrier hostage until the loop ends
Thread.startVirtualThread(() -> {
long sum = 0;
for (long i = 0; i < 5_000_000_000L; i++) sum += i; // pure CPU work
System.out.println(sum);
});
Task CPU-bound thuần chiếm trọn một carrier từ đầu đến cuối. Nếu số task như vậy bằng hoặc vượt số carrier, mọi virtual thread khác phải xếp hàng, kể cả request I/O-bound chỉ cần vài millisecond CPU — triệu chứng ở production là latency tăng vọt khó hiểu khi ai đó "tiện tay" ném một job tính toán nặng vào executor virtual thread của tầng request.
Có một van an toàn tên compensation: khi virtual thread bị pin hoặc block qua ManagedBlocker (bài Fork/Join), scheduler tạm thêm carrier để giữ độ song song. Nhưng mỗi carrier bù vẫn là một OS thread thật — van an toàn, không phải giấy phép pinning.
Toàn bộ phép màu phụ thuộc vào việc unmount được khi block. Khi không thể, virtual thread ghim — pin — chặt carrier: nó block, carrier kẹt theo, không phục vụ được ai. Một carrier bị ghim đúng bằng việc đánh mất một OS thread vào chỗ chờ — chính cái giá ta dùng virtual thread để né.

Tới Java 25, nguồn gây pinning đáng kể còn lại là native frame: khi ngăn xếp lời gọi đang đi qua một native method (JNI, một số đường foreign function), JVM không cuộn được stack ấy vào heap nên không unmount được; block ngay tại đó thì carrier bị ghim suốt thời gian chờ.
Đáng nói là synchronized. Trên Java 21, block bên trong khối/method synchronized cũng ghim carrier — một bẫy lớn vì synchronized có khắp nơi. Java 24 (JEP 491) đã sửa, baseline Java 25 thừa hưởng. Dù vậy ReentrantLock vẫn tốt hơn cho khoá giữ qua I/O dài, độc lập với pinning — nó cho tryLock có timeout:
// On Java 21: synchronized around this blocking call pins the carrier.
// ReentrantLock is safe on every runtime, and clearer about intent.
public Quote refresh(String symbol) {
lock.lock(); // ReentrantLock, not synchronized
try {
return remoteApi.fetch(symbol); // long I/O while holding the lock
} finally {
lock.unlock();
}
}
Tốt hơn nữa là đừng ôm khoá qua lời gọi I/O dài chút nào — đúng nguyên tắc Atomicity: giữ khoá đủ lâu để bao compound action trên shared state rồi nhả trước khi làm network. Khoá để bảo vệ shared mutable state, không phải để serialize lời gọi mạng.
Chẩn đoán pinning không phải đoán mò: JDK Flight Recorder phát event jdk.VirtualThreadPinned mỗi khi một virtual thread bị ghim quá ngưỡng thời gian — soi event này là cách chắc chắn nhất để tìm thủ phạm. (Cờ -Djdk.tracePinnedThreads của Java 21 đã bị gỡ ở Java 24, nên baseline 25 dựa vào JFR.)
2. Khi nào nên dùng virtual thread, khi nào không
Một lưu ý về ThreadLocal trước khi bàn ranh giới dùng. Virtual thread vẫn là Thread nên ThreadLocal (Confinement) giữ đúng ngữ nghĩa, nhưng chi phí đổi: nó tốn bộ nhớ tỉ lệ số thread, nên nhân một triệu virtual thread, một buffer vài KB mỗi thread thành vài GB heap — triệu chứng là áp lực GC tăng bất thường. Đó mới là triệu chứng; vì sao ThreadLocal vỡ và vì sao ScopedValue là lời giải nằm trọn ở bài ScopedValue ở quy mô virtual thread.
Ranh giới sử dụng, sau khi đã thấy cơ chế, trở nên rõ ràng thay vì cảm tính. Hợp nhất là task dành phần lớn thời gian chờ thứ gì đó bên ngoài (web server nhiều request, service gọi database, fan-out tới nhiều remote API) — số task đồng thời giờ bị chặn bởi bộ nhớ và tài nguyên downstream, không còn bởi số OS thread. Ngược lại, task CPU-bound thuần gần như không bao giờ unmount, chiếm carrier hệt platform thread, nên virtual thread không cho thêm gì.
| Workload | Công cụ đúng | Vì sao |
|---|---|---|
| Nhiều request đồng thời, mỗi cái chờ DB/API là chính | Virtual thread, mỗi task một thread | Lợi ích đến từ unmount khi chờ; số task không còn bị trần OS thread chặn |
| Fan-out gọi N service rồi gộp kết quả | Virtual thread (+ structured concurrency, bài sau) | Hàng nghìn nhánh chờ song song, gần như không tốn tài nguyên |
| Tính toán nặng dạng chia để trị | Fork/Join | Work-stealing cân tải CPU; virtual thread không thêm core nào |
| Tính toán nặng, task độc lập | Pool platform thread cỡ số core | Giới hạn thật là CPU; nhiều thread hơn core chỉ thêm context switch |
| Giới hạn lời gọi tới downstream mong manh | Semaphore tường minh trước lời gọi | Van giới hạn tách khỏi cơ chế chạy task - không mượn kích thước pool |
Ba nhầm lẫn đọng lại. Nhầm 1: pool virtual thread, theo trực giác cũ "thread thì phải pool":
// WRONG: pooling virtual threads rebuilds the ceiling they were meant to remove
ExecutorService pool = Executors.newFixedThreadPool(200, Thread.ofVirtual().factory());
Virtual thread không đắt cũng không khan, nên gom vào pool cố định là tự dựng lại đúng cái trần chúng sinh ra để phá; nếu con số 200 thật ra để giới hạn tài nguyên, hãy nói thẳng bằng Semaphore(200) trước lời gọi đó. Nhầm 2: synchronized ôm I/O trên JDK 21, ghim carrier như mục 1 đã mổ; đổi sang ReentrantLock hoặc thu hẹp khoá. Nhầm 3: né Thread.sleep và blocking call vì sợ "tốn thread" — phản xạ từ thời platform thread, nhưng blocking trong virtual thread là rẻ vì các thao tác chuẩn của JDK đã được trang bị lại để unmount; viết code tuần tự, chặn thoải mái, chính là phong cách virtual thread sinh ra để phục vụ.
3. Di cư từ thread pool: bỏ trần rồi thay bằng gì?
Trên giấy, việc di cư nhỏ đến mức đáng ngờ. Một service chạy task lên pool platform thread kiểu bài Executor và thread pool chỉ cần đổi nơi tạo executor:
ExecutorService pool = Executors.newFixedThreadPool(200); // before: pool size is the ceiling
ExecutorService pool = Executors.newVirtualThreadPerTaskExecutor(); // after: no such ceiling
Code task bên trong không đổi: vẫn submit một Runnable, vẫn nhận Future, vẫn viết tuần tự gặp I/O thì chặn. Nhưng một dòng đó kéo theo đổi tư duy sizing, và đây mới là phần dễ sai. Với pool cũ, một con số duy nhất vừa lấp đầy CPU vừa vô tình chặn luôn số lời gọi downstream đồng thời. Với virtual thread, câu hỏi "chọn số thread" biến mất và tách làm hai: bao nhiêu task được chạy đồng thời (thường là "bao nhiêu cũng được"), và bao nhiêu lời gọi đồng thời thì downstream chịu nổi? Câu thứ hai phải trả lời tường minh bằng Semaphore hoặc connection pool ngay tại tài nguyên đó, chứ không còn được cái trần pool trả lời hộ.
Đây là cái bẫy di cư phổ biến nhất, và nó âm thầm. Pool 200 thread cũ vẫn lặng lẽ bảo vệ database bằng cách không cho quá 200 query bay đi cùng lúc. Bỏ lớp bảo vệ ngầm đó, một đợt tải đột biến mười nghìn request có thể phóng mười nghìn query đồng thời và đánh sập đúng cái database ta định phục vụ. Virtual thread dời nút thắt cổ chai xuống downstream chứ không xoá nó; di cư an toàn nghĩa là đặt một cái van tường minh ngay tại nút thắt mới.
Trong capstone TicketFlow, đây là bước nhảy lên v4: executor xử lý request đổi sang newVirtualThreadPerTaskExecutor để mỗi request đặt vé chạy trên virtual thread riêng và thoải mái chặn ở bước kiểm tồn kho hay thanh toán, còn Semaphore canh giới hạn tới database.
Còn một câu hỏi vận hành: làm sao nhìn thấy hàng trăm nghìn virtual thread khi debug? jstack không trả lời được — nó chỉ liệt kê platform thread (vài chục carrier), còn virtual thread thì vô hình; một service "kẹt" mà jstack trông sạch sẽ là dấu hiệu kinh điển của việc nhìn nhầm tầng. JDK cung cấp một thread dump mới qua jcmd liệt kê từng virtual thread cùng stack trace:
# jstack only shows platform threads (the carriers)
jcmd <pid> Thread.dump_to_file -format=json /tmp/threads.json
File JSON đủ để trả lời "mười nghìn request đang kẹt ở đâu" (thường là cùng một frame chờ một downstream chậm). Quy trình thực dụng: bật JFR mặc định trong production, khi latency bất thường thì soi jdk.VirtualThreadPinned trước, rồi mới tới thread dump JSON.
4. Liên hệ các bài khác
- Virtual Threads: vì sao rẻ hơn platform thread nhiều bậc — cơ chế mount/unmount và carrier thread mà toàn bộ bài này dựa vào; pinning chỉ có nghĩa khi đã hiểu unmount.
- Confinement — ngữ nghĩa
ThreadLocalmà mục 2 dựa vào; hiểu confinement theo thread rồi mới thấy vì sao nó đổ vỡ về chi phí ở quy mô triệu thread. - Atomicity — nguyên tắc giữ khoá đủ ngắn để bao compound action rồi nhả trước I/O, gốc của lời khuyên tránh ôm khoá qua lời gọi mạng ở mục 1.
- Executor và thread pool — tư duy pool + sizing mà mục 3 thay thế; cancellation qua
Future.cancelvẫn dùng y nguyên trên executor virtual thread. - Fork/Join — nơi đúng cho CPU-bound;
ManagedBlockerlà tiền thân của cơ chế compensation ở mục 1. - Structured Concurrency & ScopedValue — bài kế tiếp: khi thread đã rẻ và fan-out hàng nghìn task thành chuyện thường, cần một khung kỷ luật vòng đời cho cả nhóm; và bài 22b đưa
ScopedValuethayThreadLocalở đúng quy mô này.
5. 📚 Deep Dive Oracle
Spec / reference chính thức:
- JEP 491: Synchronize Virtual Threads without Pinning — Java 24; mô tả cách JVM làm lại monitor để
synchronizedthôi ghim carrier. - Java Core Libraries Guide — Virtual Threads — hướng dẫn chính thức của Oracle: best practice, ví dụ migration, và mục troubleshooting pinning.
Ghi chú: mục troubleshooting của guide Oracle liệt kê đúng các nguồn pinning và cách bắt bằng JFR — đọc kèm JEP 491 để thấy vì sao synchronized từng ghim và Java 24 sửa ra sao.
6. Tóm tắt
- Scheduler virtual thread là một
ForkJoinPoolFIFO không preempt theo lát thời gian: virtual thread giữ carrier tới khi tự block hoặc xong, nên task CPU-bound thuộc về pool cỡ số core hoặc Fork/Join. - Pinning là block mà không unmount được, ghim chết carrier: nguồn còn lại trên Java 25 là native frame;
synchronizedchỉ còn là vấn đề trên Java 21 (JEP 491 ở Java 24 đã gỡ). Săn bằng JFR eventjdk.VirtualThreadPinned; xem virtual thread bằngjcmd,jstackkhông thấy chúng. - Chỉ thắng với workload I/O-bound; đừng pool virtual thread — giới hạn tài nguyên downstream bằng
Semaphoretường minh. ThreadLocalvẫn đúng ngữ nghĩa nhưng chi phí nhân theo số thread; triệu chứng là áp lực GC, lời giảiScopedValuethuộc bài 22b.
Nhưng newVirtualThreadPerTaskExecutor mới chỉ cho ta virtual thread rẻ và một ranh giới close(). Nó chưa nói gì về kỷ luật vòng đời của một nhóm task: subtask hỏng thì có huỷ ngay các subtask anh em không, hay để chúng rò ra ngoài? Đó là structured concurrency — chủ đề bài kế tiếp.
7. Tự kiểm tra
- Q1Pinning là gì, và nó còn là vấn đề ở JDK nào?
- Q2Vì sao không nên pool virtual thread?
- Q3Task CPU-bound chạy trên virtual thread có nhanh hơn không? Vì sao?
- Q4Đổi newFixedThreadPool(200) sang newVirtualThreadPerTaskExecutor() - một dòng code. Rủi ro ngầm nào xuất hiện?
- Q5Gọi
Thread.sleeptrong virtual thread có ghim carrier không? Điều này nói gì về phong cách code nên viết?
Bài tiếp theo: Structured Concurrency & ScopedValue — lifecycle có kỷ luật cho nhóm task
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