1. Luồng cổ điển: hoạt động như thế nào và chỗ đau ở đâu
Hãy cùng nhớ lại trước tiên luồng thông thường trong Java hoạt động ra sao (còn gọi là luồng nền tảng hoặc native threads). Chính là loại được tạo qua new Thread(...).
Khi bạn gọi new Thread(() -> { ... }).start();, JVM không chỉ đơn giản chạy một đoạn mã. Nó yêu cầu hệ điều hành tạo một luồng thực thi thực sự. OS cấp phát cho nó một stack riêng (thường vài megabyte) và dành ra các tài nguyên hệ thống khác.
Một luồng như vậy sống cho tới khi nhiệm vụ của nó hoàn thành, và suốt thời gian đó chiếm chỗ trong bảng luồng của hệ điều hành. Càng nhiều luồng như thế, càng nhiều bộ nhớ dùng cho stack của chúng và càng cao tải cho OS. Đó là lý do khi có rất nhiều luồng chạy đồng thời, ứng dụng có thể bắt đầu “nghẹt thở” – hệ thống tốn quá nhiều thời gian để chuyển đổi giữa chúng.
Ví dụ: luồng cổ điển
Thread thread = new Thread(() -> {
System.out.println("Xin chào từ luồng!");
});
thread.start();
Trông đơn giản, phải không? Nhưng hãy thử tạo không phải một hai, mà ví dụ mười nghìn luồng như vậy – chương trình của bạn sẽ nhanh chóng nghẹt thở. Hoặc hết bộ nhớ, hoặc hệ thống báo hết hạn mức luồng. Đây không phải lỗi của Java, mà là hệ quả tự nhiên của kiến trúc: luồng là thứ nặng và đắt đỏ.
Vì sao lại thế? Mỗi luồng nhận một stack riêng (thường 1–2 megabyte), cộng với cả một tập cấu trúc hệ thống từ OS. Và bản thân OS cũng không vui khi bị đưa cho hàng chục nghìn luồng – nó có các giới hạn, và đôi khi khá nghiêm ngặt.
Ngay cả khi bộ nhớ chưa cạn, lại nảy sinh vấn đề khác – chuyển ngữ cảnh. Khi có quá nhiều luồng, hệ thống liên tục nhảy qua lại giữa chúng, lưu và khôi phục trạng thái. Tất cả đều tốn thời gian và ăn hiệu năng, nên lợi ích từ “song song hóa hàng loạt” không đạt được.
Bài toán “một luồng – một yêu cầu”
Trong các ứng dụng máy chủ cũ (ví dụ Tomcat hoặc Jetty) thường dùng mô hình “thread‑per‑request”: mỗi yêu cầu người dùng đến được cấp một luồng riêng. Rất tiện, nhưng nếu có 10_000 người dùng, bạn cần 10_000 luồng! Máy chủ trở nên nặng nề, và bắt đầu cuộc đua về bộ nhớ thay vì tốc độ xử lý yêu cầu.
Kết luận:
Luồng cổ điển tốt cho số lượng nhỏ tác vụ song song, nhưng không mở rộng lên hàng chục hay hàng trăm nghìn.
2. Virtual Threads là gì?
Đây là lúc nhân vật chính của buổi học hôm nay xuất hiện – luồng ảo. Đây không chỉ là “một loại luồng nữa”, mà là một ý tưởng kiến trúc hoàn toàn khác.
Luồng ảo là các luồng do chính JVM quản lý, không phải do hệ điều hành. Chúng được hiện thực hoàn toàn trong Java và có thể được tạo ra với số lượng rất lớn (hàng chục, hàng trăm nghìn) mà không “phình to” bộ nhớ và không bị chậm.
Tóm tắt:
- Platform Thread (luồng nền tảng): luồng thông thường, ánh xạ trực tiếp tới luồng của OS.
- Virtual Thread (luồng ảo): luồng nhẹ, do JVM quản lý, không phải OS.
Cơ chế hoạt động ra sao?
Luồng ảo là các luồng “nhẹ” sống không phải trong OS mà bên trong chính JVM. Chúng chạy trên một pool nhỏ các luồng thật, gọi là platform threads. Có thể hình dung JVM quản lý chúng như một nhạc trưởng: có số lượng nhạc công hữu hạn (luồng thật), nhưng biết phân phối các phần nhạc (luồng ảo) một cách khéo léo.
Sơ đồ kiến trúc:
+-------------------+ +-------------------+
| Virtual Thread 1 |---\ | Platform Thread |
| Virtual Thread 2 |---->====> | (Carrier Thread) |
| Virtual Thread 3 |---/ +-------------------+
... (Hệ điều hành)
Carrier Thread là luồng OS thông thường, trên đó JVM thực thi nhiều luồng ảo. Nếu một luồng ảo bất ngờ bị chặn – ví dụ chờ dữ liệu từ đĩa hoặc mạng – JVM đơn giản “đóng băng” nó, giải phóng carrier thread cho nhiệm vụ khác.
Vì sao đây là cuộc cách mạng?
Bởi bây giờ bạn có thể viết mã tuyến tính quen thuộc – không cần callback bất tận, CompletableFuture hay chuỗi thenApply “địa ngục” – nhưng vẫn mở rộng ứng dụng tới hàng nghìn thao tác đồng thời.
Luồng ảo chỉ chiếm vài chục kilobyte (thay vì megabyte ở luồng thường) và được tạo gần như tức thì. Vì vậy có thể khởi chạy và hủy chúng theo hàng nghìn mà không lo OS sụp đổ dưới sức nặng. Điều này khiến lập trình song song trong Java cuối cùng trở nên nhẹ nhàng và tự nhiên.
3. Ưu điểm của luồng ảo
Khả năng mở rộng
Với luồng ảo, bạn có thể thoải mái chạy hàng chục, hàng trăm nghìn tác vụ song song. Ví dụ, xử lý mỗi yêu cầu mạng ở một luồng riêng – mà không lo máy chủ “nổ”.
Minh họa: 100 000 luồng ảo
for (int i = 0; i < 100_000; i++) {
Thread.ofVirtual().start(() -> {
// Ở đây có thể là bất kỳ logic nào
try {
Thread.sleep(1000); // Mô phỏng công việc
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});
}
System.out.println("Tất cả các luồng đã được khởi chạy!");
Đoạn mã này chạy ngon lành trên một laptop bình thường!
Hãy thử làm điều tương tự với luồng thường – bạn sẽ thấy OutOfMemoryError hoặc biến máy thành “cục gạch”.
Đơn giản khi lập trình
Luồng ảo cho phép viết mã “chặn” quen thuộc mà không biến nó thành “mì spaghetti” các lời gọi bất đồng bộ. Ví dụ, bạn có thể thoải mái dùng Thread.sleep, InputStream.read, Socket.accept – JVM sẽ tự lo để không chặn toàn bộ carrier thread.
Cải thiện khả năng đọc và bảo trì mã
Thay vì sơ đồ phức tạp với callback và CompletableFuture, bạn viết mã tuyến tính, dễ hiểu. Điều này giảm bug và giúp việc bảo trì dễ hơn.
Không cần tái phát minh bánh xe
Trước đây, để xử lý hàng nghìn yêu cầu song song, bạn phải dùng các framework bất đồng bộ, thư viện reactive (Netty, Vert.x, Project Reactor), đòi hỏi phong cách lập trình riêng. Giờ có thể không cần chúng – mà vẫn đạt khả năng mở rộng.
4. Kiến trúc: luồng ảo hoạt động “bên trong” như thế nào
Ánh xạ lên carrier threads
JVM tạo một pool nhỏ các luồng thật (carrier threads) – thường bằng số nhân CPU. Tất cả luồng ảo “đi nhờ” trên các carrier threads này, như hành khách trên xe buýt.
- Khi luồng ảo bị chặn (ví dụ chờ phản hồi mạng), JVM “dỡ” nó khỏi carrier thread và đưa vào hàng đợi.
- Khi luồng có thể tiếp tục, JVM lại “đặt” nó lên một carrier thread trống.
Ví von:
Hãy tưởng tượng bạn có 4 chiếc taxi (carrier threads) và phục vụ 10 000 khách (virtual threads). Vừa có khách xuống, taxi lập tức đón khách tiếp theo. Không xe nào rỗi, và taxi không “gãy gánh” vì quá tải hành khách.
Lập lịch và chuyển đổi
JVM tự quyết định luồng ảo nào được thực thi tại thời điểm hiện tại. Nếu luồng bị chặn ở I/O, nó không cản trở các luồng khác làm việc.
5. Giới hạn và đặc thù của luồng ảo
Không phải cứ “ảo” là vàng
Không dành cho tác vụ tính toán dài: Nếu bạn có một tác vụ liên tục chiếm CPU (heavy CPU‑bound), luồng ảo sẽ không đem lại tăng tốc. Đơn giản vì carrier threads vẫn bị giới hạn bởi số nhân.
Một số cơ chế khóa kém hiệu quả: Cơ chế đồng bộ cũ (ví dụ đồng bộ bằng synchronized trên các đối tượng có mutex native) có thể không cho JVM “đóng băng” luồng ảo. Khi đó carrier thread sẽ phải chờ cùng luồng ảo, làm giảm khả năng mở rộng.
Không phải mọi thư viện đều thân thiện với luồng ảo: Nếu thư viện thực hiện lời gọi native hoặc dùng các khóa đặc thù, luồng ảo có thể không hoạt động như kỳ vọng.
Ví dụ: khi không nên dùng Virtual Threads
Nếu bạn có một tác vụ chạy vòng lặp vô tận để tính toán, luồng ảo sẽ không mang lại lợi ích nào. Cuối cùng vẫn bị giới hạn bởi số nhân CPU.
Thread.ofVirtual().start(() -> {
while (true) {
// Tính toán vô hạn
}
});
Kết quả:
Một carrier thread sẽ bị luồng ảo này chiếm dụng, còn các tác vụ khác phải chờ tới lượt.
6. So sánh: Platform Thread vs Virtual Thread
| Đặc trưng | Platform Thread (thông thường) | Virtual Thread (ảo) |
|---|---|---|
| Được quản lý bởi | Hệ điều hành | JVM |
| Bộ nhớ mỗi luồng | Megabyte | Vài chục kilobyte |
| Số lượng luồng | Thường < 10_000 | Hàng nghìn, hàng trăm nghìn |
| Chi phí tạo | Đắt | Rẻ |
| Khả năng mở rộng | Bị giới hạn | Hầu như không giới hạn |
| Phù hợp cho | Tác vụ dài, CPU‑bound | Tác vụ ngắn, I/O‑bound |
| Chuyển đổi luồng | OS | JVM |
| Tương thích | 100% | Hầu như luôn luôn, nhưng có vài lưu ý |
7. Ví dụ: máy chủ trước và sau Virtual Threads
Trước (Platform Threads)
ServerSocket serverSocket = new ServerSocket(8080);
while (true) {
Socket client = serverSocket.accept();
new Thread(() -> handleClient(client)).start();
}
Vấn đề:
Sau 5 000 kết nối, máy chủ bắt đầu “sặc”.
Sau (Virtual Threads, Java 21+)
ServerSocket serverSocket = new ServerSocket(8080);
while (true) {
Socket client = serverSocket.accept();
Thread.ofVirtual().start(() -> handleClient(client));
}
Như có phép màu:
Giờ có thể xử lý hàng chục nghìn kết nối – mà không phải nghĩ về hạn mức luồng!
9. Lỗi thường gặp khi chuyển sang luồng ảo
Lỗi số 1: Kỳ vọng tăng tốc cho tác vụ tính toán. Luồng ảo không tăng tốc các tác vụ chiếm CPU hoàn toàn. Với các tác vụ như vậy, vẫn bị giới hạn bởi số nhân.
Lỗi số 2: Dùng cơ chế đồng bộ chặn kiểu cũ. Nếu bạn dùng các khóa cũ (ví dụ synchronized trên đối tượng có thể bị “khóa” theo kiểu native), luồng ảo có thể không được dỡ khỏi carrier thread, đánh mất mọi ưu điểm.
Lỗi số 3: Không tính tới hành vi của thư viện bên thứ ba. Một số thư viện bên thứ ba có thể chưa sẵn sàng cho luồng ảo (ví dụ dùng JNI hoặc khóa native).
Lỗi số 4: Kỳ vọng tăng trưởng hiệu năng “kỳ diệu”. Luồng ảo không phải thuốc tiên. Chúng không tăng tốc mọi thứ, mà chỉ làm cho song song hóa trở nên rẻ và tiện cho các tác vụ I/O‑bound.
GO TO FULL VERSION