Java virtual threads: 50× more throughput, with almost no code change
Your Java server has 200 threads. Request 201 waits in line. Virtual threads in Java 21 let you run a million of them with almost no code change. Here is how they work, what they really speed up, and the two traps that bite teams in production.
Why your server stops at 200
The classic Java server model is one thread per request. Each one is a platform thread, a real operating-system thread. It reserves up to a megabyte of stack and is expensive to create. That is why Tomcat caps its pool at 200 threads by default.
Here is the part people forget: most of the time, that thread does nothing. It waits for the database, for another API, for a file. It is a waiter standing at your table while the kitchen cooks. Two hundred waiters, two hundred tables, and everyone is just standing.
A smarter waiter
A virtual thread takes the order, hands it to the kitchen and goes to serve another table. In Java terms: when a virtual thread blocks on I/O, it steps off the real thread, and that real thread runs someone else.
Under the hood, the JVM runs virtual threads on a small pool of carrier threads, roughly one per CPU core. When a virtual thread blocks, its stack is parked on the heap, often just a few kilobytes instead of a megabyte. When the data comes back, it continues on whichever carrier is free.
The code is tiny
// one thread
Thread.ofVirtual().start(() -> handle(request));
// or one virtual thread per task
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
tasks.forEach(t -> executor.submit(t));
}
In Spring Boot 3.2 or later it is one line of configuration:
spring.threads.virtual.enabled=true
A simple test: 10,000 tasks that each wait one second
- Fixed pool of 200 platform threads: about 50 seconds. 10,000 ÷ 200 = 50 rounds of one second.
- Virtual threads: about 1 second. All 10,000 wait at the same time.
Same code, roughly 50× the throughput for this kind of work.
The myth: virtual threads are not faster threads
One request is not quicker. CPU-heavy work, like image processing or encryption, gains nothing, because there is no waiting to overlap. Virtual threads give you scale, not speed: far more requests waiting at the same time on the same hardware.
Trap 1: pinning
On Java 21, if a virtual thread blocks inside a synchronized block, it gets stuck to its carrier thread. Pin all the carriers and your whole app freezes, with plenty of CPU left.
- Replace
synchronizedaround blocking calls with aReentrantLock. - Or upgrade: Java 24 removes most pinning caused by
synchronized. - Watch for it with the JFR event
jdk.VirtualThreadPinned.
Trap 2: the bottleneck moves, it doesn’t disappear
This one is system design. You can now start a million virtual threads, but your database pool still has ten connections. Virtual threads don’t remove the bottleneck. They move it, usually straight onto your database or a partner’s API.
- Keep explicit limits: a connection pool, a
Semaphorearound slow downstream calls, and timeouts everywhere. - Never pool virtual threads. They are cheap. Create one per task and limit the resource, not the threads.
Why not just go reactive?
WebFlux and other reactive stacks also give you scale, but with callback chains and stack traces nobody can read. Virtual threads give you the same scale with plain, blocking code and normal debugging. For most business apps, that is the better trade.
How TechNova can help
We help teams get more out of the Java services they already run.
- Find the real bottleneck: threads, database, or a slow downstream call, measured under realistic load.
- Move to virtual threads safely: check for pinning, add limits around the database and partner APIs.
- Load-test before and after, so the gain is a number, not a guess.
Lots of waiting on I/O? Use virtual threads. CPU-heavy work? Use a normal thread pool sized to your cores. Either way, protect your database with limits.
Sources
- JEP 444: Virtual Threads: final in Java 21.
- JEP 491: Synchronize Virtual Threads without Pinning: delivered in Java 24.
- Apache Tomcat HTTP connector: maxThreads defaults to 200.
- Spring Boot reference: spring.threads.virtual.enabled, from Spring Boot 3.2.
- The 10,000-task timing is a simple illustration: 10,000 tasks ÷ 200 threads × 1 second ≈ 50 seconds.
FAQ
Which Java version do I need for virtual threads?
Java 21 or later. They were a preview in Java 19 and 20 and became final in Java 21.
Do virtual threads make my API faster?
Not per request. They let the server handle many more requests that are waiting on I/O at the same time, which raises throughput under load.
Should I put virtual threads in a thread pool?
No. Create one virtual thread per task and limit the scarce resource, such as database connections, with a pool or a semaphore.
Read next: The dual-write problem: why your order saves but the event never arrives