Java virtual threads: where they help and where they don't
· 3 min read
Virtual threads (JEP 444) became stable in Java 21. They come with a lot of “flip a flag and your service gets faster” expectations. That’s not how it works, and it’s worth understanding why.
What they change
A platform thread wraps an OS thread: a megabyte of stack, expensive creation, kernel-level context switches. That’s why classic thread-per-request is capped by pool size: 200 Tomcat threads means at most 200 concurrent requests, even if every one of them is just waiting on the database.
A virtual thread lives on the heap and runs on a small pool of carrier threads (a ForkJoinPool sized to the number of cores by default). When a virtual thread blocks on I/O, it unmounts from its carrier, which picks up the next task. A million virtual threads is perfectly realistic.
The main effect: throughput of I/O-bound services goes up, while the code stays plain and blocking, with no reactive pipelines or callbacks.
What they don’t do
- They don’t speed up an individual request. Latency is determined by what the request waits on. A virtual thread waits just as long.
- They don’t help CPU-bound work. You have the same number of cores. Computation still belongs on a bounded pool.
- They don’t remove external limits. More on this below; it’s the important part.
The bottleneck moves
Say a service talks to PostgreSQL through HikariCP with maximumPoolSize = 20. With 200 Tomcat threads, 180 of them were waiting for a connection. With virtual threads, 10,000 threads will be waiting. The database isn’t serving more queries; the queue just moved and got longer.
Practical rule: limit concurrency explicitly instead of relying on thread pool size as before.
private final Semaphore paymentsLimit = new Semaphore(50);
Response callPayments(Request req) throws InterruptedException {
paymentsLimit.acquire();
try {
return paymentsClient.send(req);
} finally {
paymentsLimit.release();
}
}
For the same reason, don’t pool virtual threads: they’re disposable. Create a new one per task:
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
List<Future<Price>> prices = providers.stream()
.map(p -> executor.submit(() -> p.quote(item)))
.toList();
// ...
}
Pinning
A virtual thread can’t unmount if it blocks inside synchronized (on Java 21–23) or inside a native call. The carrier stays occupied too, and there are only as many carriers as cores. A few such spots under load and the service stalls.
Java 24 (JEP 491) removed pinning on synchronized. If you’re on the Java 21 LTS:
- find pinning with the JFR event
jdk.VirtualThreadPinned; - in hot paths that do blocking I/O under a monitor, replace
synchronizedwithReentrantLock; - check your libraries, especially older JDBC drivers and clients.
ThreadLocal
Code that caches heavy objects (formatters, buffers) in ThreadLocal assumed 200 long-lived threads. With 100,000 virtual threads you get 100,000 copies. For passing context, ScopedValue (stable since Java 25) is a better fit; heavy objects should be thread-safe or reused through an explicit pool.
Enabling it in Spring Boot
Since Spring Boot 3.2, a single property is enough:
spring.threads.virtual.enabled=true
Before you flip it, check three things: connection pool sizes for every dependency, no pinning under a load test, and ThreadLocal usage in your code and libraries.
What about Kotlin
Coroutines have a different model: they provide structured concurrency and cancellation, which virtual threads alone don’t (structured concurrency in Java is still in preview). The two compose: blocking code inside coroutines can run on a dispatcher backed by virtual threads:
val Loom = Executors.newVirtualThreadPerTaskExecutor().asCoroutineDispatcher()
suspend fun loadUser(id: Long) = withContext(Loom) { userRepository.findById(id) }
Takeaway
Virtual threads let you write simple blocking code without hitting a thread-count ceiling. They don’t make a system faster by themselves. They remove one artificial limit, and that exposes the real ones: the database, external APIs, connection pools. Those are what you need to limit explicitly.