Table of contents
Open Table of contents
- Concurrency Models: The Event Loop vs. Lightweight Threads
- 1. Project Reactor: Inside the Non-Blocking Event Loop
- 2. Project Loom: Inside Virtual Threads mounting mechanics
- 3. Reactor vs. Virtual Threads: Deep Comparison
- 4. Practical Implementation: Comparative Code
- Summary Checklist for Java Developers
Concurrency Models: The Event Loop vs. Lightweight Threads
For a long time, Java’s concurrency relied strictly on a thread-per-request architecture. Web containers like Tomcat allocated a dedicated operating system platform thread to handle each incoming TCP connection.
But as services scale and call multiple downstream REST APIs or databases, this model hits a hard wall. OS threads are heavy ($1\text{ MB}$ default stack allocation, high context-switching costs), and we struggle to scale them past a few thousand.
To get around this, we adopted Reactive Programming (Project Reactor, Spring WebFlux), moving to non-blocking event-driven pipelines. However, since Java 21, Virtual Threads (Project Loom) allow us to write simple, blocking, imperative code that scales to millions of threads in the JVM.
Let’s look at the internal mechanics of Project Reactor’s EventLoop, dissect how Project Loom mounts and dismounts virtual threads at the JVM stack frame level, and compare their operational differences under load.
1. Project Reactor: Inside the Non-Blocking Event Loop
Project Reactor achieves high concurrency by separating the execution thread from I/O wait states. Instead of blocking a thread while waiting for a database query to return, the thread is released immediately to handle other tasks.
[ Incoming Requests ]
|
v (Socket Channels)
+-----+-----------------------------------------------------------------+
| Reactor Netty EventLoopGroup (fixed thread size = CPU Core Count) |
| |
| [ Epoll / Kqueue Selector Loop ] |
| | |
| | Trigger on I/O Read/Write Events |
| v |
| [ Event Worker Thread 1 ] -> Non-blocking Callback Pipeline |
| [ Event Worker Thread 2 ] -> Non-blocking Callback Pipeline |
+-----------------------------------------------------------------------+
The Architecture:
- EventLoopGroup (Netty): WebFlux starts a small, fixed-size thread pool (typically equal to the number of physical CPU cores) running a Netty EventLoop.
- OS Selectors (epoll/kqueue): The EventLoop registers socket channels with the operating system kernel’s multiplexers (e.g.,
epollon Linux,kqueueon macOS). - Reactive Stream Pipeline: When a request arrives, Netty’s selector thread detects the socket read state, wraps it in a Publisher (
Mono/Flux), and starts executing the chain of operations. - Non-blocking Callbacks: If the code makes a downstream HTTP call:
- The selector registers the request socket and registers a callback.
- The thread does not sleep. It immediately returns to Netty’s thread pool to process other incoming request events.
- When the downstream response packet lands, the kernel triggers an interrupt. The selector detects it, acquires a thread, and executes the callback to process the response.
The Mental Shift & Thread Schedulers:
In Reactor, you do not write imperative code. You write a functional assembly line. Threads are managed via Schedulers:
Schedulers.immediate(): Executes the callback on the current thread instantly.Schedulers.boundedElastic(): Maintains a dynamic pool of threads reserved for legacy blocking operations (like JDBC database calls).publishOnvssubscribeOn:publishOn(Scheduler): Forces all downstream operators in the chain to switch execution to the specified thread pool.subscribeOn(Scheduler): Directs the upstream data source assembly to execute on the specified scheduler pool during subscription initiation.
2. Project Loom: Inside Virtual Threads mounting mechanics
Java 21’s Virtual Threads (Project Loom) completely bypasses the need for reactive API pipelines by introducing a lightweight thread managed directly by the JVM instead of the operating system.
A virtual thread is an instance of java.lang.Thread that is not tied to a specific OS thread. Instead, the JVM maps millions of virtual threads onto a small pool of standard OS threads (called Carrier Threads, managed by a ForkJoinPool).
+-----------------------------------------------------------------------+
| JVM RUNTIME |
| |
| [ Virtual Thread 1 ] [ Virtual Thread 2 ] [ Virtual Thread 3 ] |
| | | | |
| +---------------------+---------------------+ |
| | (Dynamically mounted/dismounted) |
| v |
| [ OS Carrier Thread 1 ] |
| (ForkJoinPool Worker) |
+-----------------------------------------------------------------------+
The Mounting and Dismounting Mechanics:
What happens under the hood when a virtual thread executes a blocking network call (e.g., a standard HTTP client get request)?
- Mounting: The JVM schedules the Virtual Thread ($V_1$) to execute. It mounts $V_1$ onto a physical OS Carrier Thread ($C_1$). The carrier thread executes $V_1$‘s bytecode instructions.
- Encountering a Blocking Operation: The code reaches a blocking point (e.g., waiting for an input stream read).
- The Yield Mechanism:
- The virtual thread calls a blocking API. Under Java 21, all standard I/O classes in the JDK (
Socket,ServerSocket,FileChannel) have been rewritten to be Loom-aware. - Instead of issuing a blocking OS system call, the JVM catches the block, captures the virtual thread’s current execution context, and dismounts $V_1$ from its carrier thread ($C_1$).
- The virtual thread calls a blocking API. Under Java 21, all standard I/O classes in the JDK (
- Stack Frame Eviction:
- The JVM copies the virtual thread’s stack frames (local variables, call stack) from the physical CPU call stack to the standard JVM Heap Memory.
- The Carrier Thread ($C_1$) is now completely free! It immediately picks up another virtual thread to execute.
- Waking Up:
- The JVM uses background pollers to monitor the blocked socket.
- Once the network data arrives, the poller marks $V_1$ as runnable.
- The JVM schedules $V_1$ to run again, mounting it onto any available carrier thread (it does not have to be $C_1$), copying the stack frames from the heap back onto the physical call stack, and resuming execution exactly where it blocked.
The Carrier Thread Pinning Pitfall
There are scenarios where a virtual thread cannot be dismounted from its carrier thread during a blocking operation. This is called Thread Pinning:
- Synchronized Blocks/Methods: If a virtual thread blocks inside a
synchronizedblock or method, the JVM pins it. The carrier thread blocks as well, temporarily removing a physical thread from the carrier pool. - Native Methods: If the virtual thread invokes a native function (JNI) and blocks inside native C/C++ code.
Production Recommendation: Replace blocking
synchronizedlocks with Java’sjava.util.concurrent.locks.ReentrantLockto prevent carrier thread starvation.
3. Reactor vs. Virtual Threads: Deep Comparison
| Feature | Project Reactor (Spring WebFlux) | Virtual Threads (Java 21 / Loom) |
|---|---|---|
| Programming Paradigm | Declarative, Functional, Event-Driven | Imperative, Standard blocking code |
| Learning Curve | Extremely Steep (Reactor Operators) | Very Shallow (Standard Java) |
| Debugging & Profiling | Very Hard (Stack traces lose lexical context) | Simple (Standard stack traces work) |
| Memory Footprint | Extremely low ($~1\text{ KB}$ per publisher) | Low ($~2-10\text{ KB}$ heap stack allocation) |
| Backpressure Support | Native (Reactive streams specs) | Manual (Rate limiters/blocking queues) |
| Database Support | Requires R2DBC (Non-blocking JDBC) | Works with standard blocking JDBC/JPA |
| Integration | Requires fully reactive downstream libraries | Drop-in replacement for existing frameworks |
4. Practical Implementation: Comparative Code
Option A: The Project Reactor Approach
import reactor.core.publisher.Mono;
import org.springframework.web.reactive.function.client.WebClient;
public class ReactiveUserService {
private final WebClient webClient = WebClient.create("https://api.users.com");
public Mono<UserResponse> fetchUserAndOrders(String userId) {
return webClient.get()
.uri("/users/" + userId)
.retrieve()
.bodyToMono(User.class)
// Non-blocking flatMap to fetch orders sequentially
.flatMap(user -> webClient.get()
.uri("/users/" + user.getId() + "/orders")
.retrieve()
.bodyToMono(OrderList.class)
.map(orders -> new UserResponse(user, orders))
);
}
}
Option B: The Virtual Threads Approach (Java 21)
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.net.URI;
public class VirtualThreadUserService {
private final HttpClient httpClient = HttpClient.newBuilder()
// Configure executor to run using Virtual Threads
.executor(java.util.concurrent.Executors.newVirtualThreadPerTaskExecutor())
.build();
public UserResponse fetchUserAndOrders(String userId) throws Exception {
// Look! Simple, clean, blocking imperative code!
// 1. Fetch User (blocks virtual thread, dismounts from carrier)
var userRequest = HttpRequest.newBuilder(URI.create("https://api.users.com/users/" + userId)).build();
String userJson = httpClient.send(userRequest, HttpResponse.BodyHandlers.ofString()).body();
User user = deserialize(userJson, User.class);
// 2. Fetch Orders (blocks virtual thread, dismounts from carrier)
var orderRequest = HttpRequest.newBuilder(URI.create("https://api.users.com/users/" + user.getId() + "/orders")).build();
String orderJson = httpClient.send(orderRequest, HttpResponse.BodyHandlers.ofString()).body();
OrderList orders = deserialize(orderJson, OrderList.class);
return new UserResponse(user, orders);
}
private <T> T deserialize(String json, Class<T> clazz) { /* ... */ return null; }
}
Summary Checklist for Java Developers
- Legacy Projects: If you have existing Tomcat-based Spring Boot MVC applications, migrate them to Java 21 Virtual Threads by enabling them via
spring.threads.virtual.enabled=true. Avoid refactoring to WebFlux unless you need reactive pipeline operators. - R2DBC vs JDBC: Project Loom allows you to scale standard blocking JPA/Hibernate database connections efficiently without moving to the complex R2DBC reactive driver ecosystem.
- Beware of Pinning: Scan your project for legacy
synchronizedblocks inside key execution loops. Refactor them toReentrantLockto protect your Loom carrier threads. - Event-Driven Architectures: Project Reactor remains highly valuable for building complex, event-driven pipelines where data streaming, backpressure, windowing, and rate limiting are core functional requirements.