
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.
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
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.
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:
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:
Structured concurrency has been there in Java as preview feature since Java 21. In Java 25, it has been revamped with some API changes.
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:
open methods. The thread that opens the scope is the scope's owner.fork methods.join method. This may throw an exception.
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.
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.
The default joiner (used when no joiner is specified) behaves as follows:
join() - you extract results from individual subtaskstry (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(); }
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:
join()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:
join()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:
join()Java 25 allows you to configure structured task scopes with names, timeouts, and custom thread factories.
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... }
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(); }
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.
Learn parallel task execution in Java and Spring Boot using CompletableFuture, @Async, Virtual Threads, and Structured Concurrency for better performance.
Explore the most significant changes in JDK 24. Learn about language enhancements, performance improvements, new APIs, and security upgrades with practical code examples.

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