Java Virtual Threads
Virtual threads, finalized in Java 21 through Project Loom, are lightweight threads managed by the JVM rather than the operating system. You can create millions of them, each costing a few hundred bytes to kilobytes of heap instead of a megabyte of native stack. When a virtual thread blocks on I/O, the JVM unmounts it from its carrier (platform) thread, which immediately runs another virtual thread.
That revives the simple thread-per-request model for highly concurrent servers. You can write straightforward blocking code (JDBC calls, HTTP clients, Thread.sleep) and get scalability comparable to reactive or async frameworks, without callbacks, CompletableFuture chains, or reactive operators. For Java backends dominated by I/O waits, it's one of the biggest improvements in years.
TL;DR
- Virtual threads are cheap JVM-managed threads scheduled onto a small pool of carrier threads (ForkJoinPool).
- Blocking operations unmount the virtual thread, freeing the carrier, so blocking code scales.
- Create them with
Thread.ofVirtual(),Thread.startVirtualThread, orExecutors.newVirtualThreadPerTaskExecutor(). - Don't pool virtual threads: create one per task. Limit concurrency with semaphores, not pool sizes.
- Pinning (virtual thread stuck to its carrier) happened with
synchronizedblocks. JDK 24 fixed most of it, and native calls can still pin. - Great for I/O-bound workloads; no benefit for CPU-bound work. Pair with structured concurrency and scoped values.
Quick Example
Enabling them in Spring Boot (3.2+):
Tomcat and Jetty request handling, @Async, and scheduled tasks then run on virtual threads.
Core Concepts
Platform vs Virtual Threads
Virtual threads are still java.lang.Thread instances: debuggers, thread dumps, ThreadLocal, exceptions, and stack traces work as usual.
Mounting and Unmounting
The JVM runs virtual threads on carrier threads (by default a ForkJoinPool sized to the CPU count). When a virtual thread performs a blocking operation on a supported API (socket I/O, BlockingQueue.take, Thread.sleep, ReentrantLock, JDBC drivers built on these), the JVM saves its stack to the heap and unmounts it. When the operation completes, the thread is mounted again on any carrier. The JDK's core libraries were retrofitted for this, so ordinary blocking code becomes scalable.
Creating Virtual Threads
Don't Pool, Limit Instead
Thread pools exist because platform threads are expensive. Virtual threads aren't, so pooling them adds nothing. To protect a database or API from too much concurrency, use a Semaphore, connection pool limits, or rate limiters. The database connection pool (for example HikariCP) naturally caps database concurrency, so size it deliberately.
Pinning
A virtual thread is pinned when it can't unmount while blocked, tying up its carrier. Historically this happened:
- Inside
synchronizedblocks or methods (before JDK 24). - During native method calls or foreign function calls.
With few carriers, pinned threads can reduce throughput, or deadlock under load. JDK 24 (JEP 491) reimplemented object monitors so synchronized no longer pins in most cases. On Java 21–23, prefer ReentrantLock around blocking operations in hot paths, and diagnose with -Djdk.tracePinnedThreads=full or JFR jdk.VirtualThreadPinned events.
ThreadLocals and Scoped Values
ThreadLocal works with virtual threads, but millions of threads each holding large ThreadLocal values (caches, buffers) waste memory. Scoped values (ScopedValue, finalized in JDK 25) provide immutable, bounded context propagation, such as request IDs or user context, which is efficient with huge numbers of threads.
Structured Concurrency
StructuredTaskScope (a preview API through recent JDKs) treats a group of concurrent subtasks as a unit: start several, wait for all (or the first success), and if one fails, cancel the others. Lifetimes are clear and nothing leaks:
When Virtual Threads Help
Virtual threads raise concurrency, not raw speed. A single request isn't faster, but the server handles many more simultaneous requests with the same hardware, as long as downstream resources can keep up.
Best Practices
Adopt Through Your Framework
Spring Boot, Quarkus, Micronaut, Helidon, and Jetty support virtual threads with configuration flags. Start there, and measure throughput and latency under realistic load.
Watch Downstream Limits
With virtual threads, the bottleneck moves to databases, connection pools, and external APIs. Tune pool sizes, add backpressure (semaphores, rate limits), and monitor saturation. See scalability.
Keep Critical Sections Short
Avoid holding locks during I/O. On Java 21–23, replace synchronized with ReentrantLock where blocking happens inside, or upgrade to JDK 24+.
Upgrade Libraries
Older JDBC drivers and libraries using synchronized around I/O, or native blocking calls, limit virtual thread benefits. Use current versions, which are virtual-thread-friendly.
Common Mistakes
Pooling Virtual Threads
Limit concurrency to protected resources with semaphores, not thread counts.
Expecting CPU-Bound Speedups
Running computations on virtual threads doesn't add CPU capacity. Carriers equal the core count. Use platform threads, parallel streams, or ForkJoin for CPU work.
Huge ThreadLocal Caches
Per-thread caches that made sense with 200 pooled threads multiply across a million virtual threads. Refactor to shared caches, or scoped values.
FAQ
What problem do virtual threads solve?
They make the simple, blocking, thread-per-request style scale to very high concurrency. Previously, each concurrent blocking operation consumed an expensive OS thread, pushing developers toward complex async or reactive code. With virtual threads, blocking code releases the underlying OS thread while waiting.
Do virtual threads replace reactive programming?
For many I/O-bound applications, they remove the main reason to adopt reactive frameworks: scalability with blocking-style code that's easier to write, read, and debug. Reactive libraries still offer benefits for streaming data, backpressure-heavy pipelines, and existing reactive ecosystems.
What is pinning?
When a virtual thread can't be unmounted from its carrier during a blocking operation, it occupies the carrier until the operation completes. It occurred inside synchronized blocks (largely fixed in JDK 24) and during native calls. Excessive pinning reduces scalability, and can cause stalls.
How do I use virtual threads in Spring Boot?
On Spring Boot 3.2+ with Java 21+, set spring.threads.virtual.enabled=true. Request handling, @Async tasks, and scheduled tasks then use virtual threads. Make sure database connection pools and downstream limits are sized for the higher concurrency.
Related Topics
- Java — The language overview
- Concurrency Patterns — Models of concurrency across languages
- Spring Boot — Enabling virtual threads in applications
- JVM Performance Tuning — GC and runtime tuning
- Go — Goroutines, a similar lightweight-thread model
- Python asyncio — Async I/O with explicit awaits, for comparison