Pavan Rangani

HomeBlogJava 24 Virtual Threads and Structured Concurrency Guide

Java 24 Virtual Threads and Structured Concurrency Guide

By Pavan Rangani · March 9, 2026 · Java & Spring

Java 24 Virtual Threads and Structured Concurrency Guide

Java 24 Virtual Threads: The Concurrency Revolution, Explained Simply

For 25 years, Java developers lived with a painful trade-off: write simple blocking code that wastes threads, or write complex reactive code (Project Reactor, RxJava) that’s hard to debug. Java 24 virtual threads eliminate this trade-off entirely. You write simple blocking code AND it scales to millions of concurrent operations. This isn’t incremental improvement — it’s a fundamental change in how Java handles concurrency.

The Problem Virtual Threads Solve — Explained for Everyone

Imagine a restaurant with 10 waiters (threads). Each waiter takes an order and then stands at the kitchen counter waiting for the food (blocking I/O call). While waiting, that waiter can’t serve other tables. With 10 waiters and 100 tables, 90 tables are unserved.

The reactive programming solution was like training waiters to juggle: take an order, start cooking, immediately move to the next table, come back when cooking is done. This works but makes the waiters’ job incredibly complex — and debugging what went wrong when an order gets lost is a nightmare.

Virtual threads are like having unlimited waiters that cost almost nothing. Each table gets its own waiter. That waiter CAN stand and wait at the kitchen — because having 1000 idle virtual waiters costs almost no resources. The code is simple (each waiter follows a straightforward script), and you can handle any number of tables.

In technical terms: a traditional Java thread maps 1:1 to an OS thread, consuming roughly 1MB of stack memory each. With 200 threads, you’re using 200MB just for stacks. Virtual threads are managed by the JVM and share a small pool of OS threads (called carrier threads) — a million virtual threads might use only a few hundred megabytes total because idle virtual threads consume almost zero resources.

How Virtual Threads Actually Work Under the Hood

The magic word is continuation. When a virtual thread hits a blocking operation — say, reading from a socket — the JVM does not block the underlying OS thread. Instead, it captures the virtual thread’s stack, parks it, and frees the carrier thread to run another virtual thread. This is called unmounting. When the I/O completes, the virtual thread is mounted back onto an available carrier and resumes exactly where it left off.

Because the JVM rewrote the standard library’s blocking calls to cooperate with this scheduler, your existing code benefits automatically. A plain InputStream.read(), a JDBC query, or an HttpClient call all unmount the virtual thread instead of blocking the OS thread. You did not have to change anything — that is the entire point. Crucially, this only works for I/O that the JDK knows how to suspend; a tight CPU loop never yields, which is why CPU-bound work gains nothing here.

The scheduler itself is a dedicated ForkJoinPool sized by default to the number of CPU cores. This explains a counterintuitive rule: you should create one virtual thread per task and never pool them. Pooling virtual threads defeats their purpose, because the whole design assumes they are cheap and disposable. Treat a virtual thread like a plain object you allocate and discard, not like a scarce OS resource you carefully recycle.

Structured Concurrency: The Other Half of the Revolution

Virtual threads alone solve the scale problem. Structured concurrency (finalized after previewing since Java 19) solves the safety problem: ensuring that concurrent tasks don’t leak, that failures propagate correctly, and that parent tasks wait for all children to complete.

// THE REAL-WORLD PROBLEM: Load a user dashboard
// Need: user profile + recent orders + recommendations
// Requirements: If ANY fetch fails, cancel the others and return an error
// Old way: CompletableFuture — verbose, error-prone, tasks can leak

// Structured Concurrency — clean, safe, no leaks possible
public record Dashboard(UserProfile profile, List<Order> orders, List<Product> recs) {}

public Dashboard loadDashboard(String userId) throws Exception {
    try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {

        // Fork three concurrent tasks — each runs on a virtual thread
        Subtask<UserProfile> profile = scope.fork(
            () -> userService.getProfile(userId)     // Blocking call — that's OK!
        );
        Subtask<List<Order>> orders = scope.fork(
            () -> orderService.getRecent(userId, 10)  // Another blocking call
        );
        Subtask<List<Product>> recs = scope.fork(
            () -> recService.getPersonalized(userId)   // And another
        );

        scope.join();            // Wait for ALL tasks
        scope.throwIfFailed();   // If any failed, throw its exception
        // At this point, ALL three tasks succeeded

        return new Dashboard(profile.get(), orders.get(), recs.get());
    }
    // GUARANTEE: When this block exits, all forked tasks are DONE.
    // No leaked threads. No orphaned operations. No race conditions.
}

// COMPARE WITH THE OLD CompletableFuture APPROACH:
public Dashboard loadDashboardOldWay(String userId) {
    CompletableFuture<UserProfile> profileFuture =
        CompletableFuture.supplyAsync(() -> userService.getProfile(userId));
    CompletableFuture<List<Order>> ordersFuture =
        CompletableFuture.supplyAsync(() -> orderService.getRecent(userId, 10));
    CompletableFuture<List<Product>> recsFuture =
        CompletableFuture.supplyAsync(() -> recService.getPersonalized(userId));

    // Problem: If profileFuture fails, ordersFuture and recsFuture keep running
    // Problem: If we return early due to timeout, tasks are orphaned
    // Problem: Exception handling is complex and error-prone
    CompletableFuture.allOf(profileFuture, ordersFuture, recsFuture).join();

    return new Dashboard(profileFuture.join(), ordersFuture.join(), recsFuture.join());
}

The difference isn’t just syntactic. The structured version is provably safe: when the try-with-resources block exits, every forked task is guaranteed to be complete or cancelled. With CompletableFuture, orphaned tasks are a real production issue — they consume threads, hold database connections, and cause subtle memory leaks.

Java 24 virtual threads code development
Structured concurrency guarantees no leaked threads — when the scope closes, everything is done

Shutdown Policies and Cancellation Semantics

The default scope is not the only one. ShutdownOnFailure cancels remaining tasks the moment one fails, which suits an “all of these must succeed” fan-out. By contrast, ShutdownOnSuccess implements the opposite: the first task to return wins and the rest are cancelled — perfect for racing two replicas of a service and taking whichever answers first.

Cancellation in structured concurrency works through interruption. When a scope shuts down, it interrupts every still-running subtask, and well-behaved blocking calls in the JDK respond to that interrupt by throwing InterruptedException and unwinding. This is why structured concurrency composes cleanly with timeouts: you can wrap scope.joinUntil(deadline) and trust that a slow downstream call gets cancelled rather than silently consuming a connection forever.

// Race two data centers — take the first successful response
public Quote fetchFastestQuote(String symbol) throws Exception {
    try (var scope = new StructuredTaskScope.ShutdownOnSuccess<Quote>()) {
        scope.fork(() -> primaryRegion.getQuote(symbol));
        scope.fork(() -> backupRegion.getQuote(symbol));

        // Returns as soon as ONE succeeds; the slower fork is cancelled.
        // Fails only if BOTH fail. Add a deadline to bound total latency.
        scope.joinUntil(Instant.now().plusSeconds(2));
        return scope.result();
    }
}

This explicit policy model is a genuine improvement over CompletableFuture, where “cancel the losers” and “propagate the first error” had to be hand-coded with brittle cancel(true) calls and careful exception juggling. Here the policy is declared once, at the scope, and the runtime enforces it.

Scoped Values: Replacing ThreadLocal

ThreadLocal has been Java’s way to pass context (user ID, request trace ID, authentication token) through the call stack without adding parameters to every method. But ThreadLocal has problems with virtual threads: it’s mutable, it can leak, and its lifecycle doesn’t match structured concurrency’s scope model. With potentially millions of virtual threads, a per-thread ThreadLocal map is also a memory footgun.

ScopedValue is the replacement. It’s immutable, automatically inherited by child tasks, and its lifecycle matches the StructuredTaskScope:

// Define scoped values (typically as static fields)
private static final ScopedValue<String> CURRENT_USER = ScopedValue.newInstance();
private static final ScopedValue<String> TRACE_ID = ScopedValue.newInstance();

// Set values for a scope — automatically available in all child virtual threads
public void handleRequest(HttpRequest request) {
    ScopedValue.where(CURRENT_USER, request.getUserId())
               .where(TRACE_ID, UUID.randomUUID().toString())
               .run(() -> {
                   // Any code called here — including in forked virtual threads —
                   // can access CURRENT_USER.get() and TRACE_ID.get()
                   processOrder(request.getOrderData());
               });
    // Values are automatically cleaned up — no "remember to remove" like ThreadLocal
}

// Deep in the call stack, any method can access the scoped values
public void logActivity(String action) {
    logger.info("User {} performed {} [trace: {}]",
        CURRENT_USER.get(), action, TRACE_ID.get());
}

Because the binding is established at a clear boundary and torn down when the lambda returns, there is no “I forgot to call remove()” leak class. The immutability also means a child task can never corrupt a parent’s context — it can only rebind a value within its own narrower scope, which is exactly the discipline distributed tracing needs.

Migration from Thread Pools

The migration is straightforward for I/O-bound applications (which is most web services):

Step 1: Replace your thread pool executor with a virtual thread executor:

// BEFORE
ExecutorService executor = Executors.newFixedThreadPool(200);

// AFTER
ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
// That's it. Each submitted task gets its own virtual thread.
// No pool sizing, no queue configuration, no tuning.

Step 2: For Spring Boot applications, add one line to application.properties:

spring.threads.virtual.enabled=true

This makes all request-handling threads virtual. Your existing controller code — unchanged — now scales to thousands of concurrent requests instead of being limited to your thread pool size.

Step 3: Remove reactive programming code that you only wrote for scalability (not for streaming). If you adopted WebFlux or Project Reactor solely because thread pools couldn’t handle your concurrency requirements, you can now rewrite those endpoints as simple blocking code with virtual threads.

One migration caution: with platform thread pools, the pool size acted as an accidental rate limiter on your downstream database. Remove that ceiling and a thousand virtual threads can stampede a connection pool of 20 and exhaust it instantly. The fix is to add explicit limits — a bounded connection pool plus a Semaphore around expensive downstream calls — so that “unlimited threads” does not become “unlimited load on a fragile dependency.”

Thread migration code refactoring
Migration is often as simple as changing one line of thread pool configuration

When NOT to Use Virtual Threads

Virtual threads are not a universal replacement for all threading patterns:

  • CPU-bound computation: Image processing, mathematical simulations, and encryption benefit from a fixed thread pool sized to CPU cores. Virtual threads add no benefit here because the bottleneck is CPU, not I/O waiting.
  • synchronized blocks with I/O: Virtual threads can get “pinned” to their carrier thread during synchronized blocks. Use ReentrantLock instead of synchronized if the critical section contains blocking I/O. Note that newer JDK builds have reduced pinning substantially, but auditing hot paths is still wise.
  • Native code (JNI): Virtual threads pin during native method calls. If your hot path goes through JNI, profile carefully.
  • Long-lived stateful tasks: A connection that lives for hours holding a buffer gains little from virtual threads and may benefit from explicit management instead.

The rule of thumb: if your code spends most of its time waiting (database queries, HTTP calls, file I/O), virtual threads are transformative. If it spends most of its time computing, stick with platform thread pools. You can detect pinning during testing by running with -Djdk.tracePinnedThreads=full, which prints a stack trace every time a virtual thread is pinned — an invaluable signal before you ship.

Performance benchmarking dashboard
Virtual threads handle 1M+ concurrent connections with modest heap — impractical with platform threads

Related Reading:

Resources:

In conclusion, Java 24 virtual threads end the 10-year experiment of reactive programming for scalability. You get the simplicity of blocking code with the scalability of reactive systems. For any Java team building I/O-heavy services, this is the most important Java feature since lambdas.

← Back to all articles