Virtual Threads in Java 21: When They Help and When They Don't
Virtual threads make blocking code scale to hundreds of thousands of concurrent tasks — but only for I/O-bound work. How they work, how to use them in Spring Boot, and the pitfalls.
Virtual threads (Project Loom, final in Java 21) are the biggest change to Java concurrency in years. They let you write simple, blocking, thread-per-request code — and still handle huge numbers of concurrent requests. But they're not a general "make it faster" switch. Here's what they are, how they work, and when to use them.
The problem they solve
A classic Java server uses one platform thread per request. Platform threads are thin wrappers over operating-system threads: each one reserves a large stack (typically around 1 MB of address space), and the OS can only juggle a few thousand efficiently.
Most backend requests spend their time waiting — for the database, another HTTP service, a message broker. While waiting, a platform thread sits idle but still occupied. So you run out of threads long before you run out of CPU.
The traditional fix was reactive programming (WebFlux, CompletableFuture chains): never block, compose callbacks. It scales, but it's harder to write, read, debug and profile.
What a virtual thread is
A virtual thread is a java.lang.Thread that is managed by the JVM, not the OS:
- It's cheap: its stack lives on the heap and grows as needed, so you can create hundreds of thousands or even millions.
- It runs on a small pool of carrier threads (platform threads, a ForkJoinPool sized to the CPU count by default).
- When a virtual thread performs a blocking operation (socket I/O,
Thread.sleep,BlockingQueue.take, most JDK blocking APIs), the JVM unmounts it from its carrier and frees the carrier to run another virtual thread. When the I/O completes, the virtual thread is remounted and continues.
You keep writing ordinary blocking code; the JVM does the multiplexing.
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 0; i < 100_000; i++) {
executor.submit(() -> {
Thread.sleep(Duration.ofSeconds(1)); // blocks the virtual thread, not a carrier
return null;
});
}
} // close() waits for all tasks: finishes in about a second
With 100,000 platform threads, this would exhaust memory or take ages; with virtual threads it completes in roughly a second.
Other ways to start them:
Thread.ofVirtual().start(() -> handle(request));
Thread.startVirtualThread(() -> handle(request));
Using them in Spring Boot
Since Spring Boot 3.2, one property switches request handling (Tomcat/Jetty), @Async, scheduled tasks and several integrations to virtual threads:
spring.threads.virtual.enabled=true
Your controllers, JDBC calls and RestClient calls stay exactly the same — they just stop tying up scarce threads while they wait.
When they help
- I/O-bound services: REST APIs calling databases and other services — the typical microservice.
- Code with many concurrent blocking calls (fan-out to several downstream APIs).
- Replacing complex reactive code whose only purpose was scalability.
When they don't
- CPU-bound work (image processing, heavy computation): there are still only as many carriers as CPU cores. Virtual threads don't add computing power — use a bounded platform-thread pool or parallel streams.
- When the bottleneck is elsewhere: a database with a 20-connection pool still serves 20 queries at a time. Virtual threads will happily queue 10,000 requests on that pool — watch pool timeouts and add rate limiting or semaphores where needed.
Pitfalls to know
- Don't pool virtual threads. They're cheap to create; create one per task. Pooling defeats the point.
- Pinning. In Java 21–23, a virtual thread that blocks inside a
synchronizedblock or method stays pinned to its carrier and can't unmount, which can reduce throughput. The fix was to useReentrantLockaround blocking calls. Java 24 (JEP 491) removed this limitation forsynchronized. Native calls (JNI) still pin. Detect pinning with JDK Flight Recorder (jdk.VirtualThreadPinnedevents). - ThreadLocal overuse. Each virtual thread has its own ThreadLocals; with millions of threads, heavy per-thread caches waste memory. Prefer passing context explicitly — or scoped values (preview in Java 21, final in Java 25).
- Unbounded concurrency. Easy concurrency means it's easy to overwhelm a downstream service. Limit with a
Semaphorewhere you call fragile dependencies.
Virtual threads vs reactive: which to choose?
| Virtual threads | Reactive (WebFlux) | |
|---|---|---|
| Code style | Plain blocking, imperative | Functional pipelines (Mono/Flux) |
| Debugging, stack traces | Normal | Harder |
| Backpressure | Manual (semaphores, pools) | Built into the model |
| Streaming | Possible but manual | Natural fit |
| Best for | Typical request/response I/O services | Streaming, event-heavy systems, existing reactive stacks |
For most new Spring Boot services, virtual threads offer reactive-level scalability with far simpler code.
Follow-up questions this topic invites — and their answers
Q: Are virtual threads faster than platform threads? A: Not per task — a single request isn't faster. They improve throughput (how many concurrent blocking tasks you can serve), not latency.
Q: Do I need to change JDBC drivers?
A: No. Blocking JDBC works. Some older drivers used synchronized around I/O, which pinned in Java 21–23; recent driver versions switched to locks, and Java 24 removes the synchronized pinning issue.
Q: What is structured concurrency?
A: An API (StructuredTaskScope, still preview) that treats a group of related subtasks as one unit: they start together, finish together, and failures or cancellations propagate. It pairs naturally with virtual threads.
Q: Should I set a thread pool size for virtual threads?
A: No — use newVirtualThreadPerTaskExecutor(). Limit concurrency at the resource (connection pool, semaphore) instead.
Go deeper with the Java virtual threads lesson and our concurrency interview questions.
Related Posts
Java Garbage Collection Explained: G1, ZGC and How to Choose
How the JVM finds garbage, why generations matter, what G1 and ZGC actually do, and a practical way to pick and tune a collector for your service.
HashMap Internals: How put() and get() Really Work
Buckets, hash spreading, collisions, treeification and resizing — a step-by-step look inside java.util.HashMap, and why equals() and hashCode() must agree.
10 Java Output Questions That Trip Up Experienced Developers
Integer caching, the String pool, finally blocks that override returns — ten short Java snippets where intuition gives the wrong answer, with the exact reason for each.
Java Concurrency: The Interview Questions That Trip People Up
volatile, synchronized, ReentrantLock, happens-before — these concepts trip up even experienced engineers. Here's a clear explanation of each.