Skip to main content

On This Page

Java 25 Structured Concurrency: The End of Thread Leaks

6 min read
Share

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. Return true to cancel all other tasks.
  • result(): Called after join() 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