Millions of cheap threads — how Project Loom changes Java server-side concurrency.
Published September 21, 2026
Server applications spend most of their time waiting: for a database, for another service's HTTP response, for a file. In the classic thread-per-request model, each waiting request holds an OS (platform) thread, and OS threads are expensive: roughly a megabyte of stack each, with creation and scheduling handled by the kernel. That caps a typical server at a few thousand concurrent requests. To go further, developers moved to asynchronous, reactive code, which scales but is much harder to write, debug and profile.
Virtual threads (final since Java 21, from Project Loom) remove that trade-off. They're threads managed by the JVM, not the OS, and they're cheap enough to create millions. You can write simple, blocking, one-request-per-thread code, and it scales like asynchronous code.
A virtual thread is a Java object holding its stack in heap memory. The JVM runs virtual threads on a small pool of platform threads called carrier threads (a ForkJoinPool, by default one per core).
virtual threads (millions): V1 V2 V3 V4 V5 V6 …
│ mounted when runnable
carrier threads (≈ cores): C1 C2 C3 C4
When a virtual thread performs a blocking operation (socket I/O, Thread.sleep, BlockingQueue.take, Lock waits and most JDK blocking calls), the JVM unmounts it: it saves its stack to the heap and frees the carrier to run another virtual thread. When the operation completes, the virtual thread is scheduled onto a carrier again. The blocking call looks synchronous to your code, but no OS thread sits idle waiting.
Thread.startVirtualThread(() -> handle(request));
Thread t = Thread.ofVirtual().name("worker-", 0).start(task);
try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
List<Future<Price>> prices = suppliers.stream()
.map(s -> executor.submit(() -> s.fetchPrice(item))) // one virtual thread per task
.toList();
// ... use the futures
} // close() waits for all tasks to finish
Don't pool virtual threads. Pooling exists because platform threads are expensive. Virtual threads are cheap, so create one per task, and let them finish.
In Spring Boot 3.2+, one property runs request handling (Tomcat or Jetty), @Async tasks and more on virtual threads:
spring.threads.virtual.enabled=true
They help with high-concurrency, I/O-bound workloads: web services calling databases and other services, and fan-out calls to many downstream APIs. Throughput can rise dramatically, because concurrency is no longer limited by the thread count.
They don't help with:
Thread pools used to limit concurrency as a side effect. With virtual threads, nothing stops 50,000 requests from hitting a downstream service at once. Protect downstream services deliberately:
private final Semaphore paymentPermits = new Semaphore(50);
Receipt charge(Order order) throws InterruptedException {
paymentPermits.acquire();
try { return paymentClient.charge(order); }
finally { paymentPermits.release(); }
}
Connection pools (HikariCP) still bound database concurrency. Size them for what the database can handle, not for the number of threads.
Some situations pin a virtual thread to its carrier, so blocking also blocks the carrier:
synchronized block or method (in Java 21–23);If many virtual threads block while pinned, the few carriers are exhausted and throughput collapses. For Java 21–23, replace synchronized around blocking I/O with ReentrantLock. Java 24 (JEP 491) removed pinning for synchronized, so on current JDKs this is much less of a concern, though native calls still pin. You can diagnose pinning with the JFR event jdk.VirtualThreadPinned.
ThreadLocal works, but with a million threads, a large per-thread cached object (a buffer, a formatter) multiplies into a million copies. Avoid using ThreadLocals as caches of expensive objects. For passing context such as request IDs, scoped values (ScopedValue, finalized in recent JDKs) are the lighter-weight replacement.
A related Loom feature (in preview through several releases) treats a group of concurrent subtasks as one unit: if one fails, the others are cancelled, and the parent waits for all of them before continuing. It prevents leaked threads and makes fan-out code read like sequential code. Where it's available with preview features enabled, StructuredTaskScope is the API. Otherwise, newVirtualThreadPerTaskExecutor() in try-with-resources gives you the "wait for all" part.
Reactive frameworks (WebFlux, Reactor) also achieve high concurrency, but with a different programming model: operator chains, no blocking calls allowed, stack traces that don't show your call path, and harder debugging. Virtual threads give similar scalability for most I/O-bound services with ordinary imperative code, standard debugging and profiling, and existing blocking libraries (JDBC, the classic HTTP clients). Reactive still offers streaming and backpressure operators, which remain useful for stream-processing problems.
Q: Are virtual threads faster than platform threads? A: Not per operation. The benefit is scale: you can have vastly more concurrent blocking tasks, so throughput rises for I/O-bound workloads. CPU-bound code gains nothing.
Q: Should I pool virtual threads? A: No. Create one per task. Pooling defeats their purpose. To limit concurrency against a resource, use a semaphore or a bounded connection pool instead.
Q: What is pinning, and how do you avoid it?
A: Pinning is when a virtual thread can't unmount from its carrier while blocked, so it blocks the carrier too. In Java 21–23 it happens when blocking inside synchronized (replace it with ReentrantLock on hot paths), and in any version inside native calls. Java 24 removed the synchronized case.
Q: Do I need to rewrite my Spring application to use virtual threads?
A: Usually not. On Spring Boot 3.2+ with Java 21+, setting spring.threads.virtual.enabled=true runs request handling on virtual threads. Then review concurrency limits toward databases and downstream services, since thread-pool sizes no longer throttle them.
Q: When is reactive still a better choice? A: For true streaming pipelines with backpressure (event streams, server-sent events at scale), or where the codebase and libraries are already reactive end to end. For typical request/response services with blocking I/O, virtual threads are simpler.