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 with jdk.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
Tip: Use 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 typeExamplesUse virtual threads?Why
I/O-boundHTTP calls, DB queries, file reads, queue pollsYes — strongly recommendedThread unmounts during wait, carrier is freed
CPU-boundImage processing, encryption, sorting, ML inferenceNo — use platform threads or ForkJoinPoolThread stays mounted; no throughput benefit
MixedWeb request handlers (I/O + small computation)YesI/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
    }
}
Note: 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)
  • @Async tasks → 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);
    }
}
Spring WebFlux vs Virtual Threads: If you're already on WebFlux/Reactor, stay there — reactive and virtual threads solve the same problem differently. Virtual threads are the better choice for new projects using Spring MVC (imperative code style, simpler debugging, no reactive operator chains).

Performance Benchmarks

Benchmarks vary by workload, but typical production findings for I/O-bound servers:

MetricFixed 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)Baseline3–8x higher
p99 latency under loadHigh (queue saturation)Low (no queue buildup)
Context switch costKernel context switch (~1–10 µs)JVM mount/unmount (~100 ns)

Migration Checklist from Thread Pool to Virtual Threads

  1. Upgrade to JDK 21+ — virtual threads require JDK 21 minimum.
  2. Replace thread pool executors — swap Executors.newFixedThreadPool(n) with Executors.newVirtualThreadPerTaskExecutor() for I/O-bound work.
  3. Audit synchronized blocks — any synchronized block that contains I/O must be converted to ReentrantLock.
  4. Audit ThreadLocal usage — identify heavy thread-locals and consider ScopedValue as a replacement.
  5. Update JDBC drivers — use drivers tested with Project Loom (PostgreSQL 42.6+, MySQL Connector/J 8.2+).
  6. Enable pinning detection — run load tests with -Djdk.tracePinnedThreads=short and fix all reported pins.
  7. Disable connection pool size limits — with virtual threads, you can reduce HikariCP pool size since virtual threads wait efficiently. Start with maximumPoolSize=10 and tune from there.
  8. For Spring Boot — set spring.threads.virtual.enabled=true and remove custom executor beans for I/O work.
  9. 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 ForkJoinPool or 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 like worker-0, worker-1, etc. Named threads appear in thread dumps and profiler output, making debugging much easier.