Java 21 Virtual Threads: Simplifying High‑Concurrency I/O in Production
Why Virtual Threads Matter
For years Java developers have wrestled with the trade‑off between thread‑per‑request simplicity and the scalability limits of platform threads. Each platform thread consumes a sizable stack (default 1 MB) and a kernel‑level context, so a server handling tens of thousands of concurrent HTTP calls quickly runs out of memory or OS thread handles.
Java 21 introduces virtual threads (JEP 444) — lightweight, user‑mode threads that are scheduled by the JVM instead of the OS. They keep the familiar Thread API but cost only a few kilobytes each, allowing millions of concurrent tasks on modest hardware.
Key insight: Virtual threads turn blocking I/O into a non‑blocking operation under the hood, so you can write straight‑line code while the runtime parks the virtual thread and reuses the underlying carrier thread.
Getting Started with Virtual Threads
Enabling them is a one‑liner. The Thread.Builder API lets you create a virtual thread factory or start a virtual thread directly.
// Java 21+ – start a virtual thread
Thread.startVirtualThread(() -> {
System.out.println("Running on a virtual thread: " + Thread.currentThread());
});
// Or a factory for repeated use
ThreadFactory virtualFactory = Thread.ofVirtual().factory();
ExecutorService executor = Executors.newThreadPerTaskExecutor(virtualFactory);
Notice the Thread.ofVirtual() builder — it returns a Thread.Builder.OfVirtual that can be tuned (name prefix, daemon status, etc.) before producing threads.
Real‑World Example: Parallel HTTP Calls
Imagine a service that aggregates data from three downstream APIs for each incoming request. With platform threads you’d either block a thread per call (wasting resources) or rewrite everything with CompletableFuture and reactive streams. Virtual threads let you keep the simple synchronous style while still scaling.
record AggregatedResponse(String user, String orders, String recommendations) {}
public AggregatedResponse fetchAll(String userId) throws InterruptedException, ExecutionException {
try (var executor = Executors.newThreadPerTaskExecutor(Thread.ofVirtual().factory())) {
// Each task runs on its own virtual thread
var userFuture = executor.submit(() -> httpClient.get("https://user-service/" + userId));
var ordersFuture = executor.submit(() -> httpClient.get("https://order-service/" + userId));
var recFuture = executor.submit(() -> httpClient.get("https://rec-service/" + userId));
// Block only the caller virtual thread; carrier threads stay free
return new AggregatedResponse(
userFuture.get(),
ordersFuture.get(),
recFuture.get()
);
}
}
The ExecutorService created by Executors.newThreadPerTaskExecutor spawns a fresh virtual thread for each submitted task. The calling thread (also virtual if the request handler runs on a virtual thread) parks at get() without consuming a platform thread.
- No callback hell — code reads top‑to‑bottom.
- Structured concurrency — the try‑with‑resources block guarantees all child tasks finish or are cancelled before the method returns.
- Exception propagation works exactly like synchronous code; a failure in any sub‑call surfaces as an
ExecutionException.
Error Handling and Structured Concurrency
Java 21 also ships StructuredTaskScope (JEP 453) which pairs naturally with virtual threads. It gives you a scope where all subtasks are children of the current task, automatically handling cancellation and error aggregation.
public AggregatedResponse fetchWithScope(String userId) throws InterruptedException {
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
var userSub = scope.fork(() -> httpClient.get("https://user-service/" + userId));
var ordersSub = scope.fork(() -> httpClient.get("https://order-service/" + userId));
var recSub = scope.fork(() -> httpClient.get("https://rec-service/" + userId));
scope.join(); // wait for all
scope.throwIfFailed(); // rethrow first exception if any
return new AggregatedResponse(
userSub.get(),
ordersSub.get(),
recSub.get()
);
}
}
ShutdownOnFailure cancels the remaining subtasks as soon as one fails, preventing wasted work. The scope itself runs on a virtual thread, so the whole aggregation stays lightweight.
Performance Considerations
Virtual threads shine when the workload is I/O‑bound — database queries, HTTP calls, file reads. They do not magically speed up CPU‑intensive loops; those still need a limited pool of carrier threads (usually one per CPU core). A good rule of thumb:
- Use virtual threads for any task that
awaits on a blocking operation. - Keep a small fixed‑size
ForkJoinPool(the default carrier pool) for pure compute work.
Monitoring is straightforward: jcmd now distinguishes virtual threads (marked virtual) from platform threads. You can also enable -Djdk.traceVirtualThreadEvents for detailed lifecycle logs.
When to Stick with Platform Threads
There are still cases where platform threads are preferable:
- Native integration — JNI calls that block the OS thread (e.g., certain database drivers) cannot be parked.
- Thread‑local heavy code — libraries that rely on
ThreadLocalwith large objects may cause memory bloat if thousands of virtual threads each hold a copy. - Real‑time constraints — deterministic latency requirements sometimes need direct OS scheduling.
In those scenarios you can still mix both: run the critical section on a platform thread via Executors.newFixedThreadPool and offload the surrounding I/O to virtual threads.
Bottom line: Virtual threads let you write synchronous, readable code that scales to millions of concurrent operations. Adopt them for new services and gradually migrate existing thread‑per‑request code — your future self (and the GC) will thank you.