Java / Java 25 Structured Concurrency Interview questions
Last updated
1. What is structured concurrency in Java?
Structured concurrency treats a group of related tasks running in different threads as one unit of work. The code that starts the tasks is the same code that waits for them, so their lifetime is confined to a single block.
In Java 25 it is exposed through StructuredTaskScope, a preview API. You open a scope in a try-with-resources block, fork subtasks, join, and read results. When the block exits, no subtask is still running.
try (var scope = StructuredTaskScope.open()) { var user = scope.fork(() -> findUser(id)); var order = scope.fork(() -> findOrder(id)); scope.join(); return new Page(user.get(), order.get()); }
Take quiz
When the JVM shuts down
When its thread is garbage collected
Before the block that forked it exits
Whenever the thread pool decides
ThreadGroup
ExecutorCompletionService
ForkJoinPool.commonPool()
StructuredTaskScope
2. What problem does structured concurrency solve?
It fixes the gap between how code looks and how it runs. With plain ExecutorService and Future, a method can return while tasks it started are still running somewhere else.
- Thread leaks: if one future fails, its siblings keep running unless you cancel them by hand.
- Lost errors: a failure in a worker thread is only seen if someone calls
get(). - Weak observability: thread dumps show no link between a task and the request that started it.
Structured concurrency ties subtasks to a scope so failure, cancellation and cleanup follow the code's block structure.
Take quiz
It is cancelled automatically
It is restarted on another thread
It throws the same exception
It keeps running unless you cancel it manually
Slow SQL query plans
Orphaned subtasks
Missing cancellation of siblings
Unclear task-to-request relationships in thread dumps
3. What is StructuredTaskScope?
StructuredTaskScope<T, R> is the class in java.util.concurrent that owns a set of subtasks and enforces their structure. It implements AutoCloseable, so it is always used in try-with-resources.
The type parameters matter: T is the result type of the subtasks, and R is what join() returns. A scope is created with the static open() factory methods, not with a constructor.
In Java 25 it is a preview API (JEP 505), so you need --enable-preview to compile and run code that uses it.
Take quiz
The result returned by join()
The thread type used for subtasks
The request type of the caller
The number of subtasks allowed
new StructuredTaskScope()
StructuredTaskScope.open()
StructuredTaskScope.start()
Executors.newStructuredScope()
4. Is structured concurrency final in Java 25?
No. Java 25 (an LTS release) ships structured concurrency as its fifth preview, defined by JEP 505. Preview means the API is complete enough to use but can still change in a later release.
| API | Status in Java 25 |
StructuredTaskScope |
Preview (JEP 505) |
ScopedValue |
Final (JEP 506) |
| Virtual threads | Final (since Java 21) |
So you can try it in real code, but expect to pass --enable-preview and to revisit usage when the API settles.
Take quiz
JEP 506
JEP 505
JEP 491
JEP 453
StructuredTaskScope
StructuredTaskScope.Joiner
ScopedValue
StructureViolationException
5. How do you enable the structured concurrency preview in Java 25?
Pass --enable-preview at both compile time and run time, and compile against release 25.
javac --release 25 --enable-preview App.java java --enable-preview App # single-file source launcher java --enable-preview --source 25 App.java
In Maven, add <compilerArgs><arg>--enable-preview</arg></compilerArgs> with <release>25</release>, and set the same flag in Surefire's argLine. In Gradle, add it to compilerArgs and test jvmArgs.
Classes compiled with preview features are marked, and they only run on the same JDK feature release.
Take quiz
--preview-api 25
-XX:+EnablePreview
--release 25 --enable-preview
--add-preview java.base
Preview classes are encrypted
Java 26 deletes StructuredTaskScope
Virtual threads are disabled in 26
Its class file is tied to the exact JDK feature release
6. What is a Subtask in StructuredTaskScope?
StructuredTaskScope.Subtask<T> is the handle returned by fork(). It represents one forked task and extends Supplier<T>.
get(): the result, valid only after a successfuljoin().exception(): the failure, only when the state is FAILED.state(): UNAVAILABLE, SUCCESS or FAILED.task(): the Callable or Runnable that was forked.
Unlike Future, it has no blocking get(timeout) and no cancel(). Waiting and cancelling belong to the scope.
Take quiz
A Future
A CompletableFuture
A Thread
A Subtask
Supplier
Callable
Runnable
Function
7. How do you fork a subtask in a scope?
Call scope.fork(...) with a Callable or a Runnable. It starts the task in a new thread and returns a Subtask straight away, without waiting for the task to finish.
try (var scope = StructuredTaskScope.open()) { Subtask<String> name = scope.fork(() -> loadName()); Subtask<Integer> age = scope.fork(() -> loadAge()); scope.join(); // wait before reading System.out.println(name.get() + " " + age.get()); }
Always call join() before get(). Reading a result earlier throws IllegalStateException.
Take quiz
No, it returns immediately
Yes, always
Only for Runnable tasks
Only when a timeout is configured
fork(Supplier) only
fork(Callable) and fork(Runnable)
fork(Thread)
fork(Future)
8. What does scope.join() do?
join() blocks the owner thread until every subtask is done, or until the joiner decides the scope should be cancelled (for example, after the first failure). It then returns whatever the joiner produces.
It can throw InterruptedException if the owner is interrupted, StructuredTaskScope.FailedException if the joiner reports a failure, and a timeout exception if a configured timeout expires.
Only the owner thread may call it, and it must be called after forking and before reading results or closing the scope.
Take quiz
close() has been called
join() has returned normally
The owner thread has slept
A second subtask has been forked
Any platform thread
Any virtual thread
Only the scope's owner thread
Only the last forked subtask
9. What is a Joiner in StructuredTaskScope?
A Joiner<T, R> is the policy object you pass to open(). It decides when the scope is finished, what to do with each subtask result, and what join() returns.
It has three methods: onFork(subtask) and onComplete(subtask), both returning a boolean, and result(). If either boolean method returns true, the scope is cancelled and remaining subtasks are interrupted.
Joiners replaced the old subclass approach (ShutdownOnFailure, ShutdownOnSuccess) with a composable, testable interface.
Take quiz
onFork()
onComplete()
result()
close()
It is restarted
It pauses until the owner resumes it
It closes and rethrows the subtask exception
It is cancelled
10. List the built-in Joiner factory methods in Java 25?
| Factory method | join() returns | Behaviour |
awaitAllSuccessfulOrThrow() |
Void |
Waits for all; cancels on first failure (default) |
allSuccessfulOrThrow() |
List<T> |
Collects all results; cancels on first failure |
anySuccessfulOrThrow() |
T |
First success wins; others cancelled |
awaitAll() |
Void |
Waits for all; failures do not cancel |
allUntil(Predicate) |
List<Subtask<T>> |
Cancels when the predicate returns true |
For anything else, implement the Joiner interface yourself.
Take quiz
awaitAll()
anySuccessfulOrThrow()
awaitAllSuccessfulOrThrow()
allSuccessfulOrThrow()
anySuccessfulOrThrow()
awaitAll()
allSuccessfulOrThrow()
awaitAllSuccessfulOrThrow()
11. How do you open a StructuredTaskScope in Java 25?
Use one of the static open() overloads. Constructors are no longer part of the API.
// default joiner: awaitAllSuccessfulOrThrow() var s1 = StructuredTaskScope.open(); // choose a joiner var s2 = StructuredTaskScope.open(Joiner.<String>allSuccessfulOrThrow()); // joiner + configuration var s3 = StructuredTaskScope.open( Joiner.<String>anySuccessfulOrThrow(), cf -> cf.withName("quote-race").withTimeout(Duration.ofSeconds(2)));
Always assign it in a try-with-resources header so close() runs on every path.
Take quiz
StructuredTaskScope.open(Joiner.allSuccessfulOrThrow())
new StructuredTaskScope.ShutdownOnFailure()
StructuredTaskScope.of(List.class)
Joiner.open(scope)
As an argument to fork()
In the Configuration function passed to open()
In a system property
Through Joiner.timeout()
12. What are the states of a Subtask?
| State | Meaning | Safe call |
UNAVAILABLE |
Not finished, or cancelled before it could complete | none |
SUCCESS |
Completed with a result | get() |
FAILED |
Completed with an exception | exception() |
Check state() after join() when you use a joiner such as awaitAll(), which does not stop on failure. Calling get() on a non-SUCCESS subtask throws IllegalStateException.
Take quiz
PENDING
UNAVAILABLE
CANCELLED
RUNNING
SUCCESS
UNAVAILABLE
FAILED
Any state
13. What is the default Joiner when you call open() with no arguments?
It is Joiner.awaitAllSuccessfulOrThrow(). The scope waits for all subtasks. If one fails, the scope is cancelled and join() throws FailedException with that failure as the cause.
The joiner produces no value, so join() returns null (type Void). You read each result through its own Subtask handle.
try (var scope = StructuredTaskScope.open()) { var a = scope.fork(() -> 1); var b = scope.fork(() -> 2); scope.join(); // throws FailedException on any failure int sum = a.get() + b.get(); }
Take quiz
A list of results
The first result
null (Void)
The owner thread
Ignores the failure
Waits for the rest, then returns normally
Retries the subtask once
Cancels the scope and join() throws FailedException
14. What exceptions can StructuredTaskScope.join() throw?
| Exception | When |
InterruptedException |
Owner thread interrupted while waiting |
StructuredTaskScope.FailedException |
Joiner's result() reports a subtask failure |
StructuredTaskScope.TimeoutException |
Configured timeout expired before completion |
WrongThreadException |
Caller is not the owner thread |
IllegalStateException |
Scope already closed or used out of order |
Only InterruptedException is checked. The others are unchecked, so you decide where to handle them.
Take quiz
ExecutionException
CompletionException
RejectedExecutionException
StructuredTaskScope.FailedException
InterruptedException
TimeoutException from java.util.concurrent
FailedException
WrongThreadException
15. Why is structured concurrency still a preview API in Java 25?
The API has changed in almost every round, and the maintainers want more real-world feedback before locking it in. The path was incubator in Java 19 and 20, then previews in 21, 22, 23, 24 and 25.
Java 25's JEP 505 made the biggest change so far. The public constructors and the ShutdownOnFailure and ShutdownOnSuccess subclasses were replaced by static open() methods plus Joiner policies. join() now returns a value, and the deadline-based joinUntil became a timeout configuration.
Marking it preview lets the JDK team keep reshaping the API without breaking a stable contract.
Take quiz
Static open() methods with Joiner policies replaced constructors and subclasses
fork() became blocking
Subtask was removed
ThreadGroup support was added
Java 17
Java 21
Java 22
Java 25
16. How do you read a subtask's result?
Call get() on the Subtask after join() has returned. For a subtask that failed, use exception() instead.
scope.join(); if (task.state() == Subtask.State.SUCCESS) { use(task.get()); } else if (task.state() == Subtask.State.FAILED) { log.warn("failed", task.exception()); }
Calling get() before join(), or on a subtask that is not SUCCESS, throws IllegalStateException. With allSuccessfulOrThrow() you can skip handles entirely and use the list that join() returns.
Take quiz
ExecutionException
IllegalStateException
The original exception
Nothing, it returns null
Call scope.get()
Read thread locals
Use the List returned by join()
Fork the tasks again
17. Why is StructuredTaskScope used with try-with-resources?
Because close() is what enforces the structure. When the block ends, close() cancels any subtask still running by interrupting it, and then waits for every subtask thread to finish.
That guarantee is why no subtask can outlive the block. If you forked tasks but never called join(), close() still cleans up, but it throws IllegalStateException to flag the bug.
Only the owner thread may close the scope. Leaving it open, as you could with an executor, defeats the model.
Take quiz
Lets them continue in the background
Stops them with Thread.stop()
Interrupts them and waits for their threads to end
Ignores them
It makes fork() faster
It enables the preview feature
It selects the thread factory
It guarantees the scope is closed on every code path
18. What type of thread does a subtask run on by default?
Each subtask runs in its own new virtual thread. Virtual threads are cheap, so one thread per task is fine even for thousands of subtasks, and there is no pool to size or share.
You can change this with Configuration.withThreadFactory(...), for example to name threads or to use platform threads for a special case.
var factory = Thread.ofVirtual().name("fetch-", 0).factory(); try (var scope = StructuredTaskScope.open( Joiner.<String>allSuccessfulOrThrow(), cf -> cf.withThreadFactory(factory))) { // ... }
Take quiz
A shared platform pool
The common ForkJoinPool
The owner thread, reused
One new virtual thread each
cf.withThreadFactory(factory)
System.setProperty("scope.threads", ...)
fork(thread)
Subclass Subtask
19. What is StructuredTaskScope.FailedException?
It is the unchecked exception join() throws when a failure-aware joiner such as allSuccessfulOrThrow() or the default joiner sees a subtask fail. It extends RuntimeException.
The real error is available through getCause(). This keeps one predictable type at the join point while the original exception is preserved.
try { scope.join(); } catch (StructuredTaskScope.FailedException e) { Throwable root = e.getCause(); // exception from the failed subtask }
Take quiz
Unchecked, a RuntimeException subclass
Checked, an Exception subclass
An Error subclass
Thrown by fork()
scope.exception()
e.getCause()
Thread.getUncaughtExceptionHandler()
e.getMessage() only
20. What is the owner thread of a scope?
The owner is the thread that called open(). It controls the scope's lifecycle, so only it may call join() and close(). Another thread doing so gets WrongThreadException.
Subtasks do not own the scope. They can do work and, if they are inside the scope's thread tree, fork into it, but they cannot decide when it ends.
This single-owner rule keeps lifetime tied to one block of code.
Take quiz
IllegalMonitorStateException
WrongThreadException
SecurityException
StructureViolationException
The first forked subtask
Always the main thread
The thread that opened it
The thread with highest priority
21. What is the difference between StructuredTaskScope and ExecutorService?
| Aspect | ExecutorService | StructuredTaskScope |
| Task lifetime | Independent of caller | Bound to the try block |
| Failure handling | Manual via Future.get() | Joiner policy, cancels siblings |
| Cancellation | Manual future.cancel() | Automatic on failure, success or timeout |
| Threads | Usually a shared pool | New virtual thread per subtask |
| Lifetime of the object | Long-lived, shared | Short-lived, per operation |
Use an executor for long-lived shared workers. Use a scope for a fan-out where all subtasks serve one operation.
Take quiz
You, with future.cancel()
The operating system
The scope and joiner, automatically
The garbage collector
StructuredTaskScope, since it pools threads
Neither
Only ScopedValue
ExecutorService
22. How does allSuccessfulOrThrow() handle a failing subtask?
As each subtask completes, the joiner stores successful results. On the first failure it records the exception and returns true from onComplete, which cancels the scope and interrupts the other subtasks.
join() then throws FailedException with the first failure as its cause. If everything succeeds, join() returns a List<T> of results in the order the subtasks were forked.
try (var scope = StructuredTaskScope.open(Joiner.<Price>allSuccessfulOrThrow())) { scope.fork(() -> priceFrom("A")); scope.fork(() -> priceFrom("B")); List<Price> prices = scope.join(); }
Take quiz
Waits for all, then returns partial results
Retries the failed subtask
Returns an empty list
Cancels the scope and join() throws FailedException
A List of results
A single result
null
A Stream of Subtask objects
23. How does anySuccessfulOrThrow() work?
It returns the result of the first subtask that succeeds and cancels the rest. Failures are ignored as long as at least one subtask succeeds.
If every subtask fails, join() throws FailedException carrying a subtask failure as its cause. It fits redundant lookups, like asking three mirrors for the same quote and using the fastest answer.
try (var scope = StructuredTaskScope.open(Joiner.<Quote>anySuccessfulOrThrow())) { scope.fork(() -> mirrorA.quote(sym)); scope.fork(() -> mirrorB.quote(sym)); Quote fastest = scope.join(); }
Take quiz
Querying redundant replicas and taking the fastest answer
Summing independent numbers
Waiting for every task
Retrying the same task
join() returns null
join() throws FailedException
join() blocks forever
join() returns the last result
24. How does awaitAll() differ from awaitAllSuccessfulOrThrow()?
| awaitAll() | awaitAllSuccessfulOrThrow() | |
| On failure | Keeps waiting for others | Cancels scope, join() throws |
| join() result | null |
null |
| Who checks errors | You, through state() |
The joiner |
| Best for | Collecting partial results | All-or-nothing work |
With awaitAll(), every subtask runs to the end and you decide what a failure means. Remember to inspect each subtask's state before calling get().
Take quiz
awaitAllSuccessfulOrThrow()
awaitAll()
anySuccessfulOrThrow()
allSuccessfulOrThrow()
join() throws FailedException
Catch ExecutionException
Check subtask.state() after join()
Read the scope's error log
25. How do you set a timeout on a StructuredTaskScope?
Use the configuration function passed to open(): cf.withTimeout(Duration). The clock starts when the scope opens.
try (var scope = StructuredTaskScope.open( Joiner.<String>allSuccessfulOrThrow(), cf -> cf.withTimeout(Duration.ofSeconds(3)))) { scope.fork(() -> slowCall()); return scope.join(); } catch (StructuredTaskScope.TimeoutException e) { return fallback(); }
When the time runs out, the scope is cancelled, running subtasks are interrupted, and join() throws a timeout exception. This replaces the older joinUntil(Instant).
Take quiz
InterruptedException
SocketTimeoutException
StructuredTaskScope.TimeoutException
DeadlineExceededError
They keep running
They are paused
They finish and results are silently dropped
They are cancelled by interruption
26. How does cancellation work in a StructuredTaskScope?
Cancellation is cooperative and interrupt-based. A scope is cancelled when the joiner returns true from onFork or onComplete, when the timeout expires, or when the owner is interrupted during join().
- The scope marks itself cancelled.
- Every running subtask thread is interrupted.
- Forks made after cancellation are not run; their subtasks stay UNAVAILABLE.
close()waits for all threads to exit.
Blocking calls like sleep, I/O on virtual threads and queue operations respond to interrupts. A tight CPU loop does not unless it checks the interrupt flag, and that delays close().
Take quiz
It calls Thread.stop()
It kills the JVM process
It sets a flag the Joiner polls
It interrupts their threads
A subtask that stays UNAVAILABLE and does not run
A subtask that runs normally
An immediate FailedException
A subtask in SUCCESS state with null
27. How do you set a scope name and thread factory?
Both go through the Configuration object passed to open(). withName(String) labels the scope, and withThreadFactory(ThreadFactory) controls how subtask threads are created.
try (var scope = StructuredTaskScope.open( Joiner.<Profile>allSuccessfulOrThrow(), cf -> cf.withName("profile-load") .withThreadFactory(Thread.ofVirtual().name("pf-", 0).factory()))) { // fork tasks }
A scope name shows up in JSON thread dumps, so choose something that identifies the operation, not a generic label.
Take quiz
On the Configuration passed to open()
On the Subtask
On the Joiner
On Thread.currentThread()
It speeds up fork()
It appears in structured thread dumps
It raises thread priority
It is required by preview mode
28. Why can only the owner thread call join() and close()?
A scope represents one block of code in one thread. If any thread could join or close it, the link between the scope's lifetime and that block would break, and the guarantee that subtasks never outlive their parent would be lost.
The restriction is enforced at run time with WrongThreadException. Subtasks can still do useful work, and threads inside the scope can fork more subtasks into it, but the end of the scope stays under the owner's control.
This mirrors how a local variable or a try block belongs to one thread of control.
Take quiz
Memory usage of virtual threads
The tie between scope lifetime and one block of code
The size of the thread pool
Class loading order
IllegalArgumentException
ConcurrentModificationException
WrongThreadException
UnsupportedOperationException
29. What happens if you forget to call join()?
When the try block ends, close() sees that subtasks were forked but join() was never called. It cancels the subtasks, waits for them, and then throws IllegalStateException to report the bug.
try (var scope = StructuredTaskScope.open()) { var t = scope.fork(() -> work()); // join() missing return t.get(); // IllegalStateException here already }
Even before close, get() throws because results are only visible after a join. The scope still cleans up, so you get an error instead of a silent leak.
Take quiz
Silently ignores it
Calls join() for you
Cancels subtasks and throws IllegalStateException
Throws FailedException
get() needs a timeout argument
The thread factory blocks it
The Subtask is still being compiled
Results are only available after the owner joins
30. What is StructureViolationException?
It is thrown when the nesting rules for scopes or scoped values are broken. The runtime keeps a stack of scopes per thread and checks that they are closed in the reverse order they were opened.
A typical cause is closing an outer scope while an inner one is still open, or leaking a scope out of the thread that opened it. It signals a bug in how scopes are used, not a failure of a subtask.
var outer = StructuredTaskScope.open(); var inner = StructuredTaskScope.open(); outer.close(); // inner still open -> StructureViolationException
Take quiz
A subtask returned null
A network timeout
A joiner returned the wrong type
Scopes were used out of their required nesting order
Closing an outer scope before an inner one
Calling fork() twice
Calling join() after close()
Using a Callable instead of Runnable
31. How do scoped values work with structured concurrency?
A ScopedValue binds an immutable value for the duration of a call. Threads forked by a StructuredTaskScope automatically inherit the owner's bindings, so request data such as a user or trace ID flows to subtasks without parameters.
static final ScopedValue<User> USER = ScopedValue.newInstance(); ScopedValue.where(USER, currentUser).run(() -> { try (var scope = StructuredTaskScope.open()) { scope.fork(() -> audit(USER.get())); // sees currentUser scope.join(); } });
Bindings disappear when the run call returns, matching the scope's lifetime. ScopedValue is final in Java 25 even though the scope is preview.
Take quiz
The owner's binding, inherited automatically
Always an empty value
A mutable copy per thread
Only values set with set()
When the JVM exits
When the where(...).run(...) call returns
When the garbage collector runs
When the first subtask finishes
32. Why are ThreadLocals discouraged with subtasks?
Each subtask runs on a new thread, so a ThreadLocal set by the owner is not visible there. Copying values through InheritableThreadLocal adds per-thread cost, and the copies are mutable, which makes sharing state risky.
A thread-local also has no clear end of life. It lives until the thread dies or someone calls remove(), and that mismatches the short, scoped life of a subtask.
ScopedValue fixes this. It is immutable, inherited cheaply by structured subtasks, and unbound when the block ends.
Take quiz
Subtasks run in the same thread
The subtask runs in a different, new thread
ThreadLocal is removed in Java 25
Virtual threads cannot use ThreadLocal
Static mutable fields
System properties
ScopedValue
ThreadGroup
33. How do nested scopes behave in structured concurrency?
A subtask can open its own scope. The inner scope must be closed before the subtask ends, and the subtask itself must finish before the outer scope closes. The result is a tree of scopes that matches the call structure.
flowchart TD
A["Owner opens outer scope"] --> B["fork subtask 1"]
A --> C["fork subtask 2"]
B --> D["Subtask 1 opens inner scope"]
D --> E["fork leaf A"]
D --> F["fork leaf B"]
Cancelling the outer scope interrupts subtask 1. Its join() is interrupted, its inner scope closes, and the leaf tasks are cancelled in turn, so cancellation moves down the tree.
Take quiz
The inner scope keeps running
The inner scope is left open
The subtask is interrupted and closing its inner scope cancels the leaves
The JVM exits
Outer first, then inner
Any order
Alphabetical by name
Inner first, then outer
34. What is allUntil() and when should you use it?
Joiner.allUntil(Predicate<Subtask<T>>) runs subtasks until the predicate returns true for a completed subtask, then cancels the rest. join() returns a List<Subtask<T>> of all forked subtasks, so you inspect the state of each.
var count = new AtomicInteger(); try (var scope = StructuredTaskScope.open( Joiner.<Hit>allUntil(st -> st.state() == Subtask.State.SUCCESS && count.incrementAndGet() >= 3))) { shards.forEach(s -> scope.fork(() -> s.search(q))); List<Subtask<Hit>> all = scope.join(); }
Use it when you need a custom stop rule, such as 'stop after 3 successes', and want to see unfinished subtasks as UNAVAILABLE.
Take quiz
A single result
null
A Map of results by thread name
A List of Subtask objects
When the predicate returns true
After a fixed 1 second
Only on exception
Never
35. What is the difference between a Joiner and the old ShutdownOnFailure?
| Java 21-24 style | Java 25 style | |
| Policy | Subclass the scope | Pass a Joiner |
| Creation | new ShutdownOnFailure() |
open(joiner) |
| Failure check | throwIfFailed() after join |
join() throws FailedException |
| Result | From Subtask handles |
Returned by join() |
| Deadline | joinUntil(Instant) |
withTimeout(Duration) |
The old classes baked a policy into inheritance, and custom policies meant subclassing a scope. A Joiner is a small, separate object, easy to compose and unit-test.
Take quiz
join() throwing FailedException
scope.check()
Subtask.rethrow()
Thread.getUncaughtExceptionHandler()
By subclassing StructuredTaskScope
By passing a Joiner to open()
By setting a system property
By annotating the method
36. How do you write a custom Joiner?
Implement StructuredTaskScope.Joiner<T, R>. Override onComplete to handle each finished subtask and return true when you want to cancel the scope. Override result() to produce the value join() returns.
class FirstN<T> implements StructuredTaskScope.Joiner<T, List<T>> { private final int n; private final Queue<T> results = new ConcurrentLinkedQueue<>(); FirstN(int n) { this.n = n; } @Override public boolean onComplete(StructuredTaskScope.Subtask<? extends T> st) { if (st.state() == StructuredTaskScope.Subtask.State.SUCCESS) { results.add(st.get()); } return results.size() >= n; // true -> cancel the rest } @Override public List<T> result() { return List.copyOf(results); } }
onComplete is called from subtask threads, so the joiner's state must be thread-safe. Keep it fast. It runs inside the subtask's completion path.
Take quiz
join() runs on every thread
onComplete is invoked from the subtask threads
Joiner is serialized to disk
The JVM requires it for preview features
Call System.exit()
Throw InterruptedException from result()
Return true from onComplete
Call Thread.stop() on the owner
37. What happens when one subtask throws while others are still running?
With a failure-aware joiner, the failed subtask's completion triggers onComplete. The joiner records the exception and returns true, so the scope is cancelled and the sibling threads are interrupted.
sequenceDiagram
participant O as Owner thread
participant S as Scope
participant A as Subtask A
participant B as Subtask B
O->>S: fork A and B, then join()
A--xS: throws exception
S->>B: interrupt (cancel)
S-->>O: join() throws FailedException(cause)
O->>S: close() waits for B to exit
Exceptions thrown by siblings after cancellation are not reported. Only the first failure becomes the cause. The owner wakes up in join(), and close() still waits for interrupted siblings to finish.
Take quiz
The last subtask failure
All failures merged into one
The first subtask failure
A random subtask failure
Returns immediately without waiting
Restarts them
Forces them with Thread.stop()
Waits for interrupted siblings to exit
38. What happens if the owner thread is interrupted during join()?
join() throws InterruptedException and the scope is cancelled, so running subtasks are interrupted too. If the exception propagates out of the try block, close() waits for them to finish and the interrupt flag effectively moves down the tree.
try (var scope = StructuredTaskScope.open()) { scope.fork(() -> work()); scope.join(); } catch (InterruptedException e) { Thread.currentThread().interrupt(); // restore the flag throw new RuntimeException("cancelled", e); }
If you catch and swallow the exception, restore the interrupt flag so callers further up still see the cancellation request.
Take quiz
TimeoutException
WrongThreadException
CancellationException
InterruptedException
Restore the interrupt flag or rethrow
Ignore it completely
Call join() in a loop
Call System.exit()
39. How do you return partial results when one subtask is slow?
Pick Joiner.awaitAll() so a failure does not cancel the scope, and give each subtask its own fallback or deadline inside the task itself.
try (var scope = StructuredTaskScope.open(Joiner.<Optional<Rec>>awaitAll())) { var a = scope.fork(() -> safe(() -> recs.fromA(id), Duration.ofMillis(300))); var b = scope.fork(() -> safe(() -> recs.fromB(id), Duration.ofMillis(300))); scope.join(); return Stream.of(a, b) .filter(t -> t.state() == Subtask.State.SUCCESS) .map(Subtask::get) .flatMap(Optional::stream) .toList(); }
Here safe(...) catches errors and timeouts and returns Optional.empty(). A scope-wide timeout is a poor fit because it cancels everything and join() throws, discarding the fast results.
Take quiz
awaitAll()
anySuccessfulOrThrow()
allSuccessfulOrThrow()
awaitAllSuccessfulOrThrow()
It slows down fast subtasks
It cancels all subtasks and join() throws
It is not supported with awaitAll()
It converts results to null
40. Why should subtask code handle interruption correctly?
Cancellation in a scope is delivered as an interrupt. A subtask that ignores it keeps running, and because close() waits for every thread, the owner can hang after a failure or timeout.
- Let
InterruptedExceptionpropagate, or restore the flag withThread.currentThread().interrupt(). - In long CPU loops, check
Thread.currentThread().isInterrupted()regularly. - Prefer interruptible APIs, such as timeouts on HTTP clients and queue
pollwith a timeout.
scope.fork(() -> { while (!Thread.currentThread().isInterrupted()) { if (processNextChunk()) break; } return null; });
Take quiz
The JVM kills it instantly
close() waits on it and the owner can hang
The scope ignores cancellation
It restarts automatically
Call System.gc()
Catch Error
Check Thread.currentThread().isInterrupted()
Poll scope.state()
41. How do you troubleshoot structured concurrency with thread dumps?
Use the JSON thread dump format. Unlike the plain-text dump, it groups threads by the container that owns them, so you can see which scope a subtask belongs to and how scopes nest.
jcmd <pid> Thread.dump_to_file -format=json /tmp/dump.json
Look for the scope's name (set with withName), its owner thread, and the list of subtask threads under it. A scope with many blocked subtasks and an owner parked in join() usually means one subtask ignores interruption or a downstream call has no timeout.
Give scopes descriptive names and subtask threads a naming factory to make dumps easy to read.
Take quiz
jcmd <pid> VM.flags
jcmd <pid> GC.run
jcmd <pid> Thread.dump_to_file -format=json file
jcmd <pid> Thread.print -l
The scope has finished
The JVM is out of metaspace
The scope was never opened
A subtask is stuck, for example a call without a timeout
42. How does structured concurrency compare with CompletableFuture?
| Aspect | CompletableFuture | StructuredTaskScope |
| Style | Callback chains | Plain blocking code |
| Error handling | exceptionally, handle |
try/catch around join() |
| Cancellation | Not propagated to siblings | Automatic via joiner |
| Lifetime | Free-floating | Bound to a block |
| Stack traces | Fragmented across stages | Natural, one thread per task |
CompletableFuture is still useful for asynchronous pipelines that cross method boundaries. For fan-out inside one method, a scope is easier to read and debug, and it cannot leak tasks.
Take quiz
CompletableFuture.allOf
ExecutorService.invokeAll
Plain Thread.start()
StructuredTaskScope with a failure-aware joiner
try/catch around join()
exceptionally() stages
A global error callback
Future.get() on each stage
43. How do you test code that uses StructuredTaskScope?
First enable preview for the test JVM, for example in Maven Surefire: <argLine>--enable-preview</argLine>. Then test the three paths separately: success, failure and cancellation.
@Test void failurePropagatesAndCancelsSibling() { var interrupted = new CountDownLatch(1); assertThrows(StructuredTaskScope.FailedException.class, () -> { try (var scope = StructuredTaskScope.open()) { scope.fork(() -> { throw new IllegalStateException("boom"); }); scope.fork(() -> { try { Thread.sleep(5_000); } catch (InterruptedException e) { interrupted.countDown(); throw e; } return null; }); scope.join(); } }); assertEquals(0, interrupted.getCount()); // sibling was cancelled }
Use latches instead of sleeps to keep tests deterministic, and inject the work so you can simulate slow or failing subtasks.
Take quiz
The sibling subtask was interrupted
The scope timed out
join() was never called
The test used platform threads
skipTests=true
argLine with --enable-preview
forkCount=0
reuseForks=preview
44. How can you limit concurrency inside a StructuredTaskScope?
A scope starts one virtual thread per fork, so forking 10,000 tasks starts 10,000 threads. That is cheap for the JVM, but a database or remote API may not cope. The standard answer is a Semaphore, which limits only the part that needs it.
var permits = new Semaphore(20); try (var scope = StructuredTaskScope.open(Joiner.<Resp>allSuccessfulOrThrow())) { for (String url : urls) { scope.fork(() -> { permits.acquire(); try { return http.get(url); } finally { permits.release(); } }); } return scope.join(); }
Do not use a fixed-size platform thread pool to throttle. It throws away the advantage of virtual threads and brings back queueing.
Take quiz
A fixed platform thread pool
A Semaphore around the downstream call
Forking fewer tasks than data items
Setting a smaller timeout
It is not allowed by the compiler
It disables join()
It discards the benefits of virtual threads
It breaks ScopedValue
45. Why does synchronized pinning matter for subtasks?
Subtasks run on virtual threads. Before JDK 24, blocking inside a synchronized block pinned the virtual thread to its carrier thread, so it could not be unmounted. With many subtasks this could exhaust the carrier threads and stall the application.
JEP 491, delivered in Java 24 and present in Java 25, removed pinning for synchronized. Blocking in monitors now releases the carrier thread.
Pinning can still happen in native frames or foreign function calls. If subtasks stall for no obvious reason, check for blocking native calls, or run with -Djdk.tracePinnedThreads=full to see where pinning occurs.
Take quiz
JEP 505
JEP 506
JEP 491
JEP 444
Blocking inside synchronized
Calling Thread.sleep
Using ReentrantLock
Blocking inside a native frame
46. How do you unwrap FailedException to handle the real error?
Catch StructuredTaskScope.FailedException and inspect getCause(). Convert it to a domain exception at the boundary of your method so callers do not need to know about the scope.
try (var scope = StructuredTaskScope.open(Joiner.<Order>allSuccessfulOrThrow())) { // fork... return scope.join(); } catch (StructuredTaskScope.FailedException e) { Throwable cause = e.getCause(); if (cause instanceof PaymentDeclined pd) throw pd; if (cause instanceof RuntimeException re) throw re; throw new OrderFailed(cause); } catch (InterruptedException e) { Thread.currentThread().interrupt(); throw new OrderFailed(e); }
Do not log the wrapper and the cause separately, or you will produce duplicate entries.
Take quiz
getSuppressed()[0]
scope.errors()
Thread.currentThread().getName()
getCause()
To hide the concurrency mechanism from callers
To speed up join()
To satisfy the compiler
To enable preview features
47. When would you choose structured concurrency over parallel streams?
| Parallel streams | StructuredTaskScope | |
| Workload | CPU-bound data parallelism | I/O-bound fan-out |
| Tasks | Same operation on many elements | Different operations allowed |
| Threads | Common ForkJoinPool | Virtual thread per subtask |
| Cancellation on failure | Not coordinated | Built in via joiner |
| Timeout | None built in | withTimeout |
Use a scope when you call a few services in parallel and care about failure, cancellation and timeouts. Use parallel streams for number crunching over large in-memory collections.
Take quiz
Calling several remote services in parallel
Sorting a large in-memory array
Summing an int array
Mapping a million CPU-heavy elements
One virtual thread per element
The common ForkJoinPool
A scope's thread factory
The owner thread only
48. Explain the lifecycle of a StructuredTaskScope?
A scope goes through a fixed sequence, and the compiler and runtime both help keep it in order.
flowchart LR
A[open] --> B["fork subtasks"]
B --> C[join]
C --> D["read results"]
D --> E[close]
C -. failure or timeout .-> F["cancel and interrupt"]
F --> E
- Open:
open(joiner, config)creates the scope and makes the current thread its owner. - Fork: each fork starts a subtask thread and calls
joiner.onFork. - Join: the owner waits. Each completion calls
joiner.onComplete, which may cancel the scope. - Read: results come from
join()orSubtask.get(). - Close: unfinished subtasks are interrupted and awaited, and the scope ends.
Take quiz
fork, open, close, join
open, fork, join, read results, close
open, join, fork, close
close, open, fork, join
fork()
open()
close()
Subtask.state()
49. Explain the execution flow of a fan-out request using a scope?
Say a handler needs a user profile and recent orders to build one response. The handler opens a scope, forks both lookups, and blocks in join().
sequenceDiagram
participant H as Handler (owner)
participant S as Scope
participant U as User service
participant O as Order service
H->>S: open(allSuccessfulOrThrow)
H->>S: fork(loadUser)
S->>U: run in virtual thread 1
H->>S: fork(loadOrders)
S->>O: run in virtual thread 2
H->>S: join() blocks
U-->>S: Profile
O-->>S: List of orders
S-->>H: List of results
H->>S: close()
Both lookups run at once, so latency is close to the slower call, not the sum. If one fails, the other is interrupted and the handler gets a FailedException. No lookup can keep running after the handler returns.
Take quiz
The sum of both calls
Always the faster call
About the slower of the two calls
Fixed by the thread pool size
Closes the scope
Calls get() immediately
Starts a third thread manually
Blocks in join() until the scope completes
50. How do you migrate from the Java 21-24 StructuredTaskScope API to Java 25?
| Java 21-24 | Java 25 |
new StructuredTaskScope.ShutdownOnFailure() |
StructuredTaskScope.open() or open(Joiner.allSuccessfulOrThrow()) |
new StructuredTaskScope.ShutdownOnSuccess<T>() |
open(Joiner.anySuccessfulOrThrow()) |
scope.throwIfFailed() |
join() throws FailedException |
scope.joinUntil(deadline) |
cf.withTimeout(Duration) |
ExecutionException on failure |
FailedException |
scope.shutdown() |
Joiner returns true to cancel |
// Before (Java 21-24) try (var scope = new StructuredTaskScope.ShutdownOnFailure()) { var a = scope.fork(() -> loadA()); scope.join().throwIfFailed(); return a.get(); } // After (Java 25) try (var scope = StructuredTaskScope.open()) { var a = scope.fork(() -> loadA()); scope.join(); return a.get(); }
Also review catch blocks, since the exception type changed, and replace any custom subclasses with a Joiner implementation.