Java Virtual Threads Deep Dive: Project Loom in Production (2026)
Virtual threads — the flagship feature of Project Loom, delivered in JDK 21 — fundamentally change how Java handles concurrency. Instead of mapping every thread to an expensive OS thread, virtual threads are lightweight user-mode threads that the JVM schedules itself. This guide covers the internals, the gotchas, and how to migrate production systems safely.
How Virtual Threads Work Internally
Platform threads (the old kind) have a 1:1 mapping to OS threads. The OS kernel schedules them, each one owns ~1 MB of stack space by default, and context switching between them costs time in kernel space. You cannot have more than a few thousand before the system degrades.
Virtual threads flip this model. The JVM maintains a pool of carrier threads — OS threads from a ForkJoinPool — and multiplexes virtual threads onto them. When a virtual thread blocks on I/O, the JVM unmounts it from its carrier thread (saving its stack to the heap), and the carrier thread is immediately free to run another virtual thread. When the I/O completes, the virtual thread is mounted back onto a carrier and continues.
Key internals:
- Carrier thread pool: defaults to
Runtime.getRuntime().availableProcessors()threads. Set withjdk.virtualThreadScheduler.parallelism. - Stack storage: stack frames are stored on the Java heap as continuation objects, not on native stack. Stacks start small (~few KB) and grow on demand.
- Scheduler: FIFO work-stealing ForkJoinPool. Not pluggable in JDK 21, but the architecture allows future customization.
- JDK I/O rewired:
java.net,java.nio, JDBC (via socket), and other blocking I/O APIs park the virtual thread instead of blocking the carrier.
Thread.ofVirtual() API
Creating virtual threads is intentionally simple. The existing Thread class gained a new builder API:
// Create and start a virtual thread
Thread vt = Thread.ofVirtual().start(() -> {
System.out.println("Running in: " + Thread.currentThread());
});
vt.join();
// Named virtual threads (great for debugging)
Thread named = Thread.ofVirtual()
.name("request-handler-", 0) // auto-increments: request-handler-0, -1, -2...
.start(() -> processRequest());
// Virtual thread executor (the recommended production pattern)
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
List<Future<String>> futures = new ArrayList<>();
for (int i = 0; i < 10_000; i++) {
futures.add(executor.submit(() -> fetchFromDatabase(userId)));
}
for (Future<String> f : futures) {
System.out.println(f.get());
}
} // executor closes and waits for all tasks
Executors.newVirtualThreadPerTaskExecutor() as a drop-in replacement for Executors.newFixedThreadPool(n) for I/O-bound work. One virtual thread per task is the intended pattern — do not pool virtual threads.
Virtual threads implement the same Thread API, so existing code that uses Thread.sleep(), Object.wait(), Lock.lock(), or blocking I/O works without changes. The JVM handles the unmount/remount transparently.
I/O-Bound vs CPU-Bound Workloads
Virtual threads are a solution to the thread-per-request model's scalability problem — specifically for I/O-bound work. Understand the difference:
| Workload type | Examples | Use virtual threads? | Why |
|---|---|---|---|
| I/O-bound | HTTP calls, DB queries, file reads, queue polls | Yes — strongly recommended | Thread unmounts during wait, carrier is freed |
| CPU-bound | Image processing, encryption, sorting, ML inference | No — use platform threads or ForkJoinPool | Thread stays mounted; no throughput benefit |
| Mixed | Web request handlers (I/O + small computation) | Yes | I/O portion dominates; computation is negligible |
For CPU-bound tasks, virtual threads offer no advantage and can actually hurt performance because they all compete for the same small carrier pool. Use ForkJoinPool.commonPool() or a fixed platform thread pool:
// CPU-bound: use platform threads
ExecutorService cpuPool = Executors.newFixedThreadPool(
Runtime.getRuntime().availableProcessors()
);
// I/O-bound: use virtual threads
ExecutorService ioExecutor = Executors.newVirtualThreadPerTaskExecutor();
// Example: parallel HTTP calls (I/O-bound — virtual threads shine)
List<String> urls = List.of("https://api1.example.com", "https://api2.example.com");
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
List<Future<String>> results = urls.stream()
.map(url -> executor.submit(() -> httpGet(url)))
.toList();
results.forEach(f -> {
try { System.out.println(f.get()); }
catch (Exception e) { e.printStackTrace(); }
});
}
StructuredTaskScope
StructuredTaskScope (JDK 21 preview, standard in JDK 25) provides structured concurrency — tasks spawned within a scope are guaranteed to complete (or be cancelled) before the scope exits. It pairs naturally with virtual threads.
// ShutdownOnFailure: fail fast — first failure cancels all subtasks
String processOrder(long orderId) throws Exception {
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
Subtask<User> userTask = scope.fork(() -> fetchUser(orderId));
Subtask<Inventory> invTask = scope.fork(() -> checkInventory(orderId));
Subtask<Payment> payTask = scope.fork(() -> validatePayment(orderId));
scope.join(); // wait for all (or first failure)
scope.throwIfFailed(); // rethrows the first exception, cancels others
return buildResponse(userTask.get(), invTask.get(), payTask.get());
}
}
// ShutdownOnSuccess: first success wins (race pattern)
String fastLookup(String key) throws Exception {
try (var scope = new StructuredTaskScope.ShutdownOnSuccess<String>()) {
scope.fork(() -> lookupInCache(key));
scope.fork(() -> lookupInDatabase(key));
scope.fork(() -> lookupInRemoteService(key));
scope.join();
return scope.result(); // returns result of first successful task
}
}
StructuredTaskScope requires --enable-preview in JDK 21–24. In JDK 25 it is a standard API. Enable it in Maven with <compilerArg>--enable-preview</compilerArg> and <release>21</release>.
Thread-Local Variables Pitfall
ThreadLocal variables work with virtual threads, but they are a significant memory hazard. With platform threads you had a pool of maybe 200 threads — thread-local data was bounded. With virtual threads you might have millions of them alive simultaneously, each carrying their own thread-local copy.
// DANGEROUS with virtual threads at scale:
private static final ThreadLocal<Connection> DB_CONNECTION = new ThreadLocal<>();
// If you have 500,000 virtual threads, you have 500,000 Connection objects!
// BETTER: pass context explicitly, or use ScopedValue (JDK 21 preview)
// ScopedValue is the virtual-thread-friendly replacement for ThreadLocal
private static final ScopedValue<User> CURRENT_USER = ScopedValue.newInstance();
void handleRequest(User user) {
ScopedValue.where(CURRENT_USER, user).run(() -> {
// CURRENT_USER.get() returns 'user' within this scope
processBusinessLogic();
});
// Value is automatically cleaned up when scope exits
}
ScopedValue is immutable and scope-bound — it is set once per scope and automatically released when the scope ends. It does not scale like thread-locals do.
Synchronized Pinning Problem
When a virtual thread enters a synchronized block or calls a synchronized method, it becomes pinned to its carrier thread. A pinned virtual thread cannot be unmounted, meaning the carrier is blocked for the duration — defeating the whole purpose.
// This PINS the virtual thread to a carrier — bad for I/O inside synchronized
public synchronized void badMethod() {
String result = httpClient.send(request, bodyHandler); // blocks carrier!
}
// FIX: replace synchronized with ReentrantLock
private final ReentrantLock lock = new ReentrantLock();
public void goodMethod() {
lock.lock();
try {
String result = httpClient.send(request, bodyHandler); // virtual thread unmounts here
} finally {
lock.unlock();
}
}
To detect pinning in production, use the JVM flag:
-Djdk.tracePinnedThreads=full
# or for summary only:
-Djdk.tracePinnedThreads=short
Common pinning culprits: JDBC drivers with synchronized internals (use drivers updated for Loom), native methods inside synchronized blocks, and legacy code with coarse-grained synchronization.
Spring Boot 3.2+ Virtual Thread Configuration
Spring Boot 3.2 introduced first-class virtual thread support. Enabling it is a single property:
# application.yml
spring:
threads:
virtual:
enabled: true
When enabled, Spring Boot configures:
- Tomcat's thread pool →
VirtualThreadExecutor(each request gets a virtual thread) @Asynctasks → virtual thread executor- Spring MVC blocking handlers → virtual threads
// Spring Boot 3.2+ — verify virtual threads are active
@RestController
public class ThreadInfoController {
@GetMapping("/thread-info")
public Map<String, Object> threadInfo() {
Thread t = Thread.currentThread();
return Map.of(
"name", t.getName(),
"isVirtual", t.isVirtual(),
"threadId", t.threadId()
);
}
}
// @Async with virtual threads — no executor bean needed when VT is enabled
@Service
public class EmailService {
@Async
public CompletableFuture<Void> sendWelcomeEmail(String to) {
// runs on a virtual thread automatically
emailClient.send(to, "Welcome!");
return CompletableFuture.completedFuture(null);
}
}
Performance Benchmarks
Benchmarks vary by workload, but typical production findings for I/O-bound servers:
| Metric | Fixed Thread Pool (200 threads) | Virtual Threads |
|---|---|---|
| Concurrent requests handled | ~200 (queue at 201+) | 100,000+ |
| Memory per thread | ~1 MB OS stack | ~2–4 KB heap |
| Throughput at 10k RPS (mixed I/O) | Baseline | 3–8x higher |
| p99 latency under load | High (queue saturation) | Low (no queue buildup) |
| Context switch cost | Kernel context switch (~1–10 µs) | JVM mount/unmount (~100 ns) |
Migration Checklist from Thread Pool to Virtual Threads
- Upgrade to JDK 21+ — virtual threads require JDK 21 minimum.
- Replace thread pool executors — swap
Executors.newFixedThreadPool(n)withExecutors.newVirtualThreadPerTaskExecutor()for I/O-bound work. - Audit synchronized blocks — any synchronized block that contains I/O must be converted to
ReentrantLock. - Audit ThreadLocal usage — identify heavy thread-locals and consider
ScopedValueas a replacement. - Update JDBC drivers — use drivers tested with Project Loom (PostgreSQL 42.6+, MySQL Connector/J 8.2+).
- Enable pinning detection — run load tests with
-Djdk.tracePinnedThreads=shortand fix all reported pins. - Disable connection pool size limits — with virtual threads, you can reduce HikariCP pool size since virtual threads wait efficiently. Start with
maximumPoolSize=10and tune from there. - For Spring Boot — set
spring.threads.virtual.enabled=trueand remove custom executor beans for I/O work. - Load test before deploying — verify no hidden synchronized/native pinning surfaces under load.
FAQ
- Can I mix virtual threads and platform threads?
- Yes. Use virtual threads for I/O-bound tasks and keep a dedicated
ForkJoinPoolor fixed platform thread pool for CPU-bound work. The two coexist naturally in the same application. - Do virtual threads work with reactive frameworks like Project Reactor?
- They solve the same problem differently. You can technically run reactive code on virtual threads, but it provides no additional benefit and adds confusion. For new code, pick one model: reactive (WebFlux) or imperative with virtual threads (Spring MVC + virtual threads).
- Will virtual threads fix performance for CPU-bound work?
- No. Virtual threads do not add CPU capacity. They only improve throughput when threads spend time waiting (I/O). CPU-bound work always needs as many threads as available cores.
- Is Thread.sleep() OK in a virtual thread?
- Yes —
Thread.sleep()parks the virtual thread (unmounts from carrier) instead of blocking an OS thread. It is cheap and safe to use in virtual threads. - How do I name virtual threads for debugging?
- Use the builder:
Thread.ofVirtual().name("worker-", 0).start(task). This generates names likeworker-0,worker-1, etc. Named threads appear in thread dumps and profiler output, making debugging much easier.