What is structured concurrency?
Structured Concurrency is a programming model where a group of related tasks, executing in different threads, is treated as a single unit of work with clear boundaries. Key idea...
Structured Concurrency is a programming model where a group of related tasks, executing in different threads, is treated as a single unit of work with clear boundaries. Key idea: if a task spawns child tasks, it must wait for their completion. This is the try-with-resources equivalent for multithreading.
Why this matters: in classic multithreading, threads live “on their own” — the parent method can finish while spawned threads continue running (or leak). Structured Concurrency guarantees: by the time you exit the block, all child tasks are finished — either successfully or with an error.
Important caveat: StructuredTaskScope is available from Java 21 as a Preview API. This means the API may change in future versions. For production use on Java 17 — use ExecutorService + CompletableFuture.
Junior Level
Basic Understanding
Structured Concurrency — a programming model where a group of related tasks, executing in different threads, is treated as a single unit of work with clear boundaries.
Why the classic approach is problematic: when you create threads via new Thread().start(), they become “orphans” — the parent method doesn’t know their status, can’t cancel them on error, and doesn’t guarantee their completion. Structured Concurrency solves this via the principle: “parent waits for children”.
The Problem with Classic Multithreading
// "Orphan" threads — fire and forget
public void process() {
Thread t1 = new Thread(() -> fetchUserData());
Thread t2 = new Thread(() -> fetchOrderHistory());
t1.start();
t2.start();
// What if t1 throws? t2 will keep running!
// What if the main method throws? Threads will remain!
}
Solution: StructuredTaskScope (Java 21+ Preview)
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
Subtask<User> user = scope.fork(() -> fetchUser(id));
Subtask<Order> orders = scope.fork(() -> fetchOrders(id));
scope.join(); // Wait for all tasks
scope.throwIfFailed(); // If one failed — others are cancelled
// Both results available
return new Response(user.get(), orders.get());
}
// By the time we exit try-with-resources, all threads are finished!
Analogy
Classic threads: Structured Concurrency:
Parent Parent
│ │
├→ Thread 1 (orphan) ├→ Task 1 (child)
├→ Thread 2 (orphan) ├→ Task 2 (child)
└→ Finishes └→ Waits for children
│
All children done → Parent finishes
Middle Level
Shutdown Policies
1. ShutdownOnFailure
If at least one subtask fails — the rest are cancelled:
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
Subtask<Data> data = scope.fork(() -> fetchData());
Subtask<Config> config = scope.fork(() -> fetchConfig());
scope.join();
scope.throwIfFailed();
// If fetchData() threw — fetchConfig() is automatically cancelled
process(data.get(), config.get());
}
When: Parallel execution of steps in one business process.
2. ShutdownOnSuccess
As soon as the first task returns a result — the rest are cancelled:
try (var scope = new StructuredTaskScope.ShutdownOnSuccess<String>()) {
scope.fork(() -> fetchFromServer1(url));
scope.fork(() -> fetchFromServer2(url));
scope.fork(() -> fetchFromServer3(url));
scope.join();
String fastestResult = scope.result(); // Result of the first to complete
// Other requests are automatically cancelled!
}
When: Requests to mirrors (whoever responds fastest).
Comparison with ExecutorService
| Characteristic | ExecutorService | StructuredTaskScope |
|---|---|---|
| Lifecycle | Not bound to scope | Strictly inside try-with-resources |
| Error handling | Manual (via Future) | Automatic (Policy-based) |
| Cancellation | Manual (future.cancel()) |
Automatic (cascading) |
| Context Propagation | Complex (needs wrappers) | Supports Scoped Values |
| Thread leaks | Possible | Impossible |
Error Propagation
// ExecutorService — error "dies silently"
Future<?> future = executor.submit(() -> {
throw new RuntimeException("Error!");
});
// Nobody knows until you call future.get()
// StructuredTaskScope — error bubbles to parent
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
scope.fork(() -> {
throw new RuntimeException("Error!");
});
scope.join();
scope.throwIfFailed(); // → ExecutionException automatically
}
Why Is This a Debugging Revolution?
Observability
Thread dumps are now hierarchical:
jcmd <pid> Thread.dump_to_file -format=json threads.json
{
"threads": [
{
"name": "main",
"children": [
{
"name": "Scope-1/Worker-1",
"task": "fetchUser"
},
{
"name": "Scope-1/Worker-2",
"task": "fetchOrders"
}
]
}
]
}
Senior Level
Under the Hood: How StructuredTaskScope Works
// Simplified
public abstract class StructuredTaskScope<T> implements AutoCloseable {
private final ThreadFactory factory;
private final Set<Subtask<T>> subtasks = ConcurrentHashMap.newKeySet();
private volatile boolean closed = false;
public <U extends T> Subtask<U> fork(Callable<? extends U> task) {
if (closed) throw new IllegalStateException("Scope closed");
Thread thread = factory.newThread(() -> {
try {
U result = task.call();
handleSuccess(result);
} catch (Throwable t) {
handleFailure(t);
}
});
thread.start();
// ...
}
@Override
public void close() {
// Guarantees: all subtasks finished by the time of exit
join();
}
}
Resource Safety
The model guarantees that by the time you exit try, all threads are finished:
// IMPOSSIBLE:
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
scope.fork(() -> longRunningTask());
return; // ← Compiler won't allow! Scope must be closed
}
// By the time we exit } — all tasks are finished
Integration with Virtual Threads
// StructuredTaskScope is designed for Virtual Threads
try (var scope = new StructuredTaskScope.ShutdownOnFailure>(
Thread.ofVirtual().factory())) {
// Create a million subtasks — VT scale
for (int i = 0; i < 1_000_000; i++) {
scope.fork(() -> processItem(i));
}
scope.join();
}
// Creating a scope per request — a cheap operation
Scoped Values Integration
// Context propagation without ThreadLocal overhead
static final ScopedValue<UserContext> CONTEXT = new ScopedValue<>();
try (var scope = new StructuredTaskScope.ShutdownOnFailure>()) {
ScopedValue.runWhere(CONTEXT, userContext, () -> {
scope.fork(() -> {
// CONTEXT.get() is available in all subtasks
return fetchUser(CONTEXT.get().userId());
});
scope.fork(() -> {
return fetchOrders(CONTEXT.get().userId());
});
});
scope.join();
}
Diagnostics
JSON Thread Dump
# Only this format shows hierarchy!
jcmd <pid> Thread.dump_to_file -format=json threads.json
# Regular text dump does NOT show hierarchy
jstack <pid>
Programmatically
try (var scope = new StructuredTaskScope.ShutdownOnFailure>()) {
Subtask<?> task = scope.fork(() -> doWork());
// Check status
System.out.println(task.state()); // RUNNING, SUCCESS, FAILED
scope.join();
if (task.state() == Subtask.State.SUCCESS) {
System.out.println(task.get());
}
}
When to Use
| Scenario | StructuredTaskScope | ExecutorService |
|---|---|---|
| Business transaction (multiple steps) | Yes | No |
| Mirror requests | Yes (ShutdownOnSuccess) | No |
| Long-running background service | No | Yes |
| Continuous queue processing | No | Yes |
| Parallel independent tasks | Yes | Yes |
Best Practices
- Use for business transactions — when several steps must complete together
- ShutdownOnFailure — when all steps are mandatory
- ShutdownOnSuccess — for mirror requests
- Combine with VT — for scalability
- Scoped Values — for context propagation instead of ThreadLocal
- try-with-resources — guarantees all threads finish
- JSON thread dump — for debugging hierarchy
- Not for long-running services — use ExecutorService
When NOT to Use Structured Concurrency
- Long-running background services (daemons, queue processors) — StructuredTaskScope requires all tasks to finish when exiting the block. For “eternal” tasks, use ExecutorService
- Below Java 21 — API is in preview, may change. Use CompletableFuture + ExecutorService
- Independent parallel tasks without a shared transaction — if tasks are truly independent, it’s simpler to use
executor.submit()without scope overhead - Need full lifecycle control — if you need to manually cancel, suspend, resume tasks, ExecutorService is more flexible
StructuredTaskScope vs ExecutorService vs CompletableFuture: What to Choose?
| Situation | Choice | Why |
|---|---|---|
| Business transaction (fetchUser + fetchOrders together) | StructuredTaskScope | Auto-cancel on error, completion guarantee |
| Mirror requests (whoever is faster) | StructuredTaskScope.ShutdownOnSuccess | Auto-cancel slow ones |
| Background queue processing | ExecutorService | Infinite loop, scope not suitable |
| Callback chains (thenApply, thenCompose) | CompletableFuture | Async pipeline, doesn’t block |
| Parallel independent tasks | Any | But StructuredTaskScope is simpler for debugging |
Interview Cheat Sheet
Must know:
- Structured Concurrency: group of tasks = single unit; parent waits for all child tasks to complete
- try-with-resources equivalent for multithreading: by block exit, all threads are guaranteed finished
- ShutdownOnFailure: one failed — others cancelled (business transactions)
- ShutdownOnSuccess: first completed — others cancelled (mirror requests)
StructuredTaskScope— Preview API in Java 21, may change- Integration with Virtual Threads and Scoped Values for scalability and context propagation
- Difference from ExecutorService: no thread leaks, automatic cancellation, hierarchical thread dump
Frequent follow-up questions:
- Why is this better than ExecutorService + CompletableFuture? — ExecutorService: orphan threads, manual cancellation, possible leaks. SC: automatic lifecycle management, cascading cancellation, impossible to leak
- How does SC work with Virtual Threads? — SC is created with
Thread.ofVirtual().factory()— all fork tasks run on VT, scale to millions - When NOT to use SC? — For long-running background services (daemons, queue processors) — SC requires completion on block exit
- How to debug SC? — JSON thread dump (
jcmd Thread.dump_to_file -format=json) shows parent-child hierarchy
Red flags (DO NOT say):
- “Structured Concurrency is a stable API in Java 21” —
StructuredTaskScopeis still in Preview - “SC replaces ExecutorService completely” — SC for business transactions, ExecutorService for long-running services
- “SC automatically handles all exceptions” — you need to call
throwIfFailed()afterjoin() - “You can return a result from an SC block before it completes” — SC block must be closed (try-with-resources), all tasks must finish before exit
Related topics:
- [[23. What are Virtual Threads in Java 21]]
- [[24. What are the advantages of Virtual Threads over regular threads]]
- [[25. When should you use Virtual Threads]]
- [[28. What are Callable and Future]]