⚡ Advanced Concurrency · Advanced

Structured concurrency in Java

StructuredTaskScope (preview): treating related tasks as one unit.

🧩 The mysteryA request fans out to two services. One fails in 50 ms. The other keeps running for 30 more seconds, for nobody. Structured concurrency makes related tasks live and die together.

Tasks as one unit

Structured concurrency treats a group of related subtasks as one unit of work, scoped to a code block. Subtasks can't outlive their scope, so no orphan threads leak. Status check: in Java 25, StructuredTaskScope is still a preview API, needing --enable-preview, and its design has changed between previews.

open, fork, join, get

open() creates the scope. fork(task) starts a subtask in a new virtual thread. join() waits for the subtasks according to the scope's policy. Then get() reads each result. Try-with-resources guarantees cleanup.

try (var scope = StructuredTaskScope.open()) {
    var user = scope.fork(() -> findUser(id));
    var cart = scope.fork(() -> findCart(id));
    scope.join();
    return new Page(user.get(), cart.get());
}

Unstructured vs structured

✗ ExecutorService
var u = pool.submit(() -> fetchUser(id));
var o = pool.submit(() -> fetchOrders(id));
return new Page(u.get(), o.get());

If fetchUser fails, u.get() throws, but fetchOrders keeps running in the background: a leaked task.

✓ StructuredTaskScope
try (var s = StructuredTaskScope.open()) {
    var u = s.fork(() -> fetchUser(id));
    var o = s.fork(() -> fetchOrders(id));
    s.join();
    return new Page(u.get(), o.get());
}

The scope owns both lifetimes: by default, one failure cancels the sibling and join() throws.

🔮 Predict it

Your turn

With the default scope, fetchUser fails after 50 ms; fetchOrders would take 30 s. When does join() return?

  1. After about 50 ms, throwing
  2. After 30 s, throwing
  3. After 30 s, returning normally
Show the answer

About 50 ms. The default policy waits for all subtasks to succeed; as soon as one fails, the scope cancels the others (interrupting their threads), and join() throws. No wasted 30 seconds.

⚠️ The trap

get() before join()

Reading a subtask before the scope's owner has called join() throws IllegalStateException, even if that subtask has already finished. The rule: fork everything, join(), and only then call get().

var a = scope.fork(() -> 20);
a.get();       // IllegalStateException!
scope.join();
a.get();       // fine: 20

Policies

open() with no arguments uses the default policy: all must succeed, and the first failure cancels the rest. Other policies can be plugged in, such as "first success wins" for racing two replicas. Either way, the scope, not you, tracks every thread it started.

💼 In the real world

Fan-out without leaks

Request handlers often call two to five services at once. Structured concurrency gives them clean failure semantics, no orphaned background work after a timeout or error, and thread dumps that can show the parent-child tree of tasks. Because it's preview, keep it out of code that must compile without --enable-preview.

Key takeaways

  1. try (var scope = StructuredTaskScope.open()) { fork…; join(); }
  2. By default one failed subtask cancels the others
  3. Subtasks can't outlive the scope — no orphan threads
  4. Call join() before reading any Subtask.get()
🤯 Did you know?

StructuredTaskScope has been incubating or in preview since Java 19, and its API was redesigned along the way. That's exactly what preview is for.

Practice questions

A handler starts fetchUser() and fetchOrders() on an ExecutorService. fetchUser() fails fast, but fetchOrders() keeps running for 30 s after the request has already failed. What does structured concurrency give you here?

  1. Sibling subtasks are cancelled automatically when one fails
  2. fetchUser() is retried automatically
  3. Both calls share one thread to save resources
  4. Exceptions are ignored so the request succeeds
Check your answer

Sibling subtasks are cancelled automatically when one fails. With the default policy, the scope cancels the remaining subtasks as soon as one fails, and join() throws.

What must replace the blank before the results can be read?

try (var scope = StructuredTaskScope.open()) {
    var user = scope.fork(() -> findUser(id));
    var cart = scope.fork(() -> findCart(id));
    scope.___();
    return new Page(user.get(), cart.get());
}
  1. join
  2. close
  3. shutdown
  4. awaitTermination
Check your answer

join. join() waits for the subtasks according to the scope's policy. Reading a Subtask before join() throws IllegalStateException.

Next: ThreadLocal's successor. Scoped values share a read-only value with everything a call touches, then vanish on their own.