Virtual threads in services

Thread-per-request without the pool, what pins a virtual thread, and how to migrate a Spring service without a rewrite.

13 min read🧵 Java Concurrency

The Modern Java course explained what a virtual thread is. This lesson is about putting them into a Spring Boot service that already works — what changes, what does not, what breaks, and how to tell whether they helped. The short version: the request pool disappears, the bottleneck moves to whatever the requests wait on, and three specific patterns need attention before you flip the switch. Underneath, it is about where a virtual thread's stack lives and what happens at the moment it blocks, because every migration surprise follows from that.

Thread-per-request, without the pool

A conventional Boot service on Tomcat has a request thread pool — 200 threads by default. Each request holds one for its whole duration, including every millisecond spent waiting for the database or a downstream call. At 200 concurrent slow requests, the 201st waits in Tomcat's accept queue, and latency climbs even though the CPU is idle.

properties
spring.threads.virtual.enabled=true

With that, Tomcat gives each request a fresh virtual thread. There is no pool to fill. Ten thousand requests each waiting 200 ms on a downstream are ten thousand parked virtual threads on a carrier pool the size of your CPU count. The code is unchanged: controllers, services, repositories, RestClient calls, all blocking, all the same.

@Async, @Scheduled and Spring's TaskExecutor beans switch too. So does anything that used SimpleAsyncTaskExecutor.

Under the hood: mount, unmount, and the stack on the heap

A virtual thread is a Thread object whose stack is not an OS stack. The JVM runs it by mounting it on a carrier, a platform thread in a dedicated ForkJoinPool sized to availableProcessors(). When the virtual thread reaches a blocking point the JDK knows about — LockSupport.park, Thread.sleep, a socket read, BlockingQueue.take, ReentrantLock.lock, a CompletableFuture.join — the runtime unmounts it: the frames it has accumulated are copied off the carrier into a heap object (a StackChunk), the carrier picks up another virtual thread, and when the blocking operation completes, the frames are copied back onto whichever carrier is free. The copy is lazy and incremental (only the frames that changed since the last unmount), which is why parking is cheap even with a deep Spring stack.

Three details decide what you see in production:

  • Socket I/O unmounts. The JDK's SocketInputStream and HttpClient are implemented on non-blocking NIO underneath; a "blocking" read parks the virtual thread and registers interest with a poller thread. The old JDBC drivers block the same way, through the same sockets, so a JDBC call parks correctly on a modern driver.
  • File I/O and some native calls compensate instead. A FileInputStream.read blocks the carrier (the OS offers no async file API the JDK uses); the scheduler notices via a ManagedBlocker and temporarily adds a carrier, up to jdk.virtualThreadScheduler.maxPoolSize (256). Heavy file I/O on virtual threads is fine; it just is not free.
  • Pinning is the failure case: the virtual thread blocks while it cannot unmount, so the carrier is held. Two causes: a synchronized block or method on the stack (before Java 24), and a native frame (JNI, or a class initialiser). With eight carriers, eight pinned threads waiting on a database freeze the entire service.
carriers (ForkJoinPool, parallelism = cores) carrier-1running v1 carrier-2running v2 carrier-3 · PINNEDv3 in synchronized carrier-4idle, next thread heap: parked virtual threads (stack chunks) v4 · socketv5 · Hikariv6 · sleep… v9,842 unmount copies frames to the heap; a completed I/O puts the thread back in the run queue
Waiting costs a heap object, not a carrier. A pinned thread is the exception: it holds a carrier for the whole wait.

What actually gets faster

Throughput under I/O wait, and the tail latency caused by pool exhaustion. Nothing else. A request that spends 40 ms on CPU-bound JSON serialisation spends 40 ms on a virtual thread too. If your service was never thread-limited — low concurrency, or already reactive — the change is neutral.

Measure before and after: p99 latency at your peak concurrency, and the Tomcat tomcat.threads.busy metric (which stops being meaningful) versus a count of in-flight requests.

Where the bottleneck goes

Threads were a limit that hid other limits. Remove it and those limits show:

  • Database connections. HikariCP with maximumPoolSize=10 and 2,000 concurrent requests means 1,990 virtual threads waiting on the pool. That is fine — waiting is cheap now — but the pool's connectionTimeout (30 s default) becomes the failure mode, and the database is the true capacity. Size the pool for the database, not the threads, and watch hikaricp.connections.pending.
  • Downstream services. You can now generate ten thousand concurrent calls to a partner API that used to receive two hundred. Add a Semaphore bulkhead or a rate limiter per dependency, or you will be the reason their pager goes off.
  • Memory. Each virtual thread's stack is on the heap and grows with depth. A million threads each deep in a Spring call stack is real memory. Cheap is not free.
  • Locks. Contention on a synchronized block is unchanged and now has more contenders.

Walkthrough: flipping the switch on a real service

An order service: Tomcat's 200 threads, Hikari pool of 10, one downstream call per request at 80 ms, one query at 5 ms. Peak load 1,500 concurrent requests. Before the switch, 1,300 of them queue in Tomcat's backlog; p99 is 4 s. Now set the property and watch, in order:

  1. First minute. All 1,500 requests get a virtual thread. The 80 ms downstream calls all start at once: the partner sees 1,500 concurrent connections instead of 200, and its rate limiter returns 429 to a third of them. Error rate 30%. This is the incident the property causes.
  2. Bulkhead added, Semaphore(150) around the downstream. Calls proceed 150 at a time; the rest park cheaply; the partner is happy; p99 drops to 900 ms, all of it queueing for the semaphore, which is honest.
  3. The database. 1,500 threads want 10 connections for a 5 ms query. Little's law: 1,500 × 0.005 s = 7.5 connections busy on average; the pool is fine, pending stays near zero. Had the query been 50 ms, the pool would be the ceiling and pending the graph to watch.
  4. A JFR recording shows jdk.VirtualThreadPinned events, 40 ms each, from a legacy synchronized method wrapping a JDBC call in a metrics decorator. On Java 21 that pins a carrier; with 8 carriers and 200 concurrent pinned waits, the service freezes under load. Replaced with a ReentrantLock, or upgraded to Java 24, where JEP 491 makes synchronized unmount.
  5. Result. p99 from 4 s to 900 ms, throughput up 4×, memory up 300 MB for the parked stacks, and two new limits made explicit: the bulkhead and the connection pool.

The property took a second to set. Steps 1, 2 and 4 are the migration.

Pinning

Before Java 24, a virtual thread inside a synchronized block cannot unmount. If it blocks there — on a JDBC call inside a synchronised method, say — it holds its carrier, and with availableProcessors carriers, a handful of pinned threads stall the whole service.

bash
java -Djdk.tracePinnedThreads=full -jar app.jar     # Java 21–23: logs a stack trace at each pinning event
jcmd <pid> JFR.start                                 # any version: jdk.VirtualThreadPinned events with stacks

Look for: your own synchronized around I/O (replace with ReentrantLock or restructure), old JDBC drivers (upgrade; PostgreSQL, MySQL and Oracle drivers have been fixed), and libraries that synchronise around network calls. Java 24 (JEP 491) removes pinning for synchronized in nearly all cases; native frames still pin on every version. On 21, this audit is mandatory.

ThreadLocal

ThreadLocal works. But patterns that assumed a bounded, reused thread set no longer hold:

  • A ThreadLocal cache (a SimpleDateFormat, a buffer) was amortised over thousands of requests per thread. Now each request is a new thread: the cache is created per request and the win is gone. Replace with a shared immutable object (DateTimeFormatter) or a real pool.
  • A ThreadLocal that is set and never cleared leaked one value per pool thread — bounded. Now it leaks nothing, because the thread dies; but code that relied on inheriting a value across a pool (via a custom executor) needs re-checking.
  • MDC (logging context) is a ThreadLocal and still works per request. Spring's @Async propagation is unchanged.

Scoped values (ScopedValue, final in Java 25) are the intended replacement for request-scoped context: immutable, bound in a scope, cheap to inherit into child threads.

Structured concurrency in a service

Fan-out inside a request becomes simple and safe:

java
try (var scope = StructuredTaskScope.open()) {
    var profile = scope.fork(() -> profiles.fetch(userId));
    var orders  = scope.fork(() -> orders.recent(userId));
    var offers  = scope.fork(() -> offers.forUser(userId));
    scope.join();                                       // all done, or one failed and the rest were cancelled
    return new Dashboard(profile.get(), orders.get(), offers.get());
}

Three blocking calls in parallel, on three virtual threads, with cancellation if any fails and no thread outliving the block. Compare the CompletableFuture version: three executors to choose, exception unwrapping, allOf. Where you have Java 25, this is the shape.

A migration checklist

  1. Upgrade to Java 21+ and Spring Boot 3.2+; update the JDBC driver and connection pool.
  2. Run with pinning diagnostics in staging under load; fix or replace every pinning site around I/O.
  3. Audit ThreadLocal uses for per-thread caching assumptions.
  4. Set explicit limits where threads used to be the limit: Hikari pool size and timeout, per-dependency bulkheads, server.tomcat.max-connections.
  5. Enable spring.threads.virtual.enabled=true; load-test at 5× your previous thread count; compare p99 and error rate.
  6. Add a metric for in-flight requests; the old pool-busy gauge no longer tells the story. jcmd <pid> Thread.dump_to_file -format=json includes virtual threads; the classic Thread.print does not.

Try it yourself

Does it unmount?

For each blocking point, say whether a virtual thread unmounts on Java 21: Thread.sleep(100); a JDBC executeQuery on a current PostgreSQL driver; the same call inside a synchronized method; Files.readAllBytes(path); Object.wait() inside synchronized.

Answer

sleep: unmounts. JDBC on a modern driver: unmounts (socket I/O through NIO). The same inside synchronized: pins on 21–23, unmounts on 24+. Files.readAllBytes: blocks the carrier but the scheduler compensates by adding one, so it behaves acceptably. Object.wait() inside synchronized: pins on 21–23 like any monitor; fixed by JEP 491 in 24.

Where did the memory go?

After enabling virtual threads, heap usage at peak rose by 600 MB with no code change. A heap dump shows 40,000 jdk.internal.vm.StackChunk objects. Is it a leak?

Answer

No: 40,000 parked virtual threads, each holding its frames on the heap while it waits, at roughly 15 KB each for a Spring-depth stack. Before, the same work queued as bytes in Tomcat's accept backlog and never got a stack. The cost is real and proportional to concurrency × stack depth; the fix, if it matters, is a bulkhead that caps concurrency where it is waiting, not a bigger heap.

Read the incident

Five minutes after enabling virtual threads, the service's CPU is at 4%, every request times out, and a JFR recording shows 200 VirtualThreadPinned events per second averaging 300 ms. What is happening and what is the one-line diagnosis?

Answer

All carriers are pinned. Some synchronized block (yours, or a library's) wraps a 300 ms blocking call; with 8 carriers and hundreds of requests hitting it, every carrier is held by a pinned thread and no other virtual thread can run, including the ones whose I/O has completed. The JFR event's stack names the synchronized frame. Replace it with a ReentrantLock, or move to Java 24.

Misconceptions

  • "Virtual threads make code faster." They make waiting cheap. CPU-bound work, lock contention and serialisation cost exactly what they did.
  • "There is no thread pool any more." There is one: the carrier pool, sized to cores. Pinning fills it; that is the failure mode.
  • "Blocking is bad again." Blocking is the point. Reactive code exists to avoid holding a platform thread; a virtual thread holds nothing while it waits.
  • "ThreadLocal is broken with virtual threads." It works per thread as always; what broke is the assumption that threads are reused, which made ThreadLocal caches pay off.
  • "Enabling the property is the migration." It is the last step. The bulkheads, the pinning audit and the pool sizing are the migration.

Going deeper

  • JEP 444 (Virtual Threads), the "Scheduling" and "Pinning" sections, and JEP 491 (Synchronize Virtual Threads without Pinning, Java 24).
  • JEP 505 (Structured Concurrency) and JEP 506 (Scoped Values), both final in Java 25.
  • The java.lang.Thread Javadoc, "Virtual threads" section, and jdk.virtualThreadScheduler.* properties.
  • Spring Boot's spring.threads.virtual.enabled reference, and what it changes in Tomcat, @Async and the listener containers.
  • Ron Pressler, "State of Loom", for why the stack lives on the heap.
Progress is saved on this device and to your account when signed in.