1. Tóm tắt những điểm chính
Bạn có lẽ đã quen thuộc đôi chút với các lớp trong gói java.util.concurrent, đặc biệt là ExecutorService. Nó giống như một “trình quản lý tác vụ”: bạn gửi công việc cho nó (ví dụ, qua submit()), và nó tự quyết định khi nào và bằng luồng nào để thực thi. Thông thường, ở “tầng dưới”, một pool luồng kích thước cố định hoạt động để tiết kiệm tài nguyên và không tạo luồng mới cho mỗi nhiệm vụ.
Tuy nhiên, với luồng ảo thì mọi thứ thay đổi! Giờ bạn có thể “mạnh tay”: mỗi nhiệm vụ — một luồng riêng, mà không lo JVM “quá tải”.
Cách mới: Executors.newVirtualThreadPerTaskExecutor()
Trong Java 21 xuất hiện một cách mới để tạo ExecutorService, chạy mỗi nhiệm vụ trong một luồng ảo riêng:
ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
Điểm khác biệt quan trọng:
- Các pool luồng cũ (Executors.newFixedThreadPool, Executors.newCachedThreadPool) giới hạn số nhiệm vụ đồng thời do chi phí cao của luồng hệ điều hành.
- Executor dựa trên luồng ảo mới hầu như không bị giới hạn: mỗi nhiệm vụ có một luồng ảo nhẹ riêng.
Ví dụ đơn giản
Gửi 10 nhiệm vụ vào Executor dùng luồng ảo:
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public class VirtualExecutorDemo {
public static void main(String[] args) {
ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
for (int i = 1; i <= 10; i++) {
int taskId = i; // chụp biến cho lambda
executor.submit(() -> {
System.out.println("Task " + taskId + " is running in thread: " +
Thread.currentThread());
});
}
executor.shutdown();
}
}
Điều gì xảy ra?
Mỗi nhiệm vụ sẽ chạy trong một luồng ảo riêng, và bạn sẽ thấy các dòng kiểu như:
Task 1 is running in thread: VirtualThread[#24]/runnable@ForkJoinPool-1-worker-1
...
2. Độ song song lớn: hàng nghìn nhiệm vụ — không thành vấn đề!
Để cảm nhận sức mạnh của luồng ảo, hãy thử gửi vào ExecutorService không phải 10 mà là, ví dụ, 100_000 nhiệm vụ. Với các pool cổ điển, điều này giống như cố nhét con voi vào tủ lạnh: JVM sẽ nhanh chóng hết bộ nhớ hoặc chậm khủng khiếp. Với luồng ảo — câu chuyện lại khác!
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public class VirtualExecutorMassiveDemo {
public static void main(String[] args) {
ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
for (int i = 1; i <= 100_000; i++) {
int taskId = i;
executor.submit(() -> {
// Ví dụ — chỉ ngủ 1 ms
try {
Thread.sleep(1);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
// System.out.println("Task " + taskId + " done."); // Không in ra, nếu không sẽ có quá nhiều dòng!
});
}
executor.shutdown();
}
}
Lưu ý: in ra 100_000 dòng lên màn hình là ý tưởng tệ: console sẽ “nghẹt thở” nhanh hơn nhiều so với luồng ảo. Tốt hơn là không ghi ra console, hoặc chỉ in vài nhiệm vụ đầu tiên.
3. Cách newVirtualThreadPerTaskExecutor hoạt động
Ngắn gọn: ExecutorService này tạo một luồng ảo mới cho mỗi nhiệm vụ bạn gửi cho nó. Khác với pool cố định, ở đây không có hàng đợi nhiệm vụ và không có giới hạn cứng về số luồng đồng thời (ngoài các giới hạn của JVM và phần cứng của bạn).
Về mặt kiến trúc:
- Luồng ảo được “ánh xạ” lên một pool nhỏ các luồng hệ điều hành thực (carrier threads).
- JVM tự quyết định khi nào chạy, tạm dừng và tiếp tục luồng ảo nào.
- Nếu luồng bị chặn (ví dụ, khi đọc tệp hoặc chờ mạng), JVM có thể “đóng băng” luồng ảo và giải phóng carrier thread cho các nhiệm vụ khác.
4. Ví dụ: xử lý kết quả với Future
ExecutorService trả về đối tượng kiểu Future nếu nhiệm vụ có kết quả trả về. Mọi thứ hoạt động giống hệt như với các luồng thông thường:
import java.util.concurrent.*;
public class VirtualExecutorWithResult {
public static void main(String[] args) throws InterruptedException, ExecutionException {
ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
Future<String> future = executor.submit(() -> {
Thread.sleep(500);
return "Hello from virtual thread!";
});
System.out.println("Result: " + future.get()); // Chờ kết quả
executor.shutdown();
}
}
Mọi thứ đều quen thuộc: có thể gửi nhiệm vụ trả về giá trị, chờ kết quả qua get(), và ngoại lệ được xử lý theo cách tiêu chuẩn.
5. Cách kết thúc Executor đúng cách
Rất quan trọng là đừng quên kết thúc ExecutorService, để chương trình không treo (dù luồng là ảo chứ không phải “thực”).
shutdown() và awaitTermination
executor.shutdown(); // Thông báo: không nhận thêm nhiệm vụ
executor.awaitTermination(1, TimeUnit.MINUTES); // Chờ tất cả nhiệm vụ kết thúc (tối đa 1 phút)
Vì sao điều này quan trọng?
Nếu không gọi shutdown(), các luồng ảo có thể vẫn tồn tại, và chương trình sẽ không kết thúc ngay cả sau khi main() chạy xong. Đây là lỗi điển hình của người mới.
6. Những lưu ý hữu ích
So sánh: Executor luồng ảo vs pool luồng cổ điển
| Pool cổ điển (newFixedThreadPool) | Executor luồng ảo (newVirtualThreadPerTaskExecutor) | |
|---|---|---|
| Số lượng luồng | Bị giới hạn bởi kích thước pool | Một luồng ảo cho mỗi nhiệm vụ, gần như không giới hạn |
| Nhiệm vụ trong hàng đợi | Có, nếu tất cả luồng đều bận | Thường là không: nhiệm vụ nhận luồng ngay |
| Chi phí của luồng | Cao (ngăn xếp, tài nguyên của HĐH) | Rất thấp (JVM điều phối) |
| Khả năng mở rộng | Bị giới hạn | Gần như không bị giới hạn |
| Phù hợp cho | Nhiệm vụ CPU-bound, mức song song hạn chế | Nhiệm vụ I/O-bound, song song quy mô lớn |
Tích hợp với máy chủ web
Các máy chủ web hiện đại (ví dụ: Tomcat, Jetty, Undertow) đã bắt đầu hỗ trợ luồng ảo. Điều này có nghĩa là có thể xử lý mỗi yêu cầu HTTP trong một luồng ảo riêng, không sợ “nghẹt thở” khi lượng người dùng tăng đột biến.
Ưu điểm: không cần nghĩ ra các sơ đồ bất đồng bộ phức tạp với callbacks và CompletableFuture; mã trở nên đơn giản hơn — có thể viết mã chặn quen thuộc, nhưng ứng dụng vẫn mở rộng tốt.
Kiểm thử quy mô lớn và mô phỏng tải
Luồng ảo rất phù hợp cho các bài test cần “mô phỏng” hàng nghìn người dùng, yêu cầu hoặc thao tác đồng thời. Ví dụ, một bài test gửi 10_000 yêu cầu song song tới máy chủ, mỗi yêu cầu trong một luồng ảo riêng.
Xử lý song song tệp và kết nối mạng
Nếu ứng dụng làm việc với số lượng lớn tệp hoặc kết nối mạng, bạn có thể xử lý mỗi kết nối trong một luồng ảo riêng mà không phải tự tay quản lý các pool.
7. Các lỗi thường gặp khi làm việc với Executor dựa trên luồng ảo
Lỗi 1: quên gọi shutdown(). Nếu không đóng Executor, chương trình sẽ không kết thúc — các luồng ảo vẫn sẽ chờ nhiệm vụ mới. Khi cần, hãy thêm awaitTermination(...).
Lỗi 2: dùng luồng ảo cho tính toán nặng. Luồng ảo không làm nhanh hơn các nhiệm vụ tiêu tốn hoàn toàn CPU. Với bài toán CPU-bound, tốt hơn nên dùng pool cố định (Executors.newFixedThreadPool) và chọn kích thước cẩn thận.
Lỗi 3: bỏ qua ngoại lệ bên trong nhiệm vụ. Nếu nhiệm vụ ném ngoại lệ, nó sẽ không rơi vào luồng chính — hãy xử lý qua Future (phương thức get()) hoặc bằng try/catch ngay trong lambda.
Lỗi 4: nhầm lẫn cú pháp/các phiên bản JDK cũ và mới. Hãy kiểm tra bạn đang dùng phiên bản JDK phù hợp (Java 21+) và IDE đã được cấu hình để hỗ trợ luồng ảo. Phương thức cụ thể — Executors.newVirtualThreadPerTaskExecutor().
Lỗi 5: phụ thuộc vào ThreadLocal để truyền ngữ cảnh. Luồng ảo thường được tạo và hủy liên tục; ThreadLocal có thể không hoạt động như kỳ vọng. Để truyền ngữ cảnh, hãy dùng ScopedValue (Scoped Values; chi tiết — ở bài giảng tiếp theo).
GO TO FULL VERSION