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:
Virtual threads are genuinely production-ready. The pinning problems that plagued early adopters — where a virtual thread stuck to its carrier thread inside a
synchronizedblock and refused to yield — were largely resolved across the JDK 24/25 line. A virtual thread is still ajava.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.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.
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.
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 runningIf 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 remainsTwo 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=trueThis flips Tomcat request handling, @Async, and scheduled tasks onto virtual threads. Structured concurrency you adopt deliberately, at the fan-out sites that warrant it.
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.
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
ForkJoinPoolbehind 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.
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
- JEP 505: Structured Concurrency (Fifth Preview) — OpenJDK
- JEP 506: Scoped Values — OpenJDK
- Java 25 and the New Age of Performance: Virtual Threads and Beyond — JAVAPRO
- Structured Concurrency: Why It Matters More Than Virtual Threads for Correctness — Java Code Geeks
- Structured Concurrency in Java 26 (JEP 525 Deep Dive) — JAVAPRO
- A Deep Dive into Java 25 Virtual Threads — Andrew Baker
Click to show appreciation
Discussion