1. Khả năng mở rộng
Vì sao các luồng thông thường mở rộng kém?
Mỗi luồng cổ điển (Thread) — là thực thể của hệ điều hành với ngăn xếp riêng (thường 1–2 MB) và cấu trúc trạng thái. Cố gắng tạo, chẳng hạn, 10 000 luồng thông thường thường dẫn đến lỗi OutOfMemoryError. Vì vậy trên các máy chủ truyền thống người ta sử dụng các pool luồng giới hạn.
Luồng ảo: phép màu về khả năng mở rộng
Luồng ảo (Java 21+) là các luồng “nhẹ”, được JVM quản lý. Ngăn xếp của chúng nằm trong heap và có thể tăng/giảm động. Khi một luồng bị chặn bởi I/O, JVM “đóng băng” nó và tiếp tục chạy các tác vụ khác.
Bên trong JVM có một pool nhỏ các “luồng mang” (carrier threads) — các luồng nền tảng của HĐH, trên đó các luồng ảo lần lượt được thực thi. Điều này cho phép tạo 100 000+ tác vụ mà không kinh hoàng về bộ nhớ. JVM tự lập lịch thời điểm thực thi các luồng ảo.
Trình diễn: 100_000 luồng ảo so với 1_000 luồng nền tảng
Ví dụ: tạo 1000 luồng thông thường
// Thử tạo 1000 luồng thông thường
List<Thread> threads = new ArrayList<>();
for (int i = 0; i < 1000; i++) {
Thread t = new Thread(() -> {
try {
Thread.sleep(10_000);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});
threads.add(t);
t.start();
}
System.out.println("Số luồng đã tạo: " + threads.size());
Kết quả: Trên đa số hệ thống có thể tạo 1 000–2 000 luồng; khi lớn hơn sẽ bắt đầu gặp vấn đề bộ nhớ và chậm lại.
Ví dụ: tạo 100 000 luồng ảo
// Tạo 100_000 luồng ảo
List<Thread> vThreads = new ArrayList<>();
for (int i = 0; i < 100_000; i++) {
Thread t = Thread.ofVirtual().start(() -> {
try {
Thread.sleep(10_000);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});
vThreads.add(t);
}
System.out.println("Số luồng ảo đã tạo: " + vThreads.size());
Kết quả: Chương trình tạo 100 000 luồng ảo một cách ổn định, không bị sập và không bị chậm đáng kể. Cần ít bộ nhớ hơn rất nhiều.
So sánh trực quan
| Loại luồng | Số luồng tối đa (xấp xỉ) | Mức sử dụng bộ nhớ | Thời gian khởi chạy |
|---|---|---|---|
| Thông thường (Thread) | 1 000 – 10 000 | Cao | Lâu |
| Ảo | 100 000 – 1 000 000+ | Thấp | Ngay lập tức |
Thực tế: Luồng ảo cho phép viết mã theo kiểu “một luồng cho mỗi tác vụ” mà không cần pool phức tạp và không lo quá tải hệ thống.
2. Hiệu năng: khi nào luồng ảo phát huy
Các tác vụ bị giới hạn bởi I/O (I/O-bound)
Luồng ảo rất phù hợp cho các yêu cầu mạng, I/O tệp và làm việc với CSDL. Khi thao tác bị chặn, luồng ảo sẽ giải phóng “luồng mang”, và JVM thực thi các tác vụ khác. Điều này tăng thông lượng khi có nhiều phiên chờ đồng thời.
Ví dụ: mô phỏng 10 000 yêu cầu HTTP đồng thời
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
HttpClient client = HttpClient.newHttpClient();
List<Thread> threads = new ArrayList<>();
for (int i = 0; i < 10_000; i++) {
Thread t = Thread.ofVirtual().start(() -> {
try {
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create("https://example.com"))
.build();
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
System.out.println("Phản hồi: " + response.statusCode());
} catch (Exception e) {
System.out.println("Lỗi: " + e.getMessage());
}
});
threads.add(t);
}
// Chờ tất cả các luồng kết thúc
for (Thread t : threads) {
t.join();
}
Kết quả: Tất cả 10 000 yêu cầu được thực thi song song; chương trình không bị sập và mã vẫn đơn giản.
CPU-bound: luồng ảo không làm tính toán nhanh hơn
Nếu tác vụ tải nặng CPU, luồng ảo sẽ không tăng tốc: số lõi là cố định. Ở đây nên dùng các pool cố định bằng số lõi để tránh cạnh tranh thừa.
// Mỗi tác vụ tính tổng một phạm vi lớn
Runnable cpuTask = () -> {
long sum = 0;
for (int i = 0; i < 100_000_000; i++) {
sum += i;
}
System.out.println("Tổng: " + sum);
};
// Chạy 1000 luồng ảo thực hiện tính toán
for (int i = 0; i < 1000; i++) {
Thread.ofVirtual().start(cpuTask);
}
Kết quả: Các luồng sẽ cạnh tranh CPU, nhưng sẽ không nhanh hơn — đây không phải là bài toán cho Virtual Threads.
3. Hạn chế và đặc điểm
Đồng bộ hóa và bẫy thường gặp với luồng ảo
- Cẩn trọng với các khóa native. Sử dụng synchronized có thể “dán” luồng ảo vào luồng mang, làm giảm lợi ích. Ưu tiên ReentrantLock, Semaphore và các nguyên thủy khác trong java.util.concurrent được tối ưu cho luồng ảo.
- Thư viện cũ. Một số driver JDBC và thư viện native vẫn chưa được tối ưu cho Virtual Threads. Hãy kiểm thử kỹ các thao tác blocking.
Không dành cho tác vụ sống lâu
Virtual Threads lý tưởng cho các đơn vị công việc “ngắn”: xử lý yêu cầu, một thao tác rồi kết thúc. Hàng triệu tác vụ sống vô hạn (ví dụ tính toán vô tận) sẽ không có ích — hãy dùng luồng nền tảng cho chúng.
4. Thực hành tốt nhất: nên dùng luồng ảo ở đâu
- Tác vụ I/O-bound: gọi mạng, tệp, CSDL — bất cứ nơi nào luồng thường xuyên phải chờ.
- Máy chủ web: xử lý mỗi yêu cầu HTTP trong một luồng ảo riêng.
- Kiểm thử tích hợp: mô phỏng nhanh hàng nghìn khách hàng.
- Xử lý bất đồng bộ: cứ viết mã “blocking” quen thuộc — JVM sẽ lập lịch thông minh.
Không nên dùng:
- Cho các tác vụ liên tục tải nặng CPU.
- Khi tính tương thích với thư viện cấp thấp là tối quan trọng (vì chưa phải tất cả đều đã thích ứng).
Bên dưới nắp máy, tiện nhất là dùng executor: Executors.newVirtualThreadPerTaskExecutor() — “một luồng ảo cho mỗi tác vụ”, không có pool cố định.
5. Giám sát và đo đạc: cách nhìn thấy luồng ảo hoạt động
JVisualVM và Flight Recorder
JVisualVM hiển thị các luồng đang hoạt động, trạng thái và bộ nhớ; từ Java 21 các luồng ảo được hiển thị riêng. Java Flight Recorder (JFR) ghi “hộp đen” chi tiết về thực thi, bao gồm thống kê về Virtual Threads — hữu ích để tìm nút thắt cổ chai.
Cách xem số lượng luồng trong mã
Cách đơn giản để xem số lượng luồng trong JVM:
System.out.println("Tổng số luồng: " + Thread.activeCount());
Đếm xem có bao nhiêu trong số đó là luồng ảo:
long vCount = Thread.getAllStackTraces().keySet().stream()
.filter(Thread::isVirtual)
.count();
System.out.println("Số luồng ảo: " + vCount);
6. Lỗi thường gặp khi làm việc với luồng ảo
Lỗi số 1: Dùng luồng ảo cho tính toán nặng. Chạy hàng triệu luồng ảo với tác vụ CPU-bound sẽ không làm CPU nhanh hơn. Virtual Threads không phải “turbo” cho tính toán.
Lỗi số 2: Sao chép mù quáng các mẫu cũ. Đừng tạo các pool cố định cho luồng ảo. Hãy dùng Executors.newVirtualThreadPerTaskExecutor() và để JVM tự mở rộng.
Lỗi số 3: Dùng thư viện không được hỗ trợ. Khóa native và các thư viện chưa thích ứng với Loom có thể gây treo và sụt hiệu năng. Hãy kiểm tra sớm tính tương thích.
Lỗi số 4: Tối ưu hóa sớm. Nếu bạn chỉ có vài luồng và đa luồng thông thường — đừng vội chuyển hết sang Virtual Threads. Công cụ này phù hợp ở nơi có I/O lớn và nhiều chờ đợi.
Lỗi số 5: Bỏ qua giám sát. Tạo ra hàng triệu tác vụ thì dễ, nhưng nếu thiếu giám sát và xử lý ngoại lệ, bạn có thể nhận một “benchmark đẹp” thay vì hệ thống ổn định. Hãy dùng JVisualVM và JFR.
GO TO FULL VERSION