Virtual Threads (Project Loom) — The Biggest Change Since Generics¶
Java 21 (LTS, Sept 2023) shipped virtual threads as a stable feature (JEP 444). They’re the culmination of Project Loom, an eight-year effort to make Java’s concurrency model competitive with Go, Kotlin coroutines, and async/await ecosystems — without asking developers to color their functions.
This file is the one that separates candidates who know Loom is a feature from candidates who understand why it changes server design and when it will bite them. study partners ask about the pitfalls. Have the pitfalls loaded.
1. What a Virtual Thread Actually Is¶
A virtual thread is a java.lang.Thread that is scheduled by the JVM instead of by the OS. It runs on top of a small pool of carrier threads (platform threads) using an M:N scheduler.
Platform thread: 1:1 with an OS thread. ~1 MB stack. Expensive.
Virtual thread: M:N. Stack starts at ~1 KB, grows on demand, lives on the heap.
Carrier pool default size:
Runtime.availableProcessors()(aForkJoinPool).
When a virtual thread hits a blocking JDK call (network I/O, Thread.sleep, LockSupport.park, ReentrantLock.lock), the JVM unmounts it from its carrier. The carrier is now free to run another virtual thread. When the blocked operation is ready, the virtual thread is remounted on some carrier and continues.
The practical consequence: your JVM can carry millions of concurrent virtual threads, as long as most of them are waiting on I/O. You size for concurrency, not for cores.
2. Creating and Using Them¶
// One-off
Thread t = Thread.ofVirtual().start(() -> doWork());
// With a name
Thread t = Thread.ofVirtual().name("handler-", 0).start(() -> doWork());
// As an executor (recommended shape)
try (ExecutorService es = Executors.newVirtualThreadPerTaskExecutor()) {
List<Future<Result>> futures = urls.stream()
.map(url -> es.submit(() -> fetch(url)))
.toList();
for (var f : futures) results.add(f.get());
} // try-with-resources auto-shutdowns
Note the executor is newVirtualThreadPerTaskExecutor — one virtual thread per task. There is no pool. Do not pool virtual threads. They are cheap; pooling them defeats their purpose.
In Spring Boot 3.2+:
spring.threads.virtual.enabled=true
This switches Tomcat/Jetty to a virtual-thread request executor. One line, and every request now runs on its own virtual thread.
3. When Virtual Threads Win¶
Blocking I/O at scale. HTTP calls to slow downstream services, JDBC queries, file I/O. The classic “thread-per-request” model that used to be prohibitively expensive is now the right answer again.
Simple synchronous code style. No more callback pyramids, no more
CompletableFuturechains for I/O composition. Just write straight-line code. This is enormous.Structured concurrency (preview — see §6). Tree of related tasks, one bug, all cancelled.
Before Loom, you traded readability for scalability (reactive frameworks) or scalability for readability (thread-per-request pools capped at 200). Loom eliminates the trade-off for I/O-bound work.
4. When Virtual Threads DON’T Help (and When They Hurt)¶
study partners love this list. Learn every entry.
4.1 CPU-bound work¶
Virtual threads run on the same finite carrier pool. If you spawn 10,000 virtual threads to compute SHA-256 hashes, they contend for the same cores carriers. You get zero speedup and extra scheduling overhead. For CPU-bound work, a fixed platform-thread pool sized to cores is still the right answer.
4.2 synchronized pinning (Java 21-23)¶
When a virtual thread enters a synchronized block, it pins to its carrier for the entire duration. Reason: the OpenJDK monitor implementation associates the monitor with the OS thread’s stack. If the virtual thread blocks on I/O inside that synchronized block, the carrier is stuck too.
Symptom: carrier pool exhaustion under load. Fewer effective carriers than cores. Throughput collapses.
Diagnosis:
-Djdk.tracePinnedThreads=full # dumps stack every time a VT pins
Or the JFR event jdk.VirtualThreadPinned (enabled by default, 20 ms threshold).
Fix (Java 21-23): replace synchronized with ReentrantLock on any block that performs I/O.
Fix (Java 24+): JEP 491 eliminates monitor pinning for the vast majority of cases. Upgrade if you can.
Real-world hits:
Older JDBC drivers use
synchronizedinternally.Caffeine/ConcurrentHashMap.computeIfAbsent(mapping function running under bin lock; Netflix has a public write-up: “Java 21 Virtual Threads — Dude, Where’s My Lock?”).Older logging frameworks.
4.3 ThreadLocal misuse¶
ThreadLocal was tuned for a world where threads are long-lived and few. Virtual threads are short-lived and many. Consequences:
Caching in
ThreadLocaldoesn’t work. Each virtual thread gets its ownSimpleDateFormat— you allocate one per request. No sharing benefit.Memory bloat. With millions of virtual threads,
ThreadLocalmaps become a real heap cost.Leaks. Forgetting to
ThreadLocal.remove()in a finally block used to be recoverable because the thread ended eventually. With virtual threads finishing fast but tons of them alive, leaks compound.
Replacement (Java 21 preview, permanent in future releases): Scoped Values (ScopedValue) — immutable, inherited by child structured concurrency tasks, bounded scope. Migrate ThreadLocal → ScopedValue wherever you can.
4.4 Native code / JNI¶
Running inside JNI code pins the virtual thread. Rare, but worth knowing.
4.5 Class-file initializers¶
A virtual thread cannot unmount inside a <clinit> block. Also rare, also worth knowing.
5. Migration Strategy — From Thread Pools to Virtual Threads¶
Do not “replace all our thread pools with virtual threads” in a weekend. Do this instead:
Upgrade to Java 21+. Ideally Java 24+ to avoid
synchronizedpinning.Audit
synchronizedblocks on hot paths. Replace withReentrantLockwhere they wrap I/O.Audit
ThreadLocalusage. Kill unnecessary caching. Addremove()in finally blocks.Turn on pinning telemetry.
-Djdk.tracePinnedThreads=shortin staging. Watch JFR forjdk.VirtualThreadPinnedevents.Enable virtual threads at the framework boundary. In Spring Boot:
spring.threads.virtual.enabled=true. Load-test.Migrate internal executors last. Anywhere your code does
Executors.newFixedThreadPoolfor I/O work, consider swapping tonewVirtualThreadPerTaskExecutor. Keep the fixed pools for CPU-bound work.Delete your reactive-for-scalability code. If you adopted Reactor/RxJava purely to escape thread-pool sizing, you may be able to unwind that complexity. But don’t rip out reactive code that gives you composition value — that’s a separate decision.
6. Structured Concurrency (Preview — JEP 505 in Java 25)¶
Structured concurrency treats a group of related tasks as a single unit of work with a scope: if the parent fails, children cancel; if a child fails, siblings cancel; when the scope exits, all children are joined or interrupted.
try (var scope = StructuredTaskScope.open()) { // Java 25 preview API
Subtask<User> user = scope.fork(() -> fetchUser(id));
Subtask<Order> order = scope.fork(() -> fetchOrder(id));
scope.join(); // wait for all
scope.throwIfFailed(); // propagate first failure
return new Report(user.get(), order.get());
}
Compare with CompletableFuture: no more manually handling one branch failing while the other is still running. Cancellation propagates. The scope is a resource.
API note: the exact class name and method shape have shifted across previews (StructuredTaskScope.ShutdownOnFailure in Java 21-24, StructuredTaskScope.open in Java 25). Read the JEP for the version you target. As of the Java 25 preview (JEP 505), the API is nearing stability but not yet permanent.
7. Debugging Virtual Threads¶
Thread dumps just work.
jstack <pid>prints platform and virtual threads. New format:jcmd <pid> Thread.dump_to_file -format=json dump.jsongives a machine-readable dump with parent/child hierarchy.JFR events:
jdk.VirtualThreadStart,jdk.VirtualThreadEndjdk.VirtualThreadPinned— the big one; anything > 20 ms is worth investigatingjdk.VirtualThreadSubmitFailed— carrier pool exhausted
Pinning is your #1 enemy. Bake pinning detection into your CI performance tests.
8. Decision Table: Virtual Thread vs Platform Pool vs CompletableFuture¶
Situation |
Best choice |
|---|---|
I/O-bound task, Java 21+, few |
Virtual thread per task |
I/O-bound task, tons of |
Platform pool, sized empirically |
CPU-bound task |
Fixed platform pool sized to cores |
Complex async composition (join, race, combine) |
|
High-throughput event stream with backpressure |
Reactive (Reactor / RxJava) still has a role |
⚠️ What Most People Get Wrong¶
They turn on virtual threads, benchmark once, see a 3× improvement on a simple endpoint, and roll it out to production. Then a bank of synchronized methods in a legacy JDBC driver pins all their carriers under load, and their p99 goes up, not down. Loom is a tool, not a magic switch. The Cashfree, Netflix, and Datadog write-ups on Loom in production all end with the same conclusion: fantastic when your code is Loom-friendly, disastrous when your dependencies aren’t.
Also: they pool virtual threads. Do not create a ThreadPoolExecutor whose thread factory returns virtual threads. You’ve now serialized cheap-to-create resources through a queue and lost every advantage.
And the one study partners love to catch: they say “virtual threads make my code faster.” No. Virtual threads make your code more concurrent with less memory. If a request took 200 ms of wall clock with a platform thread, it will still take 200 ms with a virtual thread. What changes is that you can now have 100,000 of them in flight instead of 200.
Return to README.md · Next: 06_debugging_concurrency.md