9 min readjava

Virtual threads are ready — structured concurrency is the missing half

Java 25 made virtual threads boring — and that's the point. But scaling blocking code is only half the story. Here's why structured concurrency is the correctness layer you actually migrate for, from the architecture, developer, and DevOps angles.

On this page

# The Problem

It’s 02:14 on a Tuesday and your payments API is timing out. Not down — timing out. The dashboards are a study in contradiction: CPU sits at 30%, memory is fine, the database is bored. And yet requests are queuing up like planes over a fogged-in airport.

You SSH in and take a thread dump. There it is: all 200 of your Tomcat worker threads are WAITING, parked on a call to a downstream fraud-scoring service that started responding in 4 seconds instead of 40 milliseconds. Your thread pool — sized years ago by someone who has since left the company — is the bottleneck. Not the CPU. Not the fraud service itself. The pool. Every one of those 200 OS threads is sitting idle, holding a megabyte of stack, waiting on a socket, while thousands of perfectly serviceable requests pile up behind them.

This is the thread-per-request tax that has shaped JVM backend architecture for twenty years. And in Java 25, it’s finally optional.

But here’s the part most of the migration guides skip: swapping in virtual threads fixes the thread-pool exhaustion while quietly leaving a second, nastier bug in place. Solving that one is what structured concurrency is for — and it’s the half of Project Loom that actually deserves your architecture review.


# What Actually Changed in Java 25

Java 25 shipped as an LTS on 16 September 2025, and it’s the release where virtual threads stopped needing an asterisk. Two things landed:

  1. Virtual threads are genuinely production-ready. The pinning problems that plagued early adopters — where a virtual thread stuck to its carrier thread inside a synchronized block and refused to yield — were largely resolved across the JDK 24/25 line. A virtual thread is still a java.lang.Thread; it’s just scheduled by the JVM onto a small pool of carrier threads instead of mapping 1:1 to an OS thread. You can now have millions of them.

  2. Structured concurrency reached its fifth preview (JEP 505) and scoped values finalized (JEP 506). This is the correctness layer, and it’s the part this post argues you should care about most.

Scale vs. correctness

Virtual threads give you scale — thread-per-request without the ceiling. Structured concurrency gives you correctness — concurrent work with a defined lifetime, cancellation that propagates, and failures that don’t leak siblings. Adopting the first without the second is a half-migration.


# The Architecture Perspective

Zoom out. The reason your 200-thread pool existed at all was back-pressure by accident: the pool size was the implicit limit on how much concurrent downstream load your service could generate. It was a terrible limiter — it throttled the wrong thing and failed opaquely — but it was a limiter.

Virtual threads remove that accidental limiter. Which is liberating and slightly terrifying: if every request can now fan out to eight downstream calls and you can run a million requests, you can also generate a million-times-eight downstream calls and melt a dependency that has no idea you upgraded your JDK.

So the architectural shift isn’t “threads got cheaper.” It’s where the limits live. They move out of the thread pool and into explicit, intentional places: semaphores, rate limiters, and — crucially — the scope of a unit of concurrent work.

flowchart TD
    subgraph Old["Platform threads: the pool IS the limit"]
        P1["200 OS threads<br/>~1 MB stack each"] --> DS1["Downstream<br/>(implicitly throttled)"]
    end
    subgraph New["Virtual threads: limits become explicit"]
        VT["1,000,000 virtual threads<br/>~few KB each"] --> SEM["Semaphore /<br/>rate limiter"]
        SEM --> DS2["Downstream<br/>(intentionally throttled)"]
    end
    Old ==migrate==> New

    classDef danger fill:#dc2626,color:#fff,stroke:#991b1b;
    classDef ok fill:#059669,color:#fff,stroke:#065f46;
    classDef accent fill:#2563eb,color:#fff,stroke:#1e40af;
    class P1 danger;
    class VT accent;
    class SEM,DS2 ok;

Structured concurrency is the architectural primitive that makes this tractable. A StructuredTaskScope turns a fan-out into a tree with a lifetime: subtasks are children of the scope, the scope has a clear open/close boundary, and when it closes — for any reason — its children are cancelled. That means an API composition that calls user, orders, and inventory services becomes a single logical operation that either completes as a unit or is cancelled as a unit. No orphans.

The mental model

Treat a StructuredTaskScope the way you treat a database transaction: a bounded region with all-or-nothing semantics. Work started inside it cannot outlive it. This is the property that makes reasoning about failure, cancellation, and timeouts local instead of a whole-system concern.


# The Developer Perspective

Let’s make it concrete. Here’s the naive fan-out you’ll find in a thousand codebases, using a plain executor:

// The trap: works fine until a subtask fails.
var executor = Executors.newVirtualThreadPerTaskExecutor();
Future<User>      user      = executor.submit(() -> userClient.fetch(id));
Future<List<Order>> orders  = executor.submit(() -> orderClient.fetch(id));
Future<Inventory> inventory = executor.submit(() -> inventoryClient.fetch(id));

// If orders throws, user and inventory keep running — leaked.
// If the caller is interrupted, nobody tells these three.
return new Dashboard(user.get(), orders.get(), inventory.get());

Flipping to virtual threads (the newVirtualThreadPerTaskExecutor above) fixed your 2 a.m. incident: no pool, no ceiling. But the code still has the leak. If orderClient.fetch throws, the other two calls sail on in the background, burning a downstream connection each, and the exception surfaces at a .get() in a confusing order. Cancellation doesn’t propagate. This is the second bug.

Now the same logic with structured concurrency:

// StructuredTaskScope: one failure cancels the siblings, cleanly.
try (var scope = StructuredTaskScope.open()) {          // JDK 25 preview API
    var user      = scope.fork(() -> userClient.fetch(id));
    var orders    = scope.fork(() -> orderClient.fetch(id));
    var inventory = scope.fork(() -> inventoryClient.fetch(id));

    scope.join();   // waits for all; propagates the first failure,
                    // interrupts the still-running siblings

    return new Dashboard(user.get(), orders.get(), inventory.get());
}   // scope closes here — nothing started inside can still be running

If orders fails, the scope interrupts user and inventory for you, and join() throws with the original cause. When the try block exits — success, failure, or a caller interrupt — every child thread is guaranteed done. The lifetime is lexical.

sequenceDiagram
    participant Caller
    participant Scope as StructuredTaskScope
    participant User as fork: user
    participant Orders as fork: orders
    participant Inv as fork: inventory
    Caller->>Scope: open()
    Scope->>User: start
    Scope->>Orders: start
    Scope->>Inv: start
    Orders-->>Scope: throws!
    Scope-->>User: interrupt
    Scope-->>Inv: interrupt
    Scope-->>Caller: join() rethrows cause
    Note over Scope: try-with-resources closes:<br/>no orphaned work remains

Two more developer-facing wins worth a paragraph each:

Scoped values replace ThreadLocal. At a million threads, ThreadLocal is both expensive and leak-prone. ScopedValue (JEP 506, final in 25) is immutable and inherited down the scope tree — perfect for request context like a trace ID or tenant:

private static final ScopedValue<String> TENANT = ScopedValue.newInstance();

ScopedValue.where(TENANT, tenantId).run(() -> {
    // Every fork inside this scope sees TENANT.get() — no leaks, no mutation.
    handleRequest();
});

Spring Boot 4 makes the switch a one-liner. You don’t rewrite your controllers:

spring.threads.virtual.enabled=true

This flips Tomcat request handling, @Async, and scheduled tasks onto virtual threads. Structured concurrency you adopt deliberately, at the fan-out sites that warrant it.

Do not pool virtual threads

The single most common migration mistake: wrapping virtual threads in a fixed-size pool “to be safe.” That reintroduces the exact ceiling you just removed. Create one virtual thread per task. If you need to limit concurrency, use a Semaphore — that’s the explicit limiter, not a pool.

They're for blocking work, not CPU work

Virtual threads win by cheaply parking on I/O. Running CPU-bound work on them buys you nothing and can starve the carrier threads. Keep number-crunching on a bounded platform-thread pool sized to your cores.


# The DevOps Perspective

Here’s what nobody warns you about: virtual threads change your observability and capacity model, and if your runbooks don’t keep up, you’ll be flying blind during the next incident.

Your dashboards lie now. “Active thread count” was your favorite saturation signal for a decade. It’s meaningless with virtual threads — you’ll see numbers in the hundreds of thousands and they mean nothing about load. Retire that panel. Replace it with:

  • Carrier pool utilization (the ForkJoinPool behind virtual threads) — this is your real “am I compute-bound?” signal.
  • Downstream concurrency — because the pool no longer throttles it, you need explicit metrics on in-flight calls per dependency, exported from your semaphores.
  • Pinning events — enable detection and alert on them.
flowchart LR
    A["Retire:<br/>active thread count"] -.->|replace with| B["Carrier pool utilization"]
    A -.->|replace with| C["In-flight downstream calls<br/>(semaphore gauges)"]
    A -.->|replace with| D["Pinned-thread events"]
    B --> E["Real saturation signals"]
    C --> E
    D --> E

    classDef stale fill:#475569,color:#fff,stroke:#334155;
    classDef good fill:#059669,color:#fff,stroke:#065f46;
    classDef hub fill:#2563eb,color:#fff,stroke:#1e40af;
    class A stale;
    class B,C,D good;
    class E hub;

Hunt for pinning in staging, not prod. A single hot synchronized block on a frequently-blocking path can pin virtual threads to carriers and silently recreate your old ceiling. Turn on detection and put it through a load test:

# Surface stack traces when a virtual thread pins its carrier.
java -Djdk.tracePinnedThreads=full -jar app.jar

# In production, prefer the JDK Flight Recorder event
# 'jdk.VirtualThreadPinned' — near-zero overhead, alertable.

Right-size the carrier pool for your real bottleneck. By default the carrier pool is sized to available processors. That’s usually right — but if you’ve moved blocking work off and left CPU-bound work on virtual threads (don’t), or you’re in a cgroup-limited container, verify jdk.virtualThreadScheduler.parallelism matches the cores you actually have.

The capacity conversation flips

You no longer size thread pools to absorb downstream latency — virtual threads absorb it for free. Instead, capacity planning moves to your explicit limiters (semaphores, rate limiters) and your downstream dependencies’ actual capacity. Make that the subject of your next load test.

Pros

  • No thread-pool ceiling for blocking workloads
  • Thread-per-request stays simple and debuggable
  • Structured concurrency gives all-or-nothing fan-out
  • ScopedValue replaces leaky ThreadLocals
  • One-line switch in Spring Boot 4

Cons

  • Old thread-count dashboards become meaningless
  • Downstream back-pressure must be made explicit
  • Pinning can silently reintroduce ceilings
  • CPU-bound work needs a separate bounded pool
  • Structured concurrency is still a preview API in JDK 25

# Putting It Together

The headline — “millions of threads!” — is the least interesting thing about Project Loom in 2026. The interesting thing is that Java finally has a structured answer to concurrent work: a scope with a lifetime, cancellation that propagates, and failures that clean up after themselves. Virtual threads got you off the thread-pool cliff. Structured concurrency is what keeps you from walking off a new one.

Structured concurrency is still a preview in JDK 25 (--enable-preview), with JEP 525 evolving it further in JDK 26 and finalization expected in the JDK 27 timeframe. Design for it now; pin the exact API when it stabilizes.


# References

views

Click to show appreciation

Discussion