Java 21 virtual threads: blocking code that scales without reactive complexity
Virtual threads (JEP 444) let you keep blocking I/O code while scaling to tens of thousands of concurrent requests. This blog covers the mechanics, a fan‑out example, and the pinning, ThreadLocal, and pooling trade‑offs.
17 May 2026, 12:26 UTC

The problem: thread‑per‑request stalls under load
If you run a thread‑per‑request web service on the JVM, you've hit this wall: each request gets a thread, each thread is an OS thread with a megabyte‑ish of reserved stack, and somewhere in the low thousands of concurrent requests the machine gives up — not because the CPU is busy, but because you've run out of threads. The work itself is mostly waiting on a database or a downstream HTTP API.
The traditional escape hatch was reactive programming: rewrite everything as non‑blocking callbacks or Mono/Flux chains so a handful of threads never sit idle. It works, but the code becomes harder to read, debug, and stack‑trace, and the complexity leaks into every layer it touches.
What a virtual thread actually is
A virtual thread is a java.lang.Thread that the JDK schedules itself, rather than handing to the operating system. The JVM runs virtual threads on a small pool of real carrier threads — a ForkJoinPool whose parallelism defaults to your CPU count. When a virtual thread hits a blocking operation the JDK knows about (socket I/O, Thread.sleep, most of java.util.concurrent), it parks: its stack is stored on the heap and the carrier is released to run another virtual thread. When the I/O completes, the virtual thread is rescheduled.
The practical consequence: you can have tens of thousands of virtual threads alive at once, because the expensive ones — the carriers — number in the dozens. Blocking is no longer scarce, so the thread‑per‑request model stops being a liability.
A worked example: fan‑out without a pool
Say you need to call an internal pricing service for 10 000 SKUs and collect the results. With platform threads you'd size a pool, argue about the number, and still serialize most of the work. With virtual threads, each call gets its own thread:
// Run on JDK 21+. No special permissions needed; this is plain application code.\nrecord Price(String sku, BigDecimal amount) {}\n\nList fetchAll(List skus) {\n // The executor is AutoCloseable: close() waits for all submitted tasks.\n try (var exec = Executors.newVirtualThreadPerTaskExecutor()) {\n var futures = skus.stream()\n .map(sku -> exec.submit(() -> pricingClient.fetch(sku))) // blocking HTTP call\n .toList();\n return futures.stream()\n .map(f -> {\n try {\n return f.get();\n } catch (InterruptedException e) {\n Thread.currentThread().interrupt();\n throw new RuntimeException(e);\n } catch (ExecutionException e) {\n throw new RuntimeException(e.getCause());\n }\n })\n .toList();\n }\n}Two things are doing the heavy lifting. newVirtualThreadPerTaskExecutor() spawns a fresh virtual thread per task — there is no queue depth or pool size to tune. And try‑with‑resources on the executor gives you structured shutdown: leaving the block waits for everything to finish, so you don't need manual awaitTermination boilerplate.
To verify the threads really are virtual, log Thread.currentThread() inside a task — you'll see something like VirtualThread[#42]/runnable@ForkJoinPool-1-worker-3, where the part after @ is the carrier. For a deeper look, jcmd <pid> Thread.dump_to_file -format=json threads.json captures virtual threads, which classic jstack output largely omits.
Trade‑offs that can bite
- Pinning. A virtual thread can only release its carrier when the JDK controls the blocking point. On JDK 21, if a virtual thread blocks while inside a
synchronizedblock or a native (JNI) call, it stays pinned to its carrier — the carrier can't run anything else. Enough pinned threads under load and you're back to platform‑thread scarcity, only harder to diagnose. Watch for it with-Djdk.tracePinnedThreads=fullduring load tests. Note that JEP 491 (JDK 24) removed thesynchronizedcase, so this behavior is version‑dependent — check the release notes for the JDK you deploy on. - Don't pool virtual threads. They're cheap to create; pooling them reintroduces artificial scarcity for no benefit. If you need to cap concurrency against a downstream service — say, your database tolerates 200 connections — use a
Semaphorewith 200 permits around the call, not a 200‑thread executor. - ThreadLocal memory. Code that caches large objects in
ThreadLocalwas fine with a few hundred pooled threads. With tens of thousands of live virtual threads, each holding its own copy, that habit turns into a real heap problem. AuditThreadLocalusage before flipping the switch. - CPU‑bound work gains nothing. Virtual threads help when threads spend their lives waiting. A computation that saturates the CPU still wants roughly one platform thread per core; wrapping it in virtual threads just adds scheduling overhead.
Where this leaves you
The honest summary: virtual threads remove the scalability argument for rewriting I/O‑bound services as reactive code, but they don't remove the need to think. Your bottleneck may simply move downstream — 10 000 concurrent calls will find the limit of your database or the other team's API, so keep backpressure (semaphores, connection pool sizes) deliberate. Before adopting, confirm your framework's support (Spring Boot 3.2+, Helidon, and Quarkus all have first‑class virtual‑thread options), run the fan‑out example above on your target JDK, and load‑test with -Djdk.tracePinnedThreads enabled. If your service is mostly blocking I/O — and most business services are — this is the rare platform upgrade that lets you delete complexity instead of adding it.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.