Java Virtual Threads Explained
Virtual threads make blocking I/O cheap again. Here is how they work under the hood, when to use them, and what changes in your code, from a practical perspective.
What you'll learn
- ✓What a virtual thread actually is
- ✓How carrier threads schedule virtual threads
- ✓When virtual threads help and when they do not
- ✓Pinning and other pitfalls
- ✓Practical migration patterns
Prerequisites
- •Basic familiarity with the language
For two decades, Java threads were operating-system threads. They were heavy, expensive to create, and limited in number. Frameworks worked around this with thread pools, reactive APIs, and increasingly contorted callback chains. Project Loom finished that detour. Virtual threads, available since JDK 21, let you write straight-line blocking code and run a million of them on a single JVM.
What a virtual thread is
A virtual thread is a Thread that is scheduled by the JVM, not the operating system. When a virtual thread blocks on I/O, the JVM parks it and frees the underlying OS thread to run something else. When the I/O completes, the JVM resumes the virtual thread on whatever OS thread is available.
Thread t = Thread.startVirtualThread(() -> {
String body = httpClient.send(req, BodyHandlers.ofString()).body();
System.out.println(body);
});
t.join();
The API is exactly Thread. Existing code that uses Runnable, ExecutorService, or ThreadLocal keeps working. The change is in cost: a virtual thread takes a few hundred bytes instead of a megabyte of stack.
The mental model
Virtual threads sit on top of a small pool of platform threads called carriers. The JVM mounts a virtual thread onto a carrier when it has work to do and unmounts it when it blocks.
Virtual threads: [v1] [v2] [v3] [v4] [v5] ... [v1000]
\ | / \ |
\ | / \ |
Carriers (OS): [ C1 ] [ C2 ] [ C3 ] [ C4 ]
(kernel scheduling) The carrier pool defaults to the number of available CPUs. That is sufficient because virtual threads should spend most of their time blocked on I/O, not burning CPU. When all virtual threads in flight are doing network or disk waits, only a handful of carriers ever need to be active.
Why this matters
Traditional thread-per-request servers cap out around ten thousand connections because each connection consumes a real thread. Reactive code (Netty, Reactor, Webflux) scales further but forces you to write everything as non-blocking callbacks and forbids any blocking call anywhere in the request path.
Virtual threads let you go back to the simple model: one thread per request, blocking calls allowed, straight-line code. The JVM handles the multiplexing.
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
for (Request r : incoming) {
executor.submit(() -> handle(r));
}
}
newVirtualThreadPerTaskExecutor() creates a virtual thread for every submitted task. There is no pool to size, no queue to tune.
Hands-on example
A simple fan-out: fetch ten URLs in parallel and combine results.
List<URI> urls = List.of(/* ... */);
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
List<Future<String>> futures = urls.stream()
.map(u -> executor.submit(() -> fetch(u)))
.toList();
List<String> bodies = futures.stream()
.map(f -> {
try { return f.get(); }
catch (Exception e) { throw new RuntimeException(e); }
})
.toList();
}
Each fetch call blocks. Each call runs on its own virtual thread. The total wall time is roughly the slowest fetch, not the sum.
For structured workflows, prefer StructuredTaskScope, which gives proper cancellation and error propagation.
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
var user = scope.fork(() -> fetchUser(id));
var orders = scope.fork(() -> fetchOrders(id));
scope.join().throwIfFailed();
return new Profile(user.get(), orders.get());
}
Common pitfalls
Pinning. A virtual thread is pinned to its carrier when it cannot be unmounted. The classic cause is a synchronized block held across an I/O call. While pinned, the carrier cannot run other virtual threads, so high-traffic systems can starve. Replace synchronized with ReentrantLock where pinning is a concern; recent JDKs are reducing the number of pinning cases.
Thread-local abuse. Virtual threads support ThreadLocal, but with a million threads, even a few kilobytes per thread adds up. Prefer scoped values (ScopedValue) for request-scoped context.
Blocking the carrier with CPU work. Virtual threads do not help if your workload is CPU-bound. The carrier pool is the same size as your CPU count; running heavy compute on virtual threads gives you no extra parallelism.
Sizing executors as if they were pools. There is no pool. Do not call newFixedThreadPool and pass it to virtual code expecting magic; just use the virtual-thread-per-task executor.
Practical tips
Audit your code for synchronized on hot paths and for shared ThreadLocal state. Both predate virtual threads and may need adjustment. Update libraries: older JDBC drivers and HTTP clients use native locks internally and can pin. Modern versions of most major libraries are virtual-thread-friendly.
Keep observability sane. A thread dump with 100k virtual threads is unreadable. Use jcmd Thread.dump_to_file -format=json for structured dumps you can grep.
Wrap-up
Virtual threads bring Java back to a simple programming model without giving up scale. Blocking code is fine again. Reactive frameworks still have a place for streaming and backpressure, but for typical request-response services, virtual threads are the new default. Watch for pinning, prefer scoped values over thread locals, and stop worrying about pool sizing.
Related articles
- Java Java Collections Framework Cheatsheet
A pragmatic tour of Java's collection interfaces and implementations, with guidance on choosing between List, Set, Map, and Queue variants in real applications.
- Java Java Concurrency with CompletableFuture
How CompletableFuture composes asynchronous work in Java: chaining, combining, error handling, executors, and the patterns that keep concurrent code readable.
- Java Java Multithreading and Synchronization: A Practical Guide
Understand threads, the Java memory model, synchronization, locks, and concurrent collections. A practical guide to writing correct multithreaded Java code.
- Java Java Streams API Deep Dive
A practical tour of the Java Streams API: how it works, when to use it, lazy evaluation, collectors, parallel streams, and the pitfalls that trip up newcomers.