Welcome to MikeDaDev's Blog – notes on Java, backend & system design.

Explore
MikeDaDev's Blog
in java

java virtual threads

Tung Bui Thanh (TungDaDev)9 mins read

Houses near valley with trees

Concurrency (xử lý đồng thời) xưa nay luôn là bài toán khó trong phát triển phần mềm, nhất là với những hệ thống phải gánh nhiều việc cùng lúc như server, ứng dụng web, hay hệ thống phân tán.

Cách truyền thống là dùng threads của Java. Cách này có từ lâu và chạy được, nhưng số threads tăng lên là bắt đầu lộ hạn chế: tốn tài nguyên, overhead quản lý threads lớn.

Java 21 mang tới Virtual Threads (chính thức qua JEP 444), tính năng mà nhiều người đánh giá sẽ thay đổi hẳn cách mình viết code concurrency trong Java. Nó cho phép viết code blocking kiểu tuần tự dễ đọc mà vẫn phục vụ được rất nhiều tác vụ đồng thời, nhất là với ứng dụng chủ yếu chờ I/O.

# concurrency trong Java: mấy cái khó từ trước tới giờ

Trước Java 21, muốn chạy việc đồng thời thì Java dùng threads (real threads, giờ gọi là platform thread). Mỗi java.lang.Thread ánh xạ 1:1 với một thread của hệ điều hành: JVM yêu cầu OS tạo ra, OS lập lịch chạy, và mỗi thread giữ một vùng stack riêng (mặc định khoảng 1MB trên Linux 64-bit, chỉnh bằng -Xss).

Ứng dụng ít threads thì cách này vẫn ổn. Nhưng số threads tăng lên là bắt đầu gặp chuyện:

  • Tốn tài nguyên: thread nào cũng cần một ít tài nguyên (bộ nhớ cho stack, cấu trúc quản lý trong kernel). Vài nghìn thread là bắt đầu thấy nặng, chuyển ngữ cảnh (context switch) giữa chúng cũng tốn CPU.
  • Quản lý threads rắc rối: khi ứng dụng cần tới hàng nghìn, thậm chí hàng triệu threads, quản lý và tối ưu chúng rất mệt. Hệ thống vừa chậm vừa phức tạp thêm.
  • Block I/O: ứng dụng web hay hệ thống mạng có nhiều threads bị block khi làm I/O, như đọc/ghi file hay chờ API trả về. Trong lúc chờ, thread vẫn chiếm nguyên tài nguyên mà không làm gì. Mô hình "mỗi request một thread" vì thế bị giới hạn bởi số thread, nên trước đây muốn scale thì phải chuyển sang lập trình bất đồng bộ (CompletableFuture, reactive như WebFlux), code khó đọc và khó debug hơn hẳn.

# virtual threads: lời giải của java 21

Virtual Threads trong Java 21 xử lý mấy vấn đề trên bằng một mô hình thread mới, nhẹ hơn và đỡ tốn hơn.

Virtual Threads là thread "ảo", do JVM quản lý chứ không phải hệ điều hành. Chi phí của nó thấp hơn threads truyền thống rất nhiều, nên tạo hàng trăm nghìn, thậm chí hàng triệu virtual thread vẫn được.

# cách hoạt động

JVM có một scheduler riêng (mặc định là một ForkJoinPool, số thread bằng số CPU core) gồm vài platform thread gọi là carrier thread. Virtual thread muốn chạy thì được "gắn" (mount) lên một carrier. Khi nó gặp thao tác blocking (chờ socket, Thread.sleep, chờ lock trong java.util.concurrent...), JVM gỡ (unmount) nó ra, cất stack của nó vào heap, và carrier rảnh tay chạy virtual thread khác. Dữ liệu về thì virtual thread được gắn lại lên một carrier bất kỳ để chạy tiếp. (Thời còn là dự án Loom, khái niệm này từng được gọi là "fiber".) Nó có mấy đặc điểm sau:

  • Lightweight: stack nằm trên heap dưới dạng object, co giãn theo độ sâu lời gọi thực tế, nên một virtual thread đang nghỉ chỉ tốn cỡ vài trăm byte tới vài KB. Tạo ra cũng rẻ như tạo một object.
  • Code blocking, nhưng không giữ OS thread: code trong virtual thread vẫn viết kiểu blocking bình thường (gọi JDBC, HttpClient.send, đọc socket). Virtual thread có bị block, nhưng thứ bị block chỉ là virtual thread, carrier thread bên dưới được trả lại để làm việc khác. Đây là khác biệt lớn với lập trình bất đồng bộ: bạn không phải đổi cách viết code.
  • Scalable: số tác vụ đồng thời không còn bị giới hạn bởi số OS thread. Giới hạn chuyển sang chỗ khác: heap, số connection DB, sức chịu của service phía sau.

# lợi ích

Với dân Java, Virtual Threads là một bước tiến lớn trong chuyện quản lý luồng và chạy đa tác vụ. Lợi ích thì khó mà chê, ví dụ:

Throughput cao hơn: virtual thread không làm từng tác vụ chạy nhanh hơn (một lời gọi DB mất 50ms thì vẫn mất 50ms). Cái nó tăng là số tác vụ phục vụ được cùng lúc, với ứng dụng mà phần lớn thời gian là chờ I/O.

Quản lý đơn giản hơn: lập trình viên không phải tự tay quản lý từng thread. JVM lo hết phần đó, code concurrency đỡ rối hơn nhiều.

Dễ mở rộng: số request đồng thời không còn bị chặn bởi kích thước thread pool, miễn là các tài nguyên phía sau (DB, API bên ngoài) gánh được.

Ít bị nghẽn hơn khi tải cao: request không còn phải xếp hàng chờ thread rảnh trong pool, nên khi tải tăng thì thời gian phản hồi đỡ bị kéo dài hơn, nhất là với hệ thống web hay mạng.

# dùng virtual threads trong java 21

Muốn dùng Virtual Threads trong Java 21 thì phải đổi cách tạo và quản lý threads trong ứng dụng. Tin vui là tạo Virtual Thread đơn giản hơn thread thường nhiều.

Tạo Virtual Thread

Tạo một Virtual Thread trong Java 21 bằng Thread.ofVirtual().start():

Thread virtualThread = Thread.ofVirtual().start(() -> {
    System.out.println("Virtual thread is running!");
});
virtualThread.join(); // chờ nó chạy xong

Đoạn trên tạo một Virtual Thread mới và chạy nó ngay. Virtual thread luôn là daemon thread, nên nếu main kết thúc trước thì JVM tắt luôn, không chờ nó; vì vậy mới có dòng join(). Cách viết ngắn hơn là Thread.startVirtualThread(...). Nó thay được cho cách tạo thread kiểu cũ:

Thread thread = new Thread(() -> {
    System.out.println("Real thread is running!");
});
thread.start();

Một điểm đáng chú ý: với Virtual Threads, bạn không cần thread pool như khi dùng thread thường. Pool tồn tại vì platform thread đắt, phải tái sử dụng; virtual thread thì rẻ, mỗi task một thread mới rồi bỏ đi là cách dùng đúng. Đừng gom virtual thread vào ThreadPoolExecutor cố định số lượng. Bạn vẫn dùng interface ExecutorService quen thuộc, chỉ là executor bên dưới tạo virtual thread mới cho mỗi task.

Dùng Virtual Threads với ExecutorService

Java 21 cho dùng Virtual Threads với ExecutorService. Tạo một ExecutorService chạy bằng Virtual Threads như sau:

ExecutorService executorService = Executors.newVirtualThreadPerTaskExecutor();
executorService.submit(() -> {
    System.out.println("Virtual thread in ExecutorService is running!");
});

Với Executors.newVirtualThreadPerTaskExecutor(), mỗi task gửi vào executorService sẽ chạy trên một Virtual Thread mới, quản lý concurrency nhàn hơn hẳn.

# ví dụ thực tế

Xem một ví dụ thực tế cho dễ hình dung.

Giả sử bạn đang làm một server có hàng nghìn kết nối cùng lúc. Thay vì mỗi kết nối một thread, bạn dùng Virtual Threads để xử lý cho nhẹ.

import java.util.concurrent.*;
 
public class VirtualThreadExample {
    public static void main(String[] args) {
        // try-with-resources: close() chờ mọi task chạy xong (Java 19+)
        try (ExecutorService executorService = Executors.newVirtualThreadPerTaskExecutor()) {
            for (int i = 0; i < 1000; i++) {
                final int connectionId = i;
                executorService.submit(() -> {
                    // Xử lý kết nối
                    System.out.println("Handling connection " + connectionId + " in Virtual Thread");
                });
            }
        }
    }
}

Ở đây ứng dụng tạo 1000 Virtual Threads để xử lý các kết nối. Bản cũ của ví dụ này gọi shutdown() rồi để main kết thúc luôn. shutdown() không chờ task chạy xong, mà virtual thread lại là daemon, nên JVM thoát và phần lớn task không kịp chạy (mình chạy thử với task có sleep(10ms): 0/1000 task xong). Từ Java 19, ExecutorService là AutoCloseable, dùng try-with-resources như trên thì close() sẽ chờ hết task.

1000 thì platform thread vẫn gánh được. Lợi thế của virtual thread lộ rõ khi số tác vụ chờ I/O đồng thời lên hàng chục, hàng trăm nghìn.

# lưu ý khi dùng virtual threads

Virtual Threads hay thật, nhưng vẫn có vài chỗ cần để ý:

  • Không phải chỗ nào cũng hợp: Virtual Threads hợp nhất với ứng dụng có nhiều tác vụ đồng thời nhưng mỗi tác vụ không tốn nhiều tài nguyên. Với việc tính toán nặng (CPU-bound) thì nó chưa chắc là lựa chọn tốt nhất.
  • Đồng bộ hoá: Virtual Threads vẫn chạy đồng thời, nên chuyện đồng bộ giữa các thread vẫn phải lo. Có chia sẻ tài nguyên giữa các threads thì vẫn phải dùng synchronized, ReentrantLock như thường.
  • Pinning: trên Java 21-23, virtual thread blocking bên trong khối synchronized (hoặc trong native method) sẽ không unmount được, carrier thread bị "ghim" theo. Nhiều thread cùng bị ghim là hết carrier, ứng dụng đứng. Trên các bản này nên đổi synchronized bao quanh I/O thành ReentrantLock; từ Java 24 (JEP 491) thì synchronized không còn gây pinning nữa.
  • Đừng pool, hãy giới hạn tài nguyên: virtual thread rẻ, nhưng connection DB hay quota API bên ngoài thì không. Một triệu virtual thread cùng gọi DB có pool 20 connection thì 999.980 cái đứng chờ. Muốn giới hạn số tác vụ đồng thời thì dùng Semaphore, không phải giới hạn số thread.
  • ThreadLocal: vẫn dùng được, nhưng mỗi virtual thread một bản riêng, hàng triệu thread thì tốn bộ nhớ. Cũng đừng dùng ThreadLocal để cache object đắt tiền, vì virtual thread không được tái sử dụng.

# lời kết

Virtual Threads là một cải tiến lớn của Java 21 cho chuyện concurrency: giữ được kiểu code blocking dễ đọc mà vẫn phục vụ được rất nhiều tác vụ chờ I/O cùng lúc. Có nó rồi, Java đáp ứng tốt hơn cho ứng dụng web, hệ thống mạng, hay bất kỳ hệ thống nào phần lớn thời gian là chờ.

Với Spring Boot 3.2+, bật thử bằng spring.threads.virtual.enabled=true là cách nhanh nhất để thấy khác biệt. Muốn đi sâu hơn thì đọc thêm bài Java concurrency và những cái bẫy khi đưa virtual thread lên production.

Chỉ là những ghi chép cá nhân với hy vọng mang lại chút giá trị. Nếu thấy hữu ích, đừng ngại chia sẻ cho bạn bè & đồng nghiệp nhé!

Happy coding 😎 👍🏻 🚀 🔥.