Java Reactive Programming & Virtual Threads Cheat Sheet
A practical reference covering why thread-per-request breaks at scale, Reactor’s Mono/Flux model, the flatMap vs concatMap distinction, backpressure, and Project Loom’s virtual threads.
Thread-per-Request vs Reactive
Thread-per-request breaks at scale. 10k simultaneous connections = 10k threads = ~80 GB of stack memory + enormous context-switch overhead. Reactive uses a small event-loop thread pool (CPU core count) and never blocks — I/O waits become callbacks.
Thread-per-request (blocking) Reactive (non-blocking)
───────────────────────────── ────────────────────────
Thread 1: ████░░░░░░████░░░░░ Event loop: ████████████
Thread 2: ██░░░░░░████░░░░░░░ (2-4 threads handle 10k connections)
Thread 3: ░░████░░░░░░████░░░
...
Thread N: ░░░░████░░░░░░████░
█ = working ░ = blocked on I/O
Mono & Flux
Mono: 0 or 1 item. Flux: 0 to N items. Both are lazy — nothing executes until someone subscribes. Operators are composable and form a processing pipeline.
// Mono — single async value
Mono<Account> account = accountRepository.findById(id); // lazy, no DB call yet
account.subscribe(a -> System.out.println(a)); // NOW it runs
// Flux — stream of values
Flux<Transaction> txns = transactionRepository.findByAccount(id);
txns
.filter(t -> t.amount().compareTo(BigDecimal.ZERO) > 0)
.map(t -> new TxnDTO(t.id(), t.amount()))
.take(100)
.subscribe(dto -> send(dto));
// Common operators
Flux.range(1, 10)
.map(i -> i * 2) // transform each element
.filter(i -> i > 5) // keep matching
.flatMap(i -> fetchAsync(i)) // concurrent async for each
.collectList() // gather into Mono<List>
.block(); // subscribe + block (only in tests!)
// Combining
Mono.zip(fetchUser(id), fetchAccount(id))
.map(tuple -> new UserAccount(tuple.getT1(), tuple.getT2()));
flatMap vs concatMap
The most important Reactor distinction for banking: flatMap is concurrent and unordered, concatMap is sequential and ordered. Use concatMap when processing order matters (ledger entries, event sourcing).
Input: [A, B, C] (each triggers async work)
flatMap (concurrent, unordered):
A ──────────────▶ result_A (A and B run at the same time)
B ──────▶ result_B (B finishes first)
C ──────────────────▶ result_C
Output: [result_B, result_A, result_C] ← order NOT preserved
concatMap (sequential, ordered):
A ──────────────▶ result_A
B ──────▶ result_B (B starts only after A done)
C ──▶ result_C
Output: [result_A, result_B, result_C] ← order preserved
// flatMap — concurrent, good for independent lookups
Flux.fromIterable(accountIds)
.flatMap(id -> accountService.findById(id)) // all fire concurrently
.collectList();
// flatMap with concurrency limit
Flux.fromIterable(accountIds)
.flatMap(id -> accountService.findById(id), 5) // max 5 concurrent
// concatMap — sequential, use for ordered operations
Flux.fromIterable(ledgerEvents)
.concatMap(event -> applyEvent(event)) // strictly one at a time
.doOnNext(result -> log.info("applied: {}", result));
// Gotcha: flatMap error handling
flux.flatMap(item ->
process(item)
.onErrorResume(e -> Mono.empty()) // skip failed items
);
Backpressure
When a producer emits faster than a consumer can process, backpressure prevents buffer overflow. Reactor implements the Reactive Streams spec — the subscriber requests N items, and the producer respects that demand.
// Backpressure strategies
Flux.range(1, 1_000_000)
.onBackpressureBuffer(1000) // buffer up to 1000, error if exceeded
.onBackpressureDrop() // silently drop items consumer can't keep up with
.onBackpressureLatest() // keep only the most recent unprocessed item
.onBackpressureError(); // throw OverflowException immediately
// Subscriber controlling demand
Flux.range(1, 100)
.subscribe(new BaseSubscriber<Integer>() {
protected void hookOnSubscribe(Subscription s) {
request(10); // request 10 items initially
}
protected void hookOnNext(Integer value) {
process(value);
request(1); // request 1 more after each processed
}
});
// Connecting a slow consumer to a fast producer
Flux.interval(Duration.ofMillis(1)) // emits every 1ms
.onBackpressureDrop()
.publishOn(Schedulers.boundedElastic())
.subscribe(i -> {
Thread.sleep(100); // processing takes 100ms — far slower than producer
});
Virtual Threads (Project Loom)
Project Loom brings cheap threads — millions can exist simultaneously. Each virtual thread is a thin wrapper; the JVM mounts them onto carrier (OS) threads only when running. Blocking I/O automatically unmounts the virtual thread, freeing the carrier. Structured concurrency brings task lifecycle management.
Virtual Threads Platform Thread
┌──────┐ ┌──────┐ ┌──────┐ ┌──────────┐
│ VT1 │ │ VT2 │ │ VT3 │ ───▶ │Carrier 1 │ (OS thread)
└──────┘ └──────┘ └──────┘ └──────────┘
┌──────┐ ┌──────┐ ┌──────────┐
│ VT4 │ │ VT5 │ ───▶ │Carrier 2 │ (OS thread)
└──────┘ └──────┘ └──────────┘
VT blocks on I/O → unmounted → carrier picks up next runnable VT
// Create virtual threads (Java 21)
Thread vt = Thread.ofVirtual().start(() -> handleRequest(req));
// ExecutorService with virtual threads
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 0; i < 100_000; i++) {
executor.submit(() -> {
// blocking I/O here is fine — the carrier thread is freed
String data = httpClient.get(url); // blocks VT, not carrier
process(data);
});
}
} // auto-shutdown + await
// Structured concurrency (Java 21 preview)
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
Future<User> user = scope.fork(() -> fetchUser(id));
Future<Account> account = scope.fork(() -> fetchAccount(id));
scope.join(); // wait for both
scope.throwIfFailed(); // propagate any error
return new UserAccount(user.get(), account.get());
}
// If fetchUser fails, fetchAccount is automatically cancelled
// Virtual threads vs Reactor:
// Virtual threads: simpler code, blocking style, good for thread-per-request
// Reactor: more control, backpressure, composable pipelines, mature ecosystem
