Sự tiến hóa của Java Concurrency
Trong nhiều năm, các nhà phát triển Java đã phải đối mặt với một sự đánh đổi khó khăn. Chúng ta có thể viết mã blocking đơn giản nhưng lại bị nghẽn khi tải nặng, hoặc áp dụng lập trình reactive—một con đường phức tạp thường khiến ta cảm thấy như đang học một ngôn ngữ thứ hai chỉ để xử lý I/O. Java 21 phá vỡ bế tắc này với Virtual Threads. Là một phần của Project Loom, tính năng này cho phép bạn mở rộng lên hàng triệu tác vụ đồng thời mà không tốn nhiều bộ nhớ như các luồng cấp hệ điều hành (OS-level threads) truyền thống.
Di chuyển chỉ trong 5 phút
Bắt đầu với Virtual Threads không yêu cầu một cuộc cách mạng về tư duy. Nếu bạn có thể viết một Runnable, bạn đã biết cách sử dụng Virtual Threads. API vẫn giữ nguyên sự quen thuộc, nhưng cơ chế bên dưới đã hoàn toàn thay đổi.
Tạo Virtual Thread đầu tiên
Builder Thread.ofVirtual() là điểm bắt đầu trực tiếp nhất. Nó tạo ra một luồng nhẹ (lightweight thread) để xử lý các công việc nặng nề ở chế độ nền:
Thread vThread = Thread.ofVirtual().start(() -> {
System.out.println("Đang chạy trên: " + Thread.currentThread());
});
// Đợi cho đến khi tác vụ hoàn thành
vThread.join();
Executor cho mỗi tác vụ
Bạn có lẽ sẽ không quản lý các luồng một cách thủ công trong môi trường production. Thay vào đó, Java 21 cung cấp một ExecutorService chuyên dụng để tạo một virtual thread mới cho mỗi tác vụ mà bạn gửi vào:
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
IntStream.range(0, 100_000).forEach(i -> {
executor.submit(() -> {
Thread.sleep(Duration.ofSeconds(1));
return i;
});
});
} // Tự động đóng và đợi tất cả 100k tác vụ hoàn thành
Hãy thử chạy 100.000 platform threads trên một máy tính xách tay thông thường, bạn có thể sẽ gặp lỗi OutOfMemoryError. Mỗi platform thread chiếm khoảng 1MB không gian ngăn xếp (stack space) theo mặc định. Điều đó có nghĩa là cần tới 100GB RAM chỉ cho các stack. Tuy nhiên, Virtual threads chỉ bắt đầu với vài trăm byte trên heap. Chúng xử lý mức tải này một cách dễ dàng.
Cách JVM vận hành
Hiểu được mối liên kết giữa Virtual Threads và Platform Threads là chìa khóa để sử dụng chúng một cách chính xác.
Các luồng Java tiêu chuẩn là những lớp bao bọc mỏng xung quanh các luồng của Hệ điều hành (OS threads). Đây là những tài nguyên đắt đỏ. Vì việc tạo ra chúng chậm và tiêu tốn đáng kể bộ nhớ, chúng ta thường sử dụng thread pools để duy trì và tái sử dụng chúng. Đó là một giải pháp tạm thời cho một hạn chế cơ bản.
Virtual threads chuyển việc quản lý từ OS sang JVM. Runtime sẽ “mount” (gắn) một virtual thread vào một nhóm các “carrier” platform threads để thực thi mã. Khi mã của bạn gặp một lời gọi blocking—như truy vấn cơ sở dữ liệu hoặc yêu cầu REST API—JVM sẽ “unmount” virtual thread đó và đưa nó vào heap. Điều này giải phóng carrier thread để ngay lập tức thực hiện tác vụ khác. Đây là cơ chế lập trình hiệu quả cao mà không cần đến “callback hell”.
Hiệu năng cải thiện là rất rõ ràng. Trong một đợt chuyển đổi gần đây cho một microservice Spring Boot chuyên xử lý các lời gọi API bên ngoài, chúng tôi đã thay thế một pool cố định 200 luồng bằng một virtual executor. Lượng bộ nhớ sử dụng đã giảm 65% và chúng tôi duy trì được lưu lượng (throughput) cao gấp 3 lần trong các đợt cao điểm mà không làm tăng mức sử dụng CPU.
Các mô hình nâng cao: Structured Concurrency
Với khả năng tạo ra số lượng luồng “vô hạn” cũng đi kèm nguy cơ mất kiểm soát chúng. Java 21 giới thiệu Structured Concurrency (đang ở chế độ preview) để nhóm các tác vụ liên quan. Nếu một tác vụ cha bị hủy, tất cả các tác vụ con sẽ tự động được dọn dẹp.
Phân tách tác vụ an toàn
Hãy xem xét việc lấy dữ liệu từ hai dịch vụ độc lập. Nếu dịch vụ đầu tiên thất bại, việc chờ đợi dịch vụ thứ hai là vô nghĩa:
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
Subtask<String> userTask = scope.fork(() -> fetchUser(id));
Subtask<String> orderTask = scope.fork(() -> fetchOrders(id));
scope.join(); // Đợi kết quả
scope.throwIfFailed(); // Dừng sớm nếu có bất kỳ lỗi nào xảy ra
return new Response(userTask.get(), orderTask.get());
}
Cách tiếp cận này làm cho logic không đồng bộ trông giống như mã tuần tự. Nó dễ đọc hơn, dễ kiểm thử hơn và giảm đáng kể rủi ro rò rỉ luồng (thread leak).
Lưu ý thực tế cho môi trường Production
Virtual threads rất mạnh mẽ, nhưng chúng không phải là giải pháp vạn năng. Hãy tránh những sai lầm phổ biến sau để giữ cho môi trường production của bạn ổn định.
1. Ngừng sử dụng Thread Pool
Bản năng sử dụng thread pool rất khó bỏ. Tuy nhiên, việc tạo pool cho virtual threads thực tế là một anti-pattern. Chúng được thiết kế để trở thành các đối tượng rẻ và dùng một lần. Hãy tạo chúng khi cần và để Garbage Collector (Bộ thu gom rác) xử lý việc dọn dẹp. Nếu bạn tạo pool, bạn chỉ đang tạo thêm chi phí không cần thiết.
2. Vấn đề Thread Pinning
Một virtual thread có thể bị “pinned” (ghim) vào carrier thread của nó. Điều này xảy ra nếu bạn sử dụng các khối synchronized hoặc gọi các phương thức native (JNI). Khi bị ghim, carrier thread không thể chuyển sang các tác vụ khác, khiến động cơ hiệu suất cao của bạn trở thành nút thắt cổ chai.
Giải pháp: Thay thế synchronized bằng ReentrantLock trong các đoạn mã quan trọng. Điều này cho phép JVM unmount luồng ngay cả khi nó đang đợi khóa (lock).
3. Bảo vệ tài nguyên hạ tầng
Các thread pool tiêu chuẩn đóng vai trò như một bộ hãm tự nhiên cho cơ sở dữ liệu hoặc các API bên ngoài. Với Virtual Threads, bạn có thể dễ dàng gửi 5.000 truy vấn đồng thời đến một cơ sở dữ liệu chỉ hỗ trợ 50 kết nối. Hãy sử dụng Semaphore để giới hạn quyền truy cập vào các tài nguyên bên ngoài một cách rõ ràng.
4. Cẩn thận với ThreadLocals
Mở rộng lên một triệu luồng có nghĩa là có khả năng tạo ra một triệu bản sao ThreadLocal. Nếu mỗi bản sao giữ một đối tượng lớn, bộ nhớ heap của bạn sẽ cạn kiệt. Đối với các ứng dụng Java hiện đại, hãy ưu tiên Scoped Values. Chúng cung cấp một cách chia sẻ ngữ cảnh hiệu quả hơn về mặt bộ nhớ giữa các luồng.
Virtual threads đại diện cho sự thay đổi đáng kể nhất trong mô hình đa luồng của Java trong suốt hai mươi năm qua. Bằng cách loại bỏ chi phí cao của việc blocking, chúng cho phép bạn viết mã sạch, đáp ứng khả năng mở rộng của điện toán đám mây hiện đại. Hãy bắt đầu bằng cách xác định các dịch vụ bị giới hạn bởi I/O nhất—đó là những ứng viên hoàn hảo cho đợt nâng cấp này.

