Virtual Threads in Spring Boot, and the One Thing That Breaks

3 min read

  • Java
  • Spring Boot
  • Performance

Virtual threads arrived for real in Java 21. For a typical Spring Boot application that spends most of its time waiting on a database and two HTTP calls, they are close to a free throughput win. There is one category of code that gets slower instead, and it is worth knowing which before you flip the switch.

The problem they solve

A platform thread is a thin wrapper around an operating system thread. It costs around a megabyte of stack and the scheduler is the kernel's. That is why Tomcat defaults to two hundred of them: you cannot afford fifty thousand.

So a server handling requests that each wait 200ms on a database is limited by thread count rather than by anything real. Two hundred threads, 200ms each, gives about a thousand requests per second no matter how idle the CPU is.

A virtual thread is a Java object managed by the JVM. Creating one costs a few hundred bytes. When it blocks on IO, the JVM unmounts it from its carrier platform thread and mounts something else. Millions of them are practical.

Turning it on

One property:

spring.threads.virtual.enabled=true

That makes Tomcat handle each request on a virtual thread, and makes @Async and scheduled tasks use them too. No code changes. Blocking JDBC calls stay blocking, which is the point: you keep the straightforward imperative style and stop paying for it in thread count.

Directly, outside a framework:

try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
    List<Future<String>> results = urls.stream()
        .map(url -> executor.submit(() -> fetch(url)))
        .toList();
}

The executor closes by waiting for every task, so the try-with-resources is a structured join.

What breaks: pinning

A virtual thread cannot unmount while it holds a synchronized monitor. It stays pinned to its carrier thread for the whole blocked section. If that section is a database call, you have spent a scarce platform thread on a wait, which is the exact problem you were trying to escape. With enough pinned threads the pool starves and throughput collapses below where it started.

public synchronized Result call() {
    return restClient.get()... // pins the carrier for the whole call
}

The fix is a ReentrantLock, which Loom understands and unmounts across:

private final ReentrantLock lock = new ReentrantLock();

public Result call() {
    lock.lock();
    try {
        return restClient.get()...;
    } finally {
        lock.unlock();
    }
}

Note that JDK 24 removed most pinning from synchronized itself. If you are on 21 or 23, audit any synchronized block that performs IO. If you are on 24 or later, the concern is largely historical, though object monitors held across native calls still pin.

What else to look at

Thread pools become pointless. Pooling exists to amortise the cost of creating a thread. Virtual threads are cheap to create, so a fixed pool of them just reimposes a limit you removed. Use newVirtualThreadPerTaskExecutor and rate-limit deliberately with a semaphore if you need to protect a downstream service.

ThreadLocal still works but scales differently. With two hundred threads, a fat ThreadLocal costs nothing. With fifty thousand, it costs fifty thousand copies. Spring's RequestContextHolder and the MDC are both thread locals, and they are fine, but avoid caching anything large in one.

Your connection pool is still the real limit. Ten HikariCP connections are ten concurrent queries regardless of how many virtual threads are waiting for them. Virtual threads move the bottleneck to the database rather than removing it, which is usually an improvement but occasionally a nasty surprise for the database.

CPU-bound work gains nothing. If a request is computing rather than waiting, the number of cores is the ceiling and virtual threads add overhead. They pay off for IO-bound workloads, which is most web applications.

Is it worth it

For a service that fans out to other services, yes, and the change is one property. Check your Java version, grep for synchronized around IO calls, watch for oversized thread locals, and measure the database rather than the application after you turn it on.