Java Virtual Threads: Khi Concurrency trở nên dễ thở hơn
Concurrency trong Java từ lâu đã là một sự đánh đổi khá “đau đớn”: hoặc bạn chọn Platform Threads — dễ viết nhưng tốn nhiều RAM và khó scale, hoặc chọn Reactive programing — mạnh mẽ nhưng sẽ là thảm hoạ khi bạn cố gắng đọc hiểu code hoặc debug chúng.
Nếu thấy hay, kết nối với mình tại LinkedIn.
Virtual Threads được tạo ra để chấm dứt sự đau đầu đó. Nó cho phép bạn xử lý hàng triệu request với cách viết code synchronous đơn giản nhất. Hãy cùng đi từ mô hình thread truyền thống đến sức mạnh thực sự của virtual threads để xem tại sao đây lại là một bước ngoặt lớn của Java (xem có lấy lại chút thị phần từ node được không nhé anh em). Bắt đầu nhé


Nếu anh em muốn ôn lại một chút về kiến thức Process và Thread, cách CPU hoạt động trên máy tính, xem qua loạt bài về hệ điều hành của mình nhé, bao chi tiết và chất lượng cho anh em
.
Hệ điều hành (Phần 1): Tìm hiểu về Process, Thread, Multi-Process và Multi-Thread
Hệ điều hành (Phần 2): Tìm hiểu về Multitasking, Scheduler, Shared Memory và CPU Caches
Platform Thread
Mechanism
Trước khi nói về Virtual Threads, mình cần hiểu Java đã xử lý thread như thế nào cho đến trước khi nó xuất hiện ở phiên bản LTS (Java 21).
Trong Java, mỗi thread bạn tạo (thông qua java.lang.Thread) gần như được ánh xạ 1-1 với một thread của hệ điều hành (OS thread). Những thread này thường được gọi là platform threads. Nghe có vẻ đơn giản, nhưng chi phí của nó không hề nhỏ.
Tại sao platform threads được gọi là “heavyweight”?
Mỗi platform thread không chỉ là một khái niệm trong JVM — nó gắn chặt với tài nguyên của hệ điều hành. Khi bạn tạo một thread, hệ điều hành sẽ cấp phát một vùng nhớ riêng gọi là thread stack. Đây là nơi lưu toàn bộ trạng thái thực thi (call stack) của thread.
Bên trong stack là các **stack frame **— mỗi frame tương ứng với một lần gọi hàm. Mỗi frame sẽ chứa:
- Return address
- Local variables
- Parameters
- Dữ liệu trung gian dùng trong quá trình thực thi
Stack hoạt động theo nguyên tắc quen thuộc LIFO (Last In, First Out).
- Gọi hàm → push frame vào stack
- Kết thúc hàm → pop frame ra khỏi stack
Để theo dõi vị trí hiện tại của stack, CPU sử dụng một thanh ghi đặc biệt gọi là stack pointer. Con trỏ này luôn trỏ đến đỉnh của stack và sẽ di chuyển lên hoặc xuống tương ứng với các thao tác push/pop trong quá trình thực thi.
Vấn đề nằm ở đâu?
Điểm quan trọng là: kích thước stack của mỗi thread được quyết định ngay khi thread được tạo (ví dụ thông qua tham số JVM -Xss). Dù một số hệ điều hành có thể cấp phát stack theo kiểu “dùng đến đâu cấp đến đó” (lazy allocation), mỗi thread vẫn phải reserve trước một vùng stack tối đa.
Điều này dẫn đến hai hệ quả lớn:
- Mỗi thread tiêu tốn một lượng bộ nhớ đáng kể
- Thread được OS scheduler quản lý trực tiếp → chi phí context-switch cao
Đây là một trong những nguyên nhân khiến mô hình thread truyền thống gặp khó khăn khi cần scale lên hàng chục hoặc hàng trăm nghìn concurent tasks.

Vấn đề với threads — khi mọi thứ bắt đầu quá tải
Cơ chế thread stack nghe có vẻ gọn gàng, nhưng thực tế nó đã ẩn đi khá nhiều vấn đề.
StackOverflowError – lỗi quen thuộc
Một hệ quả trực tiếp là: nếu chương trình dùng đệ quy mà không có điểm dừng hợp lý, hoặc có quá nhiều lời gọi hàm lồng nhau, số lượng stack frame sẽ tăng liên tục. Cuối cùng, khi vượt quá giới hạn stack của thread, JVM sẽ ném ra:
StackOverflowError
Điều quan trọng cần hiểu là lỗi này chỉ xảy ra trên một thread cụ thể, không phải do tổng số thread trong hệ thống. Nó đơn giản có nghĩa là thread đó đã hết chỗ để chứa thêm lời gọi hàm.
OutOfMemoryError – khi tạo quá nhiều thread
Ở chiều ngược lại, vấn đề không còn nằm trong một thread, mà là số lượng thread. Mỗi thread cần một vùng stack riêng, mặc định thường khoảng vài trăm KB đến 1 MB (tùy JVM và OS). Khi bạn tạo hàng nghìn hoặc hàng chục nghìn thread, tổng bộ nhớ cần dùng tăng rất nhanh.
Đến một lúc nào đó, hệ thống không đủ bộ nhớ để cấp phát thêm thread mới, và bạn sẽ gặp lỗi:
OutOfMemoryError: unable to create new native thread
Điều này nhắc chúng ta một điều rất thực tế: thread không miễn phí, và luôn phải được kiểm soát cẩn thận.
Thread-per-request – mô hình phổ biến nhưng có giới hạn
Do cách hoạt động của platform threads, các server truyền thống thường dùng mô hình: một request = một thread. Điều này rất trực quan, dễ code, dễ debug, và hoạt động tốt ở quy mô vừa (vài trăm đến vài nghìn request đồng thời).
Nhưng khi scale lên hàng trăm nghìn hoặc hàng triệu request cùng lúc, vấn đề lộ rõ:
- Không đủ bộ nhớ để giữ từng đó thread
- Context switching giữa các thread trở nên cực kỳ tốn kém
Lúc đó, hệ thống bắt đầu chậm lại, rồi cuối cùng bị quá tải.
Reactive – một giải pháp không dễ “nuốt”
Để vượt qua những giới hạn đó, một hướng phổ biến là Reactive Programming. Thay vì “giữ” một thread suốt vòng đời của request, hệ thống sẽ:
- Dùng non-blocking I/O
- Nhả thread ra khi đang chờ (ví dụ: chờ database hoặc API)
- Tiếp tục xử lý khi dữ liệu sẵn sàng (event-driven)

Nhờ đó, một số lượng nhỏ thread có thể xử lý rất nhiều request cùng lúc. Nghe rất hay, nhưng cái giá là độ phức tạp. Bạn không còn viết code theo kiểu tuần tự (synchronous) nữa — bạn phải chuyển sang tư duy bất đồng bộ:
- Code khó đọc hơn (callback, chain, reactive stream…)
- Debug khó hơn (flow không còn tuyến tính)
- Maintenance trở nên khó khăn nếu design không chặt
Và đây chính là điểm mà nhiều team gặp khó khi áp dụng reactive trong hệ thống lớn.
Virtual threads
Sau tất cả những giới hạn của platform threads, Virtual Threads xuất hiện như một cách tiếp cận hoàn toàn khác. Ở level API, mọi thứ vẫn quen thuộc: bạn vẫn dùng java.lang.Thread. Nhưng bên dưới, mô hình vận hành đã thay đổi hoàn toàn.
Không còn “một thread = một OS thread”
Khác biệt lớn nhất là: một virtual thread không còn bị gắn cố định với một OS thread trong suốt vòng đời của nó. Thay vào đó, JVM đóng vai trò như một scheduler thông minh. Khi một virtual thread chạy, nó được tạm thời gán vào một OS thread (gọi là carrier thread).
Nhưng khi virtual thread gặp một thao tác blocking (ví dụ: gọi database, đọc file, gọi API…), JVM có thể:
- Pause virtual thread đó
- Giải phóng OS thread
- Dùng OS thread đó chạy virtual thread khác
Khi dữ liệu sẵn sàng, virtual thread ban đầu sẽ được resume trên một OS thread (có thể không phải thread cũ).

Ít thread thật hơn, nhiều việc hơn
Với cơ chế này, bạn không còn cần:
- 10,000 OS threads để xử lý 10,000 request
- Chỉ cần một số nhỏ OS threads để xử lý rất nhiều virtual threads
Nói cách khác, JVM đang làm multiplexing: nhiều virtual threads → ít OS threads
Rẻ đến mức dùng thoải mái
Vì không cần native stack cố định như platform thread, virtual thread có chi phí tạo cực thấp. Bạn có thể tạo hàng trăm nghìn hoặc hàng triệu virtual threads mà vẫn trong giới hạn tài nguyên chấp nhận được — điều gần như không thể với platform threads.
Điều này đặc biệt hữu ích cho các hệ thống có:
- Nhiều I/O
- Nhiều thời gian chờ (waiting time lớn hơn CPU time)
Giống với virtual memory
Nếu khái niệm này nghe hơi “magic”, hãy nghĩ như sau: Virtual Threads giống với cách virtual memory hoạt động. Thay vì bắt chương trình làm việc trực tiếp với tài nguyên vật lý bị giới hạn (RAM hoặc OS threads), JVM tạo ra một lớp abstraction:
- Ẩn đi giới hạn thật
- Phân phối tài nguyên linh hoạt hơn
- Tận dụng tối đa những gì có
Kết quả là bạn có cảm giác tài nguyên gần như vô hạn, trong khi bên dưới vẫn được tối ưu rất kỹ.
Virtual Thread hoạt động như thế nào ?
Một virtual thread không phải là một “thread thật” độc lập, mà được đưa vào cơ chế scheduling nội bộ của JVM.
Bạn có thể hình dung đơn giản: JVM giữ một hàng đợi các virtual threads sẵn sàng chạy. Khi một OS thread (carrier thread) rảnh, JVM sẽ mount một virtual thread lên để thực thi.
Mount / Unmount
Trong quá trình chạy, virtual thread liên tục đi qua hai trạng thái:
- Mount: gắn vào carrier thread để chạy
- Unmount: tách ra khi gặp điểm phải chờ (blocking I/O, sleep, …)
Điểm thú vị là: khi virtual thread bị unmount, carrier thread không bị giữ lại để chờ. Thay vào đó, OS thread đó ngay lập tức được trả lại cho JVM để chạy virtual thread khác. Virtual thread ban đầu chỉ đơn giản là chờ đến khi sẵn sàng rồi mount lại sau.
Sử dụng tài nguyên hiệu quả
Nhờ cơ chế này, một số nhỏ OS threads có thể xoay vòng để phục vụ rất nhiều virtual threads. So với thread-per-request:
- Không có thread nào bị giữ chỉ để chờ I/O
- CPU được sử dụng liên tục và hiệu quả hơn
- Số lượng OS threads cần thiết giảm mạnh
Nói cách khác, hệ thống không còn lãng phí tài nguyên chỉ vì phải chờ.
Code vẫn sync, runtime rất async
Một điểm cực kỳ giá trị là: developer không cần thay đổi cách viết code. Bạn vẫn có thể viết:
- Gọi hàm tuần tự
- Dùng blocking I/O bình thường
Nhưng bên dưới, JVM âm thầm:
- Pause thread khi cần
- Chuyển sang task khác
- Quay lại đúng chỗ để tiếp tục
Tức là: cảm giác là synchronous, nhưng performance gần như async.
Một hiểu lầm phổ biến là mỗi virtual thread sẽ được gán vào OS thread “ít bận nhất”. Thực tế không phải vậy. JVM không cố định mapping này. Thay vào đó, nó liên tục:
- Schedule
- Pause
- Resume virtual threads
dựa trên trạng thái thực thi của từng thread. Chính sự linh hoạt này giúp virtual threads hoạt động tốt trong các hệ thống có:
- Nhiều I/O
- Nhiều request đồng thời
- Thời gian chờ chiếm phần lớn workload
Virtual thread context switching nhẹ hơn
Khi nghe “context switching”, nhiều người nghĩ đơn giản là đổi thread đang chạy. Nhưng với OS threads, câu chuyện nặng hơn nhiều.
OS thread context switch – chi phí ở đâu?
Với thread truyền thống, mỗi lần context switch yêu cầu OS chuyển vào kernel space. Không chỉ là dừng thread A và chạy thread B — mà còn:
- Lưu toàn bộ trạng thái CPU của thread hiện tại
- Khôi phục trạng thái của thread tiếp theo
- Cập nhật cấu trúc quản lý của kernel
- Ảnh hưởng đến CPU cache (cache bị “cold”)
Nếu xảy ra liên tục, chi phí tích lũy rất lớn và kéo giảm throughput hệ thống.
Virtual threads – đưa công việc vào JVM
Virtual threads hoạt động khác. Thay vì đẩy gánh nặng context switch xuống kernel, JVM xử lý phần lớn logic này ở user space. Khi virtual thread gặp blocking (I/O, sleep,…), JVM sẽ:
- “Freeze” (park) virtual thread
- Lưu trạng thái cần thiết vào memory của JVM
- Unmount khỏi carrier thread
Quan trọng nhất: tất cả diễn ra mà không cần kernel can thiệp. Carrier thread được tái sử dụng ngay để chạy virtual thread khác. Điều đó làm giảm đáng kể tính trạng “một thread đang chờ nhưng vẫn chiếm tài nguyên”, vốn là điểm yếu lớn của mô hình thread truyền thống.
Không cần “vác cả cỗ máy” như OS thread
OS thread luôn đi kèm native stack cố định và trạng thái gắn với kernel. Ngược lại, virtual thread chỉ giữ những gì cần thiết để resume.
Nói đơn giản:
- OS thread = mang cả cỗ máy
- Virtual thread = chỉ mang phần cần để chạy tiếp
Vì vậy, pause/resume virtual thread nhẹ hơn rất nhiều. Một điều cần hiểu đúng: virtual threads không loại bỏ context switching. Chúng chỉ:
- Giảm số lần cần switching ở kernel level
- Chuyển phần lớn scheduling vào JVM
- Tối ưu cho pattern
run → wait → run again
Đây là khác biệt rất lớn: thread đang chờ không còn giữ chặc một OS thread trong lúc chờ, nên cùng một số carrier thread nhỏ hơn vẫn có thể phục vụ rất nhiều virtual threads.
Trong hệ thống I/O-heavy, đây là khác biệt rất lớn. Bạn có thể hình dung:
- OS thread: mỗi lần đổi task phải “xin phép OS”, làm đủ thủ tục → tốn kém
- Virtual thread: JVM tự điều phối nội bộ, OS thread chỉ là worker tạm → nhanh và linh hoạt
Virtual threads không “magic hơn”, mà vì nó giảm đáng kể số lần phải trả cái giá đắt đỏ không cần thiết của context switching ở tầng kernel.
Examples
Platform Thread
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
public class PlatformThreadExample {
private final HttpClient client = HttpClient.newHttpClient();
public String getCombinedData() throws Exception {
String user = call("https://api.example.com/user/1");
String orders = call("https://api.example.com/orders/1");
return user + " | " + orders;
}
private String call(String url) throws Exception {
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(url))
.GET()
.build();
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
return response.body();
}
public static void main(String[] args) throws Exception {
PlatformThreadExample app = new PlatformThreadExample();
System.out.println(app.getCombinedData());
}
}
Ở đây, client.send(…) là blocking call. Thread đang chạy sẽ bị giữ lại cho tới khi HTTP response về, nên nếu có rất nhiều request đồng thời, bạn sẽ cần rất nhiều platform threads
Reactive programming
Ví dụ dưới đây dùng Spring WebFlux / Reactor. Mục tiêu là không block thread trong lúc chơ I/O, mà chain các bước xử lý bằng Mono.
import org.springframework.web.reactive.function.client.WebClient;
import reactor.core.publisher.Mono;
public class ReactiveExample {
private final WebClient webClient = WebClient.create();
public Mono<String> getCombinedData() {
Mono<String> userMono = webClient.get()
.uri("https://api.example.com/user/1")
.retrieve()
.bodyToMono(String.class);
Mono<String> ordersMono = webClient.get()
.uri("https://api.example.com/orders/1")
.retrieve()
.bodyToMono(String.class);
return userMono.zipWith(ordersMono, (user, orders) -> user + " | " + orders);
}
public static void main(String[] args) {
ReactiveExample app = new ReactiveExample();
app.getCombinedData()
.subscribe(System.out::println);
try {
Thread.sleep(3000);
} catch (InterruptedException ignored) {
}
}
}
Ở đây, Mono không đại diện cho giá trị có sẵn ngay lập tức mà đại diện cho một pipeline sẽ hoàn thành sau. Code không chặn thread trong lúc chờ HTTP response, và reactive framework sẽ điều phối việc tiếp tục xử lý khi dữ liệu sẵn sàng.
Virtual Thread
Ví dụ này giữ nguyên phong cách viết động bộ như platform thread, nhưng chạy trên virtual thread.
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public class VirtualThreadExample {
private final HttpClient client = HttpClient.newHttpClient();
public String getCombinedData() throws Exception {
String user = call("https://api.example.com/user/1");
String orders = call("https://api.example.com/orders/1");
return user + " | " + orders;
}
private String call(String url) throws Exception {
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(url))
.GET()
.build();
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
return response.body();
}
public static void main(String[] args) {
VirtualThreadExample app = new VirtualThreadExample();
try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
executor.submit(() -> {
try {
System.out.println(app.getCombinedData());
} catch (Exception e) {
throw new RuntimeException(e);
}
});
}
}
}
Điểm quan trọng là logic business gần như không đổi so với platform thread, nhưng thay vì bị “kẹt” một OS thread trong lúc chờ I/O, virtual thread có thể được JVM tạm park để carrỉer thread chạy việc khác.
Lưu ý và các Best practices
Dù virtual threads rất mạnh, vẫn có một số điểm quan trọng cần hiểu đúng để tránh dùng sai.
Nó vẫn là Thread nhưng đừng đối xử nó như cách cũ
Virtual thread vẫn là Thread, bạn vẫn dùng start(), join() như bình thường. Nhưng điều đó không có nghĩa nó behave như platform thread. Những API cũ như stop() hay suspend() (đã deprecated vì nguy hiểm) càng nên tránh hơn với virtual threads.
Các vấn đề về concurrency giữa các thread không hề bị mất đi
Virtual threads không thay đổi bản chất của concurrent programming. Các vấn đề quen thuộc vẫn còn:
- Race condition
- Deadlock
- Visibility
- Atomicity
Nói cách khác: virtual threads giúp bạn chạy nhiều việc hơn, nhưng không làm code tự đúng. Bạn vẫn phải:
- Dùng lock khi cần
- Đảm bảo safe publication
- Thiết kế shared state cẩn thận
JVM sẽ không “đợi” virtual threads
Về mặt vòng đời ứng dụng, virtual threads không đóng vai trò như non-deamon threads theo cách platform threads thường làm. Điều đó có nghĩa là JVM không giữ chương trình sống chỉ vì còn virtual threads đang chạy. Vì vậy, nếu cần đảm bảo một tác vụ quan trọng phải hoàn tất trước khi ứng dụng kết thúc, bạn vẫn phải quản lý vòng đời của nó một cách rõ ràng, thay vì mặt định trông chờ vào việc thread sẽ giữ JVM alive.
Vì vậy với các task quan trọng (ghi dữ liệu, xử lý transaction, gửi event…), bạn cần:
- Join rõ ràng
- Hoặc quản lý lifecycle (executor, structured concurrency, …)
Pinning – khi virtual thread bị “dính” vào OS thread
Trong một số trường hợp, virtual thread không thể unmount khỏi carrier thread — gọi là pinned. Ví dụ:
- Chạy trong synchronized rồi gặp blocking
- Gọi native method hoặc foreign function
Khi bị pin, carrier thread bị giữ lại và không thể tái sử dụng, làm giảm khả năng scale. Nói đơn giản: bạn quay lại gần mô hình thread cũ mà không nhận ra.
Vì vậy nếu code dùng nhiều synchronized, hãy xem lại: Có thật sự cần lock không? Có đang giữ lock khi gọi I/O không?
Một số hướng cải thiện:
- Dùng
ReentrantLock(linh hoạt hơn) - Tránh giữ lock khi chờ (I/O, sleep, …)
- Thiết kế lại để giảm shared state
Không phải cấm synchronized, nhưng đừng để virtual thread bị giữ chặt khi đang chờ.
Đừng pooling các virtual threads
Một nguyên tắc quan trọng: không pool virtual threads. Đây là thói quen rất dễ mang từ thread truyền thống sang — và nó sai.
Pooling hợp lý khi thread là tài nguyên đắt đỏ, nhưng virtual threads không phải là tài nguyên đắt đỏ theo cách đó. Mô hình phù hợp hơn là tạo một virtual thread cho mỗi task, rồi để JVM tự điều phối việc chạy của chúng. Nói cách khác, nếu task của bạn là đơn vị công việc, thì virtual thread nên được xem như một “abstraction” cho task đó, không phải như một worker quý hiểm cần phải giữ lại để tái sử dụng.
Cẩn thận khi dùng ThreadLocal
Với platform threads, ThreadLocal khá tiện. Nhưng với virtual threads, số lượng thread có thể rất lớn, nếu lạm dụng ThreadLocal bạn có thể làm tăng memory nhanh và khiến code khó kiểm soát.
Nếu mục tiêu là giới hạn truy cập tài nguyên hữu hạn (ví dụ DB connection), semaphore thường rõ ràng hơn.
Hi vọng kết hợp với parallel streams sẽ hiệu quả hơn
Một hiểu lầm phổ biến:
Virtual thread + parallel stream = nhanh hơn
Parallel stream chủ yếu dành cho CPU-bound và dùng ForkJoinPool, trong khi virtual threads phát huy sức mạnh nhất ở các workload I/O bound nơi phần lớn thời gian bị tiêu tốn vào chờ đợi. Hai cái không xung đột, nhưng thường không phải là tổ hợp mang lại hiệu quả tối ưu.
Nếu không có lý do rõ ràng, kết hợp thường không cải thiện performance, thậm chí làm hệ thống khó đoán hơn.
Conclusion
Tóm lại, virtual threads không thay thế mọi mô hình concurrency, nhưng là một bước tiến lớn giúp Java vừa giữ được tính dễ đọc của code, vừa scale tốt. Dùng đúng chỗ, nó giảm độ phức tạp mà không ép developer phải chuyển sang kiến trúc async nặng nề.
Nếu bạn đang dùng Java 21 trở lên, virtual threads đã sẵn sàng để sử dụng. Nếu vẫn đang dùng version cũ, bạn đã sẵn sàng upgrade chưa?
![]()
Bài viết này cũng được mình dịch sang tiếng Anh trên blog substack của mình.
Mình viết lại những điều này như một cách để ghi nhớ hành trình làm nghề của mình. Nếu bạn cũng đang làm backend, devops hoặc cloud, hy vọng những chia sẻ này có thể giúp bạn một chút gì đó. Còn nếu có chỗ nào mình hiểu chưa đúng, mình vẫn luôn sẵn sàng học thêm.
All rights reserved