Structured Concurrency in Java 25: Complete Guide with Examples

    Structured Concurrency in Java 25: Complete Guide with Examples

    19/09/2025

    Structured Concurrency in Java 25: Complete Guide with Examples

    Introduction

    Structured concurrency is a programming paradigm that treats groups of related tasks running in different threads as a single unit of work. This approach streamlines error handling and cancellation while managing the lifecycle of concurrent operations cohesively.

    Related: Learn about Spring Boot Concurrency Patterns here.

    Why Structured Concurrency

    Before we jump into the structured concurrency, let us understand why we need it by looking at a practical example.

    Let us say we have an app which generates a stock report. We need to fetch stock holdings from a database, get current market price from an API, and retrieve company information

    Sequential Approach

    First, let's see how this would work sequentially:

    public void generateReport(String stockSymbol) { StockHolding stock = stockRepository.getStockHolding(stockSymbol); double marketPrice = stockAPIClient.getStockPrice(stock.symbol()); String companyName = stockAPIClient.getCompanyName(stock.symbol()); StockReportData stockReport = new StockReportData( stock.symbol(), companyName, stock.price(), stock.quantity(), marketPrice, stock.quantity() * marketPrice ); printReport(stockReport); }

    This approach is simple but inefficient - each operation waits for the previous one to complete, resulting in longer execution times.

    Parallel Approach with CompletableFuture or ExecutorService

    We can improve performance using CompletableFuture or ExecutorService:

    public void generateReport(String stockSymbol) throws ExecutionException, InterruptedException { CompletableFuture<StockHolding> stockHoldingFuture = CompletableFuture.supplyAsync(() -> stockRepository.getStockHolding(stockSymbol)); CompletableFuture<Double> marketPriceFuture = CompletableFuture.supplyAsync(() -> stockAPIClient.getStockPrice(stockSymbol)); CompletableFuture<String> companyNameFuture = CompletableFuture.supplyAsync(() -> stockAPIClient.getCompanyName(stockSymbol)); StockHolding stock = stockHoldingFuture.get(); double marketPrice = marketPriceFuture.get(); String companyName = companyNameFuture.get(); StockReportData stockReport = new StockReportData( stock.symbol(), companyName, stock.price(), stock.quantity(), marketPrice, stock.quantity() * marketPrice ); printReport(stockReport); }

    We can also use Virtual Threads which are lightweight threads implemented by JDK to improve the throughput.

    While this improves performance by running tasks in parallel, it has several issues:

    • No automatic cancellation if one task fails
    • Manual exception handling across multiple futures
    • No clear relationship between the tasks
    • Potential for resource leaks if not handled properly

    Structured Concurrency in Java 25

    The key principle of structured concurrency is simple: If a task splits into concurrent subtasks, then they all return to the same place - the task's code block. This creates a clear parent-child relationship between tasks and subtasks, bringing several benefits:

    • Clarity: Code follows a consistent pattern of setup, wait, and decision
    • Error handling: Short-circuiting where a failing subtask can cancel others
    • Cancellation propagation: If a parent task is cancelled, all subtasks are cancelled
    • Observability: Thread dumps clearly display the task hierarchy

    Structured concurrency has been there in Java as preview feature since Java 21. In Java 25, it has been revamped with some API changes.

    Basic Usage

    public void generateReport(String stockSymbol) throws InterruptedException { try (var scope = StructuredTaskScope.open()) { var stockTask = scope.fork(() -> stockRepository.getStockHolding(stockSymbol)); var marketPriceTask = scope.fork(() -> stockAPIClient.getStockPrice(stockSymbol)); var companyNameTask = scope.fork(() -> stockAPIClient.getCompanyName(stockSymbol)); scope.join(); StockHolding stock = stockTask.get(); double marketPrice = marketPriceTask.get(); String companyName = companyNameTask.get(); StockReportData stockReport = new StockReportData( stock.symbol(), companyName, stock.price(), stock.quantity(), marketPrice, stock.quantity() * marketPrice ); printReport(stockReport); } }

    The general workflow of using StructuredTaskScope is:

    1. Open a new scope by calling one of the static open methods. The thread that opens the scope is the scope's owner.
    2. Fork subtasks in the scope using the fork methods.
    3. Join all subtasks as a unit using the join method. This may throw an exception.
    4. Process the outcome of the subtasks.
    5. Close the scope, usually implicitly via try-with-resources.

    alt text


    Advantages of Structured Concurrency

    • Short-circuit error handling: If any subtask (like getStockHolding() or getStockPrice()) throws an exception, the others are cancelled right away if they haven't finished.

    • Automatic cancellation: If the parent thread (e.g., running generateReport()) is interrupted before or during join(), both subtasks are cancelled as soon as the scope closes.

    • Clear structure: The flow is straightforward—fork subtasks, join them, then handle results or exceptions. Cleanup happens automatically.

    • Easy to debug: Thread dumps clearly show the hierarchy, with subtask threads (such as those for getStockHolding() and getStockPrice()) nested under the scope, making it easier to trace and monitor.

    Each subtask is run in a virtual thread by default.

    Joiners

    In the example above, if any subtask fails, the join method throws an exception and the scope is cancelled. If all subtasks complete successfully, join() finishes normally and returns null. This is the default completion policy.

    You can choose other policies by creating a StructuredTaskScope with a specific StructuredTaskScope.Joiner. A Joiner determines how subtask completion is handled and what the join() method returns—it could be a result, a stream, or another object, depending on the joiner used.

    Default Joiner

    The default joiner (used when no joiner is specified) behaves as follows:

    • Does not react to successful completion of subtasks
    • Cancels the scope if any subtask fails
    • Throws the failed subtask's exception
    • Returns no result from join() - you extract results from individual subtasks
    try (var scope = StructuredTaskScope.open()) { var stockTask = scope.fork(() -> stockRepository.getStockHolding(stockSymbol)); var marketPriceTask = scope.fork(() -> stockAPIClient.getStockPrice(stockSymbol)); var companyNameTask = scope.fork(() -> stockAPIClient.getCompanyName(stockSymbol)); scope.join(); // Throws exception if any task fails // Extract results from individual subtasks StockHolding stock = stockTask.get(); double marketPrice = marketPriceTask.get(); String companyName = companyNameTask.get(); }

    All Successful or Throw Joiner

    When all subtasks return the same type and must succeed:

    public List<StockPrice> getMultipleStockPrices(List<String> symbols) throws InterruptedException { try (var scope = StructuredTaskScope.open( Joiner.<StockPrice>allSuccessfulOrThrow() )) { for (String symbol : symbols) { scope.fork(() -> stockAPIClient.getStockPrice(symbol)); } return scope.join() .map(Subtask::get) .toList(); } }

    This joiner:

    • Waits for all subtasks to complete successfully
    • Cancels remaining tasks if any task fails
    • Returns a stream of results from join()
    • Throws exception if any task fails

    Any Successful Result or Throw Joiner

    When you have multiple data sources and only need one to succeed:

    public double getStockPrice(String stockSymbol) throws InterruptedException { try (var scope = StructuredTaskScope.open( Joiner.<Double>anySuccessfulResultOrThrow() )) { scope.fork(() -> stockAPIClient.getStockPrice(stockSymbol)); scope.fork(() -> stockAPIClient.getStockPriceSecondServer(stockSymbol)); scope.fork(() -> stockAPIClient.getStockPriceThirdServer(stockSymbol)); return scope.join(); // Returns the first successful result } }

    This joiner:

    • Returns as soon as the first subtask completes successfully
    • Cancels all remaining subtasks
    • Returns the successful result directly from join()
    • Throws exception only if all subtasks fail

    Await All Joiner

    When you want all tasks to complete regardless of success or failure:

    public Map<String, Object> getStockDataWithFallbacks(String stockSymbol) throws InterruptedException { try (var scope = StructuredTaskScope.open(Joiner.awaitAll())) { var priceTask = scope.fork(() -> stockAPIClient.getStockPrice(stockSymbol)); var companyTask = scope.fork(() -> stockAPIClient.getCompanyName(stockSymbol)); var newsTask = scope.fork(() -> newsAPIClient.getLatestNews(stockSymbol)); scope.join(); // No exception thrown Map<String, Object> result = new HashMap<>(); // Check each task individually if (priceTask.state() == Subtask.State.SUCCESS) { result.put("price", priceTask.get()); } else { result.put("price", "N/A"); } if (companyTask.state() == Subtask.State.SUCCESS) { result.put("company", companyTask.get()); } else { result.put("company", "Unknown"); } if (newsTask.state() == Subtask.State.SUCCESS) { result.put("news", newsTask.get()); } else { result.put("news", Collections.emptyList()); } return result; } }

    This joiner:

    • Waits for all subtasks to complete (success or failure)
    • Never throws exceptions from join()
    • Requires manual checking of each subtask's state
    • Useful for scenarios where partial results are acceptable

    Configuration Options

    Java 25 allows you to configure structured task scopes with names, timeouts, and custom thread factories.

    Basic Configuration

    ThreadFactory customFactory = Thread.ofVirtual() .name("stock-worker-", 0) .factory(); try (var scope = StructuredTaskScope.open( Joiner.<StockPrice>allSuccessfulOrThrow(), cf -> cf .withName("stock-report-generation") .withThreadFactory(customFactory) .withTimeout(Duration.ofSeconds(5)) )) { var stockTask = scope.fork(() -> stockRepository.getStockHolding(stockSymbol)); var marketPriceTask = scope.fork(() -> stockAPIClient.getStockPrice(stockSymbol)); var companyNameTask = scope.fork(() -> stockAPIClient.getCompanyName(stockSymbol)); scope.join(); // Process results... }

    Custom Joiner Implementation

    For specialized requirements, you can implement your own joiner:

    public class FirstSuccessfulJoiner<T> implements Joiner<T> { private volatile T result; private volatile boolean hasResult = false; @Override public void onSuccess(Subtask<? extends T> subtask) { if (!hasResult) { synchronized (this) { if (!hasResult) { result = subtask.get(); hasResult = true; } } } } @Override public void onFailure(Subtask<?> subtask) { // Ignore failures, wait for success } @Override public boolean shouldCancel() { return hasResult; // Cancel remaining tasks once we have a result } @Override public T result() { return hasResult ? result : null; } } // Usage try (var scope = StructuredTaskScope.open(new FirstSuccessfulJoiner<>())) { scope.fork(() -> stockAPIClient.getStockPrice(symbol)); scope.fork(() -> stockAPIClient.getStockPriceSecondServer(symbol)); return scope.join(); }

    Conclusion

    Structured concurrency is going to change the way we write concurrent code in Java once it is a finalized feature. It makes it really easy to write concurrent code with less boilerplate code and more readable code.

    To stay updated with the latest updates in Java and Spring, follow us on LinkedIn and Medium.

    References

    Summarise

    Transform Your Learning

    Get instant AI-powered summaries of YouTube videos and websites. Save time while enhancing your learning experience.

    Instant video summaries
    Smart insights extraction
    Channel tracking