in

Java 21 Virtual Threads in Spring Boot: Where the Gain Is

Virtual threads don't make the CPU faster; they stop waiting requests from consuming threads. How to enable them in Spring Boot with one line, which I/O-heavy scenarios show measurable gains, and the pitfalls — pinning, connection-pool bottlenecks, ThreadLocal bloat — with code.

Diagram of many virtual threads running on a few carrier threads

Virtual threads, delivered in Java 21 (JEP 444), are the biggest concurrency change in the Java world in a decade. But the marketing language blurs one thing: virtual threads don't make code faster. What they do is stop a waiting request from occupying an expensive operating-system thread. That distinction determines which applications actually win.

This article covers the mechanism, how to enable it in Spring Boot with one line, which workloads show measurable gains, and the five pitfalls we've met in production — pinning, connection-pool bottlenecks, ThreadLocal bloat, unbounded concurrency and observability — with code.

The mechanism: carrier threads and unmounting on wait

In the classic model every request takes a platform thread; when Tomcat's default 200 threads are used up, new requests wait in a queue. Most of those threads do no work at that moment: they wait for a database response, an HTTP call or a slow client. A virtual thread makes that waiting cheap: a lightweight thread managed by the JVM runs on a small number of carrier platform threads and unmounts from its carrier on a blocking I/O call. The carrier immediately runs another virtual thread; when the I/O completes, the waiting thread is mounted again.

The result: hundreds of thousands of virtual threads can "wait" at once; the memory cost is in the kilobytes and the stack grows on demand. The CPU itself doesn't change — an eight-core machine still computes eight things at a time.

Enabling it in Spring Boot

spring:
  threads:
    virtual:
      enabled: true        # Spring Boot 3.2+ and Java 21+

This single setting moves Tomcat/Jetty request handling, the auto-configured applicationTaskExecutor used by @Async, scheduled tasks and messaging listener containers such as RabbitMQ/Kafka onto virtual threads (the coverage grows with each Boot version; check the notes for yours). For work you start yourself, the model is now thread-per-task rather than a pool:

try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
    List<Future<Price>> results = suppliers.stream()
        .map(s -> executor.submit(() -> priceService.query(s)))   // each query on its own virtual thread
        .toList();
    return results.stream().map(Future::resultNow).toList();
}

Virtual threads are not pooled: using them through newFixedThreadPool(50) gives the whole gain back.

Where the gain is real, and where it isn't

Scenario

Gain

Why

API gateway / BFF making 2–5 external HTTP calls per request

High

Threads spend 90% of their time waiting; the 200-thread ceiling disappears

Slow clients, long-lived connections (report downloads, large responses)

High

An open connection no longer consumes a thread

Many queue listeners, thousands of small I/O operations per second

Medium–high

The per-listener thread cost disappears

Database-heavy CRUD (HikariCP with 10–20 connections)

Low

The bottleneck is the connection pool, not threads; the queue just moves

CPU-bound work (computation, compression, encryption)

None

Virtual threads add no CPU; core count decides

Practical rule: if more than half of a request's duration is spent waiting on external systems, virtual threads make a measurable difference. If not, don't claim "it got faster" without a load test.

Five pitfalls

1. Pinning: a virtual thread stuck to its carrier

In Java 21, when blocking I/O happens inside a synchronized block, the virtual thread cannot unmount; the carrier blocks too, and the gain disappears at that point. This is common with older JDBC drivers and libraries that use synchronized. JDK 24 (JEP 491) removed this limitation; if you stay on Java 21 you need to detect and fix it:

# Java 21–23: log pinned threads with stack traces
java -Djdk.tracePinnedThreads=full -jar app.jar

# Alternative: the JFR event
jfr print --events jdk.VirtualThreadPinned recording.jfr
// ReentrantLock instead of synchronized + I/O: the virtual thread can release its carrier
private final ReentrantLock lock = new ReentrantLock();

void refreshCache() {
    lock.lock();
    try { data = remoteService.fetch(); }   // blocking call
    finally { lock.unlock(); }
}

Updating driver and library versions resolves most pinning problems on its own; PostgreSQL JDBC, HikariCP and the major HTTP clients have shipped virtual-thread-friendly releases.

2. The connection pool becomes the new bottleneck

When 10,000 concurrent requests hit a HikariCP pool of 10 connections, 9,990 of them wait at the pool. Virtual threads add no database capacity; they move the queue from Tomcat to the pool. Keep the pool as large as the database can handle (small multiples of the core count for most OLTP), keep transactions short, and watch pool wait time (hikaricp.connections.pending). If the pending count stays high, the fix is fewer queries or a cache, not more threads.

3. ThreadLocal bloat

Code that keeps per-thread caches (formatters, buffers, copies of the security context) is harmless with 200 threads; with 200,000 virtual threads it fills the heap. Don't keep large objects in ThreadLocal; ScopedValue, finalized in Java 25, was designed for this need, and on Java 21 passing the value explicitly as a parameter is the safest route.

4. Unbounded concurrency

The old thread pool acted as an unintended brake. With virtual threads it is easy to launch 50,000 concurrent calls for 50,000 products and take the downstream system down. Limit concurrency deliberately:

private final Semaphore supplierLimit = new Semaphore(50);   // at most 50 calls in flight

Price query(Supplier s) throws InterruptedException {
    supplierLimit.acquire();
    try { return client.price(s); }
    finally { supplierLimit.release(); }
}

Resilience4j's bulkhead and rate limiter modules do the same job declaratively and produce metrics.

5. Observability and debugging

A classic thread dump can't list hundreds of thousands of virtual threads; jcmd <pid> Thread.dump_to_file -format=json dump.json produces the new format that includes them. The "active thread count" metric loses its meaning; watch request latency (p95/p99), pool wait times and pinning events instead.

Migration plan

  1. Move to Java 21 (preferably 25) and Spring Boot 3.2+; update the JDBC driver, HTTP client and cache libraries.

  2. Enable spring.threads.virtual.enabled=true in the test environment; scan for pinning with jdk.tracePinnedThreads.

  3. Run a realistic load test: same scenario, same data, before/after p99 and throughput.

  4. Roll out gradually while watching pool wait metrics and downstream error rates.

  5. If there is no measurable gain, leaving it on does no harm either; but base the "we got faster" claim on measurement.

Frequently asked questions

Is WebFlux / reactive programming no longer needed?

For high-concurrency I/O, virtual threads deliver the same throughput with blocking, readable code; choosing the reactive model for that reason alone is hard to justify in new projects. Where you need reactive stream semantics (backpressure, stream processing), WebFlux still applies.

Can we use virtual threads on Java 17?

No. Preview in Java 19 and 20, final in 21. Enterprise projects should target Java 21 LTS or 25 LTS.

Will existing code have to change?

Mostly no; blocking code runs as it is. What needs to change are sections doing I/O inside synchronized, hand-managed thread pools and large ThreadLocal usage.

Conclusion

Virtual threads remove the thread cost of waiting on I/O; the gain is large in applications heavy on external calls and small in CRUD applications bounded by the database pool. A team that scans for pinning, watches pool wait time and limits concurrency deliberately turns a one-line setting into a real throughput increase.

To plan your application's move to Java 21/25 and a load-test-driven performance roadmap, see our software design and architecture and maintenance and support services.