Java 25 Structured Concurrency: The End of Thread Leaks
TL;DR
Java 25’s Structured Concurrency (JEP 505, Fifth Preview) finally solves the thread leak problem that’s plagued Java since ExecutorService. The new StructuredTaskScope API binds thread lifetimes to syntactic blocks (like try-with-resources), automatically cancels child tasks when the parent exits, and provides hierarchical thread dumps for debugging. The biggest change: a Joiner interface replaces subclassing for concurrency policies (fail-fast, race, quorum). Combined with Virtual Threads and Scoped Values, this makes Java viable for high-throughput services without the ExecutorService tax. If you’re building anything that needs to fan out requests (microservices, batch jobs, distributed queries), you need this.
Why ExecutorService Needs to Die
For 20 years, we’ve dealt with ExecutorService’s fundamental flaw: when you submit a task, the parent-child relationship is severed. If your request handler throws an exception, those background tasks keep running. They’re zombies consuming memory, CPU, and database connections with no one waiting for their results.
The damage compounds in three ways:
Thread leaks everywhere. You submit 10 tasks to fetch user data, one throws NullPointerException, your handler exits, but 9 threads keep hammering the database. In production, these accumulate until you’re out of connections.
Silent failures. If a child task fails and you forget to call Future.get(), the exception is swallowed. No logs, no alerts, just missing data in your response. Good luck debugging that in prod when 1 out of 10,000 requests returns incomplete results.
Manual cancellation hell. Want “stop everything if one fails” logic? Track every Future, iterate through them, call cancel() on each. Miss one, and you’ve leaked a thread. The boilerplate is brutal and error-prone.
Thread dumps made this worse. You’d see 10,000 threads in a flat list with no relationship between “Worker-Thread-4532” and “RequestHandler-Thread-89.” Correlating which worker belongs to which request required grepping logs for trace IDs and praying your timestamps aligned.
Virtual Threads (JDK 21) solved the resource problem, you can spawn millions cheaply. But that made the coordination problem worse. Now you can leak a million threads instead of 200.
What Structured Concurrency Actually Fixes
Structured Concurrency treats concurrent tasks like local variables. A thread spawned inside a scope must terminate before the scope exits. This is enforced by the JVM, not your discipline.
Think of it as the GOTO to structured programming transition from the 1960s. GOTO let execution jump anywhere, making code flow unpredictable. Block-structured control flow (if/while/for) confined execution to enter-at-top, exit-at-bottom blocks. Structured Concurrency does the same for threads.
The API uses try-with-resources to guarantee cleanup. If your scope exits (normally or via exception), the JVM cancels all running child tasks and waits for them to stop. No manual tracking, no forgotten futures, no leaks.
The JDK 25 API: Static Factories and Joiners
JDK 25 (Fifth Preview) makes two major changes from earlier previews.
Static Factories Replace Constructors
Old way (deprecated):
var scope = new StructuredTaskScope.ShutdownOnFailure();
New way:
var scope = StructuredTaskScope.open(Joiner.allSuccessfulOrThrow());
This emphasizes that opening a scope is resource acquisition (like opening a file). It pairs with close() in the try-with-resources contract.
The Joiner Interface
The killer feature in JDK 25 is Joiner<T, R>. Instead of subclassing StructuredTaskScope to customize behavior, you pass a Joiner that defines the completion policy.
Three methods matter:
onFork(Subtask<T>): Called when a task is forked.onComplete(Subtask<T>): Called when a task finishes. Returntrueto cancel all other tasks.result(): Called afterjoin()to produce the final result or throw.
This separates lifecycle (the scope) from policy (the joiner). You can write a custom Joiner for any aggregation pattern without touching thread management internals.
Standard Joiners
Joiner.allSuccessfulOrThrow(): Fail-fast for scatter-gather. If any task fails, cancel the rest and throw FailedException.
Joiner.anySuccessfulResultOrThrow(): Race pattern. First success wins, cancel the rest.
Joiner.awaitAll(): Wait for all tasks regardless of success/failure (batch jobs where you log failures instead of aborting).
Practical Patterns
Pattern: All-or-Nothing Fan-Out
You’re building an invoice. You need Order, Customer, and Template. All three or nothing.
public Invoice createInvoice(int orderId, int customerId, String lang)
throws InterruptedException {
try (var scope = StructuredTaskScope.open(Joiner.allSuccessfulOrThrow())) {
Subtask<Order> orderTask = scope.fork(() -> orderService.getOrder(orderId));
Subtask<Customer> custTask = scope.fork(() -> customerService.getCustomer(customerId));
Subtask<Template> tmplTask = scope.fork(() -> templateService.getTemplate(lang));
scope.join(); // If any fail, throws FailedException and cancels others
return new Invoice(orderTask.get(), custTask.get(), tmplTask.get());
}
}
Key insight: If orderService hangs and customerService throws, the scope cancels the hung thread. No infinite wait.
Pattern: Redundant Race
Query three stock price providers. Take the first response.
public double getStockPrice(String symbol) throws InterruptedException {
try (var scope = StructuredTaskScope.open(Joiner.<Double>anySuccessfulResultOrThrow())) {
scope.fork(() -> providerA.getPrice(symbol));
scope.fork(() -> providerB.getPrice(symbol));
scope.fork(() -> providerC.getPrice(symbol));
return scope.join(); // Returns first success, cancels the rest
}
}
Compare this to CompletableFuture.anyOf(), which doesn’t cancel losing tasks. Those keep running, wasting CPU and network. Structured Concurrency’s auto-cancel is a massive win for high-throughput systems.
Pattern: Timeouts
public List<String> fetchWithDeadline() throws InterruptedException {
try (var scope = StructuredTaskScope.open(
Joiner.allSuccessfulOrThrow(),
config -> config.withTimeout(Duration.ofSeconds(2)))) {
scope.fork(() -> slowTask());
scope.fork(() -> fastTask());
scope.join(); // Throws TimeoutException if > 2 seconds
return List.of(/* results */);
} catch (StructuredTaskScope.TimeoutException e) {
log.warn("Timed out", e);
return List.of();
}
}
The timeout is scoped to the block. If it fires, the scope cancels everything.
Pattern: Custom Quorum Joiner
Distributed consensus: query 5 replicas, need 3 successful responses to trust the data.
public class QuorumJoiner<T> implements Joiner<T, List<T>> {
private final int quorum;
private final List<T> successes = Collections.synchronizedList(new ArrayList<>());
private final AtomicInteger failures = new AtomicInteger(0);
private final int totalTasks;
public QuorumJoiner(int totalTasks, int quorum) {
this.totalTasks = totalTasks;
this.quorum = quorum;
}
@Override
public boolean onComplete(Subtask<T> subtask) {
if (subtask.state() == Subtask.State.SUCCESS) {
successes.add(subtask.get());
return successes.size() >= quorum; // Cancel rest if quorum met
} else {
int maxFailures = totalTasks - quorum;
return failures.incrementAndGet() > maxFailures; // Fail fast if quorum impossible
}
}
@Override
public List<T> result() {
if (successes.size() >= quorum) {
return new ArrayList<>(successes);
}
throw new StructuredTaskScope.FailedException(
new RuntimeException("Quorum not reached")
);
}
}
This level of control was painful with ExecutorService. You’d need CountDownLatch, manual cancellation, and a lot of synchronized blocks.
Scoped Values: Context Propagation Without ThreadLocal
ThreadLocal is a disaster with Virtual Threads. It’s mutable, leaks memory in thread pools, and copying maps across millions of threads is expensive.
ScopedValue (JEP 506) is immutable and lexically scoped. When you open a StructuredTaskScope, it captures current scoped values. When you fork a task, those bindings are inherited automatically.
final static ScopedValue<String> TRACE_ID = ScopedValue.newInstance();
void handleRequest() {
ScopedValue.where(TRACE_ID, "req-8899").run(() -> {
try (var scope = StructuredTaskScope.open(Joiner.awaitAll())) {
scope.fork(() -> {
System.out.println("Trace: " + TRACE_ID.get()); // Prints "req-8899"
return performDbQuery();
});
scope.join();
}
});
}
No manual passing of trace IDs, no MDC gymnastics. The JVM handles propagation using structural sharing (basically a linked list on the stack frame), making it near-zero-cost.
Continue reading
Next article
Stop Sending Nulls in Your API Responses
Related Content
Java 25 Gather API
How Java 25's Gather API brings virtual thread-based concurrent processing to streams, eliminating the choice between readable code and performant I/O. Practical examples, performance data, and migration patterns included.
FastAPI Performance Optimization - Production-Grade Techniques
Deep dive into FastAPI performance optimization: database connection pooling, caching strategies, async patterns, profiling, and real benchmarks from production systems.
TLS: How Your Browser Keeps Secrets (And Why It's Harder Than You Think)
A no-bullshit deep dive into TLS 1.3: the handshake, record protocol, certificate chains, and why perfect forward secrecy actually matters. With annotated diagrams because the RFCs are 100+ pages.