Skip to content
Go back

Java Reactive Programming: Project Reactor Internals vs. Virtual Threads

Table of contents

Open Table of contents

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:

  1. EventLoopGroup (Netty): WebFlux starts a small, fixed-size thread pool (typically equal to the number of physical CPU cores) running a Netty EventLoop.
  2. OS Selectors (epoll/kqueue): The EventLoop registers socket channels with the operating system kernel’s multiplexers (e.g., epoll on Linux, kqueue on macOS).
  3. 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.
  4. 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:


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)?

  1. 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.
  2. Encountering a Blocking Operation: The code reaches a blocking point (e.g., waiting for an input stream read).
  3. 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$).
  4. 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.
  5. 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:

Production Recommendation: Replace blocking synchronized locks with Java’s java.util.concurrent.locks.ReentrantLock to prevent carrier thread starvation.


3. Reactor vs. Virtual Threads: Deep Comparison

FeatureProject Reactor (Spring WebFlux)Virtual Threads (Java 21 / Loom)
Programming ParadigmDeclarative, Functional, Event-DrivenImperative, Standard blocking code
Learning CurveExtremely Steep (Reactor Operators)Very Shallow (Standard Java)
Debugging & ProfilingVery Hard (Stack traces lose lexical context)Simple (Standard stack traces work)
Memory FootprintExtremely low ($~1\text{ KB}$ per publisher)Low ($~2-10\text{ KB}$ heap stack allocation)
Backpressure SupportNative (Reactive streams specs)Manual (Rate limiters/blocking queues)
Database SupportRequires R2DBC (Non-blocking JDBC)Works with standard blocking JDBC/JPA
IntegrationRequires fully reactive downstream librariesDrop-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


Share this post on:

Previous Post
DynamoDB Architecture: Deep Dive into Partitioning and Adaptive Capacity
Next Post
Database Multitenancy Design: Pooled vs. Siloed Architectures