advanced~2h

Virtual Threads — Project Loom

Learn how JVM-managed virtual threads let you write ordinary blocking code that scales to millions of concurrent tasks, and the thread-pinning pitfall that can silently erase their benefit.

Learning objectives

  • Explain how virtual threads multiplex onto carrier threads via continuations
  • Create virtual threads using all four standard JDK APIs
  • Identify thread pinning, its causes, and how to detect and fix it
  • Distinguish workloads where virtual threads help from CPU-bound workloads where they don't
  • Describe what changes in connection pool and thread pool sizing when migrating to virtual threads

◆ Story

A call center with 200 physical phone lines can, at best, handle 200 conversations happening literally at once — and most of those conversations spend the bulk of their time in silence, one side on hold or looking something up, not actually talking. Buying a thousand more physical lines to handle a thousand more simultaneous calls is expensive and eventually runs into limits that have nothing to do with how much actual talking is happening at any instant. What you really needed all along wasn't more lines — it was a way to let a line serving a silent, on-hold caller be quietly reused for someone else's active conversation, and handed back the moment the hold music stops.

That's the exact scalability problem virtual threads solve. A platform thread costs roughly a megabyte of stack and maps one-to-one onto a real OS thread, so an application tops out somewhere around ten to fifty thousand threads before memory or the OS scheduler gives out — most of which, in a typical web server, are sitting there blocked on a slow database call or a downstream HTTP request, doing nothing but waiting. Virtual threads, finalized in Java 21, are lightweight, JVM-managed threads that let you write the exact same simple, blocking-style code you already know, while running millions of them at once — because a virtual thread that's merely waiting doesn't tie up a real OS thread at all.

The JVM schedules virtual threads itself — not the operating system — onto a small set of "carrier" platform threads, sized to the CPU core count by default. A virtual thread's stack isn't a real OS stack; it's a continuation, a JVM-level construct able to capture "everything needed to resume execution at this exact point later" and store that on the heap, the same way any ordinary object is stored. A virtual thread starts as a tiny heap object — a few hundred bytes — rather than a megabyte-sized OS stack.

When a virtual thread performs an operation the JVM specifically recognizes as safe to suspend from — blocking I/O, Thread.sleep(), most java.util.concurrent lock waits — the JVM captures its continuation, detaches it from its carrier thread, and immediately frees that carrier to run a different virtual thread. Once the original operation completes, the JVM finds an available carrier — not necessarily the same one — and resumes the continuation there. From inside the virtual thread's own code, none of this is visible: a blocking call still just looks like it eventually returned, exactly as it always has.

This is why so few carrier threads are needed to support enormous concurrency: at any given instant, only virtual threads that are actively executing CPU instructions need a carrier at all. A server with a million open connections might have 999,000 virtual threads unmounted and waiting on I/O simultaneously, with only a handful genuinely running — so a carrier pool sized to the CPU core count is enough. The scarce resource was never "threads" in the abstract; it was always real OS threads sitting idle while blocked, and that's precisely the cost virtual threads eliminate.

💻 Code example

package concurrency.virtualthreads.creation; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; /** * Four ways to start a virtual thread, matching four slightly different needs. */ public class VirtualThreadCreation { public static void main(String[] args) throws Exception { // 1. Quick, unconfigured, one-off background task. Thread t1 = Thread.startVirtualThread(() -> System.out.println("Pattern 1 running")); // 2. Fluent builder: configure (e.g. name it) before starting. Thread t2 = Thread.ofVirtual().name("my-vthread") .start(() -> System.out.println("Pattern 2 running")); // 3. Create the Thread object without starting it yet -- useful when // you need the reference before deciding when to run it. Thread t3 = Thread.ofVirtual().unstarted(() -> System.out.println("Pattern 3 running")); t3.start(); // 4. The idiomatic way to plug virtual threads into existing // ExecutorService-based code: a fresh virtual thread per submitted task. try (ExecutorService exec = Executors.newVirtualThreadPerTaskExecutor()) { exec.submit(() -> System.out.println("Pattern 4 running")); } // close() waits for submitted tasks to finish. t1.join(); t2.join(); t3.join(); } }

In real server and application code, you rarely construct virtual threads one at a time the way the creation patterns above do — instead, Executors.newVirtualThreadPerTaskExecutor() becomes the drop-in replacement wherever a fixed or cached platform-thread pool used to sit, because it slots into any code already written against the ExecutorService interface with minimal change, while gaining virtual threads' scalability essentially for free.

The shift this enables is real and worth being precise about: with a bounded platform-thread pool, submitting more concurrent I/O-bound work than the pool has threads for means later tasks simply queue and wait their turn, no matter how idle the CPU actually is. With newVirtualThreadPerTaskExecutor(), every submitted task gets its own fresh, cheap virtual thread immediately — there is no pool to size, and no queuing delay caused by a bounded worker count, because the bottleneck moves from "how many threads do we have" to "how much real work — CPU time, downstream capacity — is actually happening at once." That reframing is exactly why connection pool sizing, not thread pool sizing, becomes the thing worth tuning carefully once virtual threads are in the picture: the database or downstream service's real capacity is the actual constraint, not thread count.

💻 Code example

package concurrency.virtualthreads.perrequest; import java.util.List; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.Future; import java.util.stream.Collectors; import java.util.stream.IntStream; /** * Simulating 1,000 concurrent "requests", each doing a blocking call, using * a virtual thread per task -- viable at a scale a platform-thread pool * could never sustain with one thread per request. */ public class VirtualThreadPerRequest { static String handleRequest(int requestId) throws InterruptedException { // Simulates a blocking downstream call (DB, HTTP). The virtual // thread unmounts from its carrier for this sleep, freeing the // carrier to run other virtual threads in the meantime. Thread.sleep(50); return "Response for request " + requestId; } public static void main(String[] args) throws Exception { try (ExecutorService exec = Executors.newVirtualThreadPerTaskExecutor()) { List<Future<String>> futures = IntStream.range(0, 1000) .mapToObj(id -> exec.submit(() -> handleRequest(id))) .collect(Collectors.toList()); long completed = futures.stream() .map(f -> { try { return f.get(); } catch (Exception e) { return null; } }) .filter(r -> r != null) .count(); System.out.println("Completed: " + completed + " requests"); } // All 1,000 blocking "requests" complete in roughly the time of ONE // 50ms sleep, not 1,000 sequential 50ms sleeps -- because each ran // on its own virtual thread, unmounted while blocked. } }

Blocking while holding a synchronized monitor — thread pinning. There's exactly one common situation where a blocked virtual thread does not free its carrier: performing a blocking operation while inside a synchronized block or method. The JVM cannot unmount a virtual thread whose continuation includes a held monitor, so the carrier stays stuck alongside it — degrading back to platform-thread-like behavior for the duration. Native method calls (JNI) cause the same effect, since native stack frames also prevent unmounting.

Assuming pinning will show up in your program's output. It won't. Pinning is a scalability and throughput problem, not a correctness one — a program's stdout looks identical whether or not pinning happened. You cannot observe it from output alone; you need -Djdk.tracePinnedThreads=full (which prints a stack trace at the moment pinning occurs) or a profiler/JFR session to actually see it. This is exactly why JDBC drivers that internally use synchronized — historically true of PostgreSQL's and MySQL's drivers — are a well-known, silent virtual-thread gotcha: pinning is real, can bottleneck an application scaled for millions of virtual threads back down toward platform-thread-like throughput, and nothing in the logs points at the cause unless you already know to look for it. The fix is replacing synchronized with ReentrantLock in any code path that also does blocking I/O, since ReentrantLock permits unmounting while a virtual thread waits on it.

Migrating CPU-bound thread pools to virtual threads. The single most common migration mistake is swapping every ExecutorService in an application for a virtual-thread executor as a blanket find-and-replace, including pools that run CPU-bound work. For I/O-bound pools this is a straightforward win; for CPU-bound pools it does nothing useful and can measurably worsen things — unbounded virtual thread creation for CPU-bound tasks can flood the small carrier pool with far more runnable work than there are cores to run it, adding scheduling overhead with zero offsetting benefit.

Virtual threads offer no advantage for pure computation. A virtual thread doing CPU-bound work with no blocking calls anywhere is never eligible to unmount — it needs a carrier thread's actual CPU time for its entire duration, exactly like a platform thread would. Running more virtual threads than available CPU cores for CPU-bound work just adds bookkeeping overhead with zero parallelism gain, since work-stealing pool-based parallelism already saturates the cores that actually exist. Virtual threads and CPU-bound parallel work aren't competitors — they solve two entirely different problems (I/O-bound concurrency vs. CPU-bound parallelism) that happen to share some scheduling plumbing underneath.

Connection pools need re-sizing around downstream capacity, not thread count. With a platform-thread pool, it made sense to size something like a HikariCP connection pool to roughly match the thread pool's size (a common historical rule of thumb sat around 200). With virtual threads, thread count can vastly exceed any sane connection count — so the connection pool itself becomes the real bottleneck and backpressure point, sized to what the downstream database can actually sustain rather than to how many virtual threads might be in flight.

"More threads is always more expensive" stops being true only for I/O-bound work. For CPU-bound work, the old sizing intuition — a pool sized around available cores — still fully applies; virtual threads don't change anything about how much actual computation a fixed number of cores can perform per second.

💻 Code example

package concurrency.virtualthreads.pinning; /** * Blocking (sleep) INSIDE a synchronized block pins the virtual thread to * its carrier for the sleep's duration -- the carrier can't be freed to run * anyone else. The stdout output is identical whether or not this pinning * actually occurs; run with -Djdk.tracePinnedThreads=full to observe it. */ public class PinningDemo { private static final Object lock = new Object(); public static void main(String[] args) throws Exception { Thread.ofVirtual().start(() -> { synchronized (lock) { try { // Blocking while holding this monitor is exactly what // triggers pinning -- the JVM cannot unmount this // virtual thread from its carrier while it holds `lock`. Thread.sleep(100); } catch (InterruptedException e) {} } }).join(); System.out.println("Done -- check -Djdk.tracePinnedThreads=full output to see if pinning occurred."); } }

Spring Boot 3.2 and later expose virtual threads behind a single configuration line: spring.threads.virtual.enabled=true swaps Tomcat's request-handling connector, @Async execution, and scheduled tasks over to virtual threads without any code change, letting an existing thread-per-request Spring MVC application scale its concurrent request handling dramatically without a rewrite into a reactive style.

The specific pinning gotcha around synchronized inside JDBC drivers is a concrete, well-documented issue that shows up whenever a team adopts virtual threads under an existing relational database stack — worth checking the driver version in use, since this has been an area of active fixing across drivers as virtual-thread adoption has grown.

More broadly, virtual threads are positioned as a genuine third option next to the two that existed before: keep paying the platform-thread scalability ceiling, or rewrite in a reactive style (Project Reactor's Mono/Flux) and accept its steeper learning curve and harder debugging in exchange for scalability. Virtual threads let ordinary, sequential-looking blocking code scale the way reactive code always could, which is why they've become the default recommendation for new thread-per-request-style Java 21+ server code rather than reaching for a reactive rewrite.

What problem do virtual threads actually solve? : The platform-thread scalability wall: roughly 1MB per OS-backed thread caps an application around 10,000-50,000 threads, most of which in a typical server are simply blocked waiting on I/O, not doing real work.

How does a virtual thread avoid tying up a real OS thread while blocked? : Its stack is a heap-stored continuation, not a real OS stack. On a recognized blocking operation, the JVM captures the continuation, unmounts it from its carrier thread, and remounts it (possibly on a different carrier) once the operation completes.

What is thread pinning and why is it dangerous? : Blocking while holding a synchronized monitor (or during a native call) prevents unmounting, so the carrier stays stuck for that duration. It degrades performance silently -- program output looks identical whether pinning happened or not.

When should you NOT reach for virtual threads? : For CPU-bound work with no blocking calls -- a virtual thread doing pure computation is never eligible to unmount and gains nothing over a correctly-sized platform thread pool.

What changes about connection pool sizing with virtual threads? : Size the pool to what the downstream database or service can actually handle, not to match thread count -- with virtual threads, thread count can vastly exceed useful connection count, making the connection pool the real bottleneck.

Want a visual for this concept?

Generate a diagram tailored to “Virtual Threads — Project Loom” — the AI picks whichever visual (flowchart, comparison, sequence, etc.) best fits.

Sign in to generate a visual →

Practice quiz

Next Step

Continue to Structured Concurrency← Back to all Java Concurrency & Multithreading chapters