The problem: threads are the scarce resource
A typical Spring MVC service handles each request on its own platform thread. Most of the time that thread is not computing; it is waiting for a database, an HTTP call to another service or a file. While it waits, the thread still occupies an operating-system thread with its stack memory and scheduling cost. A Tomcat pool of a few hundred threads therefore caps concurrency at a few hundred simultaneous requests, even when the CPU is almost idle. Raising the pool size helps only up to the point where memory and context switching hurt.
The alternatives used to be reactive programming or callbacks, which scale well but change how you write, debug and read code. Virtual threads, finalised in Java 21 (JEP 444), take a different route: keep the simple blocking style and make threads cheap enough to have one per task.
What a virtual thread is
A virtual thread is a java.lang.Thread managed by the JVM rather than directly by the operating system. Many virtual threads are scheduled onto a small pool of platform threads called carrier threads. When code running in a virtual thread calls a blocking operation that the JDK knows how to handle (socket I/O, Thread.sleep, java.util.concurrent locks, queues), the virtual thread is unmounted: its stack is stored on the heap and the carrier thread is free to run another virtual thread. When the operation can continue, the virtual thread is mounted again, possibly on a different carrier.
virtual threads (thousands, cheap, stacks live on the heap)
VT1 VT2 VT3 VT4 VT5 VT6 ...
\ | | / | /
v v v v v v
+-----------------------------+
| carrier threads (about #CPU)| <- real OS threads
+-----------------------------+Creating one is a one-liner, and the code inside is ordinary blocking code:
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) { List<Future<Quote>> quotes = providers.stream() .map(p -> executor.submit(() -> p.fetchQuote(request))) .toList(); // each task may block on network I/O without wasting an OS thread}Note the difference from pools: you do not pool virtual threads. You create one per task and let it end. Pooling them defeats the purpose.
What improves, and what does not
Virtual threads improve throughput for workloads with many concurrent tasks that spend most of their time waiting: request handlers calling databases and other services, fan-out calls, batch jobs with per-item I/O. The code stays sequential, stack traces stay meaningful, and debuggers and profilers still see one thread per task.
They do not make CPU-bound code faster. A computation that needs a core still needs a core, and with more virtual threads than cores, they simply take turns. They also do not reduce the latency of a single request; they allow more requests to wait at once.
Spring Boot setting
In Spring Boot 3.2 and later, running on Java 21 or newer, one property switches request handling and several other executors to virtual threads:
spring: threads: virtual: enabled: trueWith this property, the embedded Tomcat or Jetty uses virtual threads for requests, and Spring's default task execution and scheduling infrastructure follows. Check your framework version's documentation for the exact set of components affected, and verify in a thread dump or log output that request threads are virtual before drawing conclusions.
Pinning
Sometimes a virtual thread cannot unmount and stays pinned to its carrier thread while blocked, which wastes the carrier. In JDK 21 through 23, the two main causes were:
- Blocking inside a
synchronizedblock or method (includingObject.wait), because the monitor is tied to the carrier thread. - Blocking inside a native method or a foreign function call.
If many virtual threads block while pinned, the small set of carrier threads can be exhausted and throughput collapses. JDK 24 (JEP 491, "Synchronize Virtual Threads without Pinning") changed the implementation so that virtual threads can acquire, hold and release monitors without pinning their carrier in the typical cases. Pinning from native frames still exists. If you are on JDK 21, which many teams still use as the long-term-support baseline, treat synchronized around blocking calls as a hazard.
Practical steps:
- Prefer
ReentrantLock(or otherjava.util.concurrentlocks) where a lock is held across a blocking call. These park the virtual thread properly on JDK 21. - Keep
synchronizedsections short and free of I/O. This was good advice before virtual threads and remains so. - To find pinning in the JDK 21 to 23 era, the system property
-Djdk.tracePinnedThreads=fullprints stacks when a thread blocks while pinned; Java Flight Recorder records ajdk.VirtualThreadPinnedevent. This property may not exist in every JDK version, so check the release notes of the JDK you run. - Libraries you depend on may contain
synchronizedaround I/O (older JDBC drivers are a known example). Keep them updated and test under load.
Thread-locals
Virtual threads support ThreadLocal, so existing code works. But a virtual thread is short-lived and there may be a huge number of them, so a thread-local holding an expensive object, such as a large buffer or a SimpleDateFormat-style cache, is no longer a cheap per-thread cache: every task gets its own copy. Data intended to be shared across a request should be passed explicitly or kept in a structure designed for it.
Scoped values (ScopedValue) are the intended replacement for passing immutable context down a call tree. They were a preview API in Java 21, so check the status in your JDK version before depending on them. Frameworks that propagate context (security context, MDC logging values, tracing) through thread-locals continue to work, but be aware of any code that relies on thread reuse for correctness.
Connection pools and other limits still apply
Cheap threads do not mean unlimited resources. If ten thousand virtual threads all want a database connection and your pool has twenty, almost all of them wait for a connection. The pool is now the bottleneck, which is often what you want: it protects the database. But watch for these effects:
- Increasing the number of concurrent requests moves queueing from the web server into the connection pool; configure sensible acquisition timeouts so waiting requests fail rather than hang.
- Downstream services, rate limits and databases do not become faster. A sudden increase in concurrent calls can overwhelm them. Use bulkheads or a
Semaphoreto cap concurrency to a dependency, as discussed in failure handling in distributed systems. - Memory is lighter per thread but not free. Each virtual thread's stack lives on the heap while it is parked, so deep call stacks multiplied by very many threads still add up; see memory and garbage collection.
// Cap concurrent calls to a fragile dependencyprivate final Semaphore paymentGatewayLimit = new Semaphore(50);Result charge(Payment p) throws InterruptedException { paymentGatewayLimit.acquire(); try { return gateway.charge(p); } finally { paymentGatewayLimit.release(); }}When not to use virtual threads
- CPU-bound work: use a pool sized to the cores, or the fork/join framework.
- Code that relies on thread pools as a concurrency limit. Replace the implicit limit with an explicit semaphore or rate limiter.
- Applications whose dependencies are not yet verified on virtual threads (pinning-prone drivers or native libraries).
- When the application is already reactive and works well; rewriting it gains little.
- If your concurrency is low (tens of concurrent requests), the benefit is small and the migration risk is not worth it.
Common mistakes
- Pooling virtual threads in a fixed-size executor.
- Assuming virtual threads remove the need for thread safety. Shared mutable state still needs the tools from concurrency basics.
- Enabling the Spring property and measuring nothing before and after.
- Forgetting container sizing: CPU limits in a container affect the number of carrier threads; see Docker for Java.
- Leaving the pool limits unchanged and being surprised that queues move elsewhere.
Trade-offs
Virtual threads buy simple, readable, blocking code with high concurrency at the cost of new failure modes (pinning on older JDKs, hidden pool bottlenecks, per-task thread-locals) and less natural backpressure than reactive streams provide. Reactive code gives you explicit flow control at the price of complexity. For most request-per-thread services doing database and HTTP calls, virtual threads are the lower-effort route to higher concurrency, provided you measure.
Checklist
- Java 21 or later; Spring Boot 3.2 or later for the property.
- A baseline measurement taken before the change.
- No
synchronizedblocks around blocking calls on JDK 21 (re-check on newer JDKs). - Connection pool size and timeouts reviewed deliberately.
- Concurrency caps on fragile dependencies.
- Thread-locals reviewed for heavy objects.
- Load test with the setting on and off.
Where to go next
Start with concurrency basics if the memory-model parts were unfamiliar, then read memory and garbage collection. To place this topic on your learning path, see the senior backend developer roadmap.