Java 21 Virtual Threads: Có đáng bỏ Spring WebFlux không? · Pattern Quest
#java
#spring-boot
#virtual-threads
#webflux
#concurrency
Java 21 Virtual Threads: Có đáng bỏ Spring WebFlux không?Java 21 Virtual Threads: Should You Drop Spring WebFlux?
So sánh virtual threads của Java 21 với Spring WebFlux, giúp bạn quyết định kiến trúc nào phù hợp cho dịch vụ high‑concurrency hiện tại.A practical comparison between Java 21 virtual threads and Spring WebFlux to help you pick the right architecture for high‑concurrency services.
Khi nào bạn bắt đầu cảm thấy WebFlux "cực kỳ phức tạp"?#
Bạn đã có một dịch vụ Spring MVC truyền thống, rồi quyết định chuyển sang Spring WebFlux để đáp ứng p99 latency trong môi trường tải cao. Sau vài tuần, bạn gặp:
Các hàm Mono/Flux lẫn lộn với CompletableFuture, code rối.
Thư viện thứ ba (JDBC driver, SDK của bên thứ ba) vẫn block và buộc bạn phải subscribeOn(Schedulers.boundedElastic()).
Debugging trong IDE trở nên khó, vì mọi thứ chạy trên một event‑loop thread duy nhất.
Nếu bạn đang đứng ở ngã rẽ này, Java 21 Virtual Threads có thể là một con đường ngắn hơn.
Virtual thread (hay lightweight thread) là một thread do JVM quản lý, không phải OS thread. Mỗi virtual thread chiếm ~2 KB stack, thay vì vài MB của OS thread. Khi một virtual thread gặp Thread.sleep hay một cuộc gọi I/O block, JVM sẽ park thread đó và chuyển sang một thread thực (OS thread) khác để tiếp tục công việc còn lại. Điều này cho phép bạn viết code đồng bộ (blocking) mà vẫn đạt được mức độ đồng thời hàng chục ngàn.
Lưu ý: Virtual thread không thay thế non‑blocking I/O trong mọi trường hợp, nhưng chúng giảm bớt nhu cầu viết reactive code khi bạn chỉ cần đơn giản.
Spring Boot 3.2+ hỗ trợ tự động tạo Executor dựa trên Executors.newVirtualThreadPerTaskExecutor(). Bạn chỉ cần bật một property hoặc cung cấp bean Executor.
Java
@Configurationpublic class VirtualThreadConfig { @Bean public Executor taskExecutor() { // Mỗi task sẽ được chạy trên một virtual thread riêng return Executors.newVirtualThreadPerTaskExecutor(); }}
Khi bean Executor có trong ApplicationContext, Spring MVC sẽ tự động dùng nó cho các phương thức trả về CompletableFuture, Mono.fromFuture, …
@RestController@RequiredArgsConstructorpublic class OrderController { private final OrderService service; @GetMapping("/orders/{id}") public CompletableFuture<OrderDto> getOrder(@PathVariable Long id) { // Code viết như thường, blocking trong service được chấp nhận return CompletableFuture.supplyAsync(() -> service.findById(id)); }}
OrderService.findById có thể thực hiện JDBC blocking, HTTP client blocking, hoặc bất kỳ I/O nào. Nhờ virtual thread, mỗi request sẽ có một thread riêng nhưng không tốn bộ nhớ như khi tạo OS thread.
@RestController@RequiredArgsConstructorpublic class OrderController { private final OrderRepository repo; // Reactive repository @GetMapping("/orders/{id}") public Mono<OrderDto> getOrder(@PathVariable Long id) { return repo.findById(id) .map(entity -> new OrderDto(entity)); }}
Ở đây bạn phải dùng reactive repository (R2DBC) hoặc chuyển các blocking call sang publishOn(Schedulers.boundedElastic()). Nếu quên, một thread block sẽ làm event‑loop bị nghẽn, gây hiệu năng “thảm”.
Thread‑local: Virtual thread vẫn có ThreadLocal. Nếu bạn dùng ThreadLocal để lưu context (như user ID) thì hành vi không thay đổi – nhưng hãy cân nhắc dùng ReactorContext hoặc MDC tích hợp với VirtualThreadScheduler.
Thread‑dump: Khi bạn dump heap, mỗi virtual thread xuất hiện như một thread “pseudo‑native”. Điều này có thể làm các công cụ JFR/jstack bận rộn, nhưng vẫn giúp bạn thấy được số lượng đang hoạt động.
CPU‑bound work: Nếu công việc của bạn chủ yếu là tính toán, virtual threads không mang lại lợi ích lớn – chúng vẫn sẽ chiếm một OS thread khi chạy.
Kubernetes: Đảm bảo container limit đủ lớn (memory) để chứa hàng chục ngàn stack (2 KB mỗi). Thông thường, một pod 512 MiB có thể hỗ trợ >200k virtual threads.
Virtual threads của Java 21 không làm WebFlux lỗi thời; chúng chỉ cung cấp một con đường trung gian giữa mô hình thread‑per‑request truyền thống và mô hình event‑loop hoàn toàn. Đối với đa số các dự án Spring Boot hiện tại, đặc biệt là những hệ thống có phụ thuộc vào driver blocking, việc bật virtual threads sẽ giảm đáng kể code complexity và thời gian triển khai, đồng thời vẫn đạt được mức độ đồng thời đủ cho hầu hết các traffic thực tế.
Rule of thumb: Khi bạn muốn đánh đổi độ phức tạp code để có độ trễ ổn định và tiết kiệm bộ nhớ, hãy thử virtual threads trước. Chỉ chuyển sang WebFlux khi bạn thực sự cần back‑pressure, non‑blocking I/O toàn diện và scale tới hàng triệu request mỗi giây.
A virtual thread (also called a lightweight thread) is a thread managed by the JVM rather than the OS. Each virtual thread consumes only ~2 KB of stack, compared to several MB for a native OS thread. When a virtual thread hits a blocking call like Thread.sleep or a synchronous I/O operation, the JVM parks that virtual thread and schedules another OS thread to keep the CPU busy. This allows you to write synchronous (blocking) code while still achieving tens of thousands of concurrent executions.
Note: Virtual threads do not replace non‑blocking I/O in every scenario, but they dramatically reduce the need to write reactive code when you only need simplicity.
Spring Boot 3.2+ can automatically create an Executor based on Executors.newVirtualThreadPerTaskExecutor(). You can turn it on via a property or by providing an Executor bean.
Java
@Configurationpublic class VirtualThreadConfig { @Bean public Executor taskExecutor() { // Each submitted task runs on its own virtual thread return Executors.newVirtualThreadPerTaskExecutor(); }}
When an Executor bean is present, Spring MVC will use it for controller methods that return CompletableFuture, Mono.fromFuture, etc.
Code comparison: MVC + Virtual Threads vs WebFlux#
@RestController@RequiredArgsConstructorpublic class OrderController { private final OrderService service; @GetMapping("/orders/{id}") public CompletableFuture<OrderDto> getOrder(@PathVariable Long id) { // Business logic stays synchronous; blocking DB calls are fine return CompletableFuture.supplyAsync(() -> service.findById(id)); }}
OrderService.findById can perform a blocking JDBC query, a blocking HTTP client call, or any other I/O. Thanks to virtual threads, each request gets its own thread without the massive memory overhead of native threads.
@RestController@RequiredArgsConstructorpublic class OrderController { private final OrderRepository repo; // Reactive repository @GetMapping("/orders/{id}") public Mono<OrderDto> getOrder(@PathVariable Long id) { return repo.findById(id) .map(entity -> new OrderDto(entity)); }}
Here you must use a reactive repository (R2DBC) or explicitly shift blocking calls onto publishOn(Schedulers.boundedElastic()). Forgetting this will block the event‑loop thread, causing a classic “reactive bottleneck”.
Thread‑local: Virtual threads still have ThreadLocal. If you rely on ThreadLocal for request context (e.g., user ID), the behaviour stays the same – consider switching to ReactorContext or MDC that integrates with the virtual‑thread scheduler.
Thread‑dump size: A dump will list thousands of pseudo‑native threads. Tools like JFR or jstack can still show you the count, but the output may be larger than you expect.
CPU‑bound workloads: If your tasks are heavy calculations, virtual threads don’t bring benefits – they still need a native OS thread to run.
Kubernetes limits: Make sure your container’s memory limit can accommodate the stack of many virtual threads (2 KB each). In practice, a 512 MiB pod can host >200 k virtual threads.
Java 21 virtual threads do not make WebFlux obsolete; they simply add a middle ground between the classic thread‑per‑request model and a full event‑loop architecture. For the majority of Spring Boot applications that still rely on blocking drivers, turning on virtual threads dramatically reduces code complexity and deployment time while still delivering sufficient concurrency for real‑world traffic.
Rule of thumb: When you want to trade off code simplicity for stable latency and memory savings, try virtual threads first. Only move to WebFlux when you truly need back‑pressure, non‑blocking I/O everywhere, and massive scale.