skip to content

Virtual Threads & Structured Concurrency

Project Loom's virtual threads, structured concurrency and scoped values — the shift that makes plain blocking code scale to millions of concurrent tasks. This is the hottest modern-Java interview topic, and pinning is its most-asked pitfall.

part ofJavaoverview, primer and where to startread it →
on this pageshow

explore

questions

29

What is structured concurrency in Java, and what problem does it solve compared to launching threads or futures manually?

level: juniorimportance: must knowfreq 70%

answer

  1. Structured programming for threads: lifetimes nest in a block
  2. open scope -> fork subtasks -> join -> read -> close
  3. try-with-resources close() => no orphan can survive
  4. fixes leaks, swallowed errors, no cancellation propagation
  5. pairs with virtual threads (cheap to fork)

basics

~20 s

Structured concurrency ties a group of concurrent tasks to a code block. You open a scope, start subtasks, wait for them all, then close the scope. No task can outlive the block, so you never leak forgotten threads.

solid answer

~50 s

Structured concurrency treats a group of related concurrent tasks as a single unit of work bound to a syntactic block, the way structured programming bound control flow to blocks. In Java you open a StructuredTaskScope in a try-with-resources, fork() each subtask, then join() to wait for all of them before the block exits. Because the scope is closed when the block exits, no subtask can outlive its parent: there are no orphaned threads leaking past the method that started them. This fixes the classic problems of ad-hoc concurrency: a future you forgot to cancel keeps running after an error, exceptions from one task get swallowed, and thread lifetimes are unbounded and untracked. The result reads top-to-bottom like sequential code, gives you a clear parent-child relationship for error and cancellation propagation, and makes thread dumps and debugging far easier because the structure is visible.

go deeper

for a junior

Can state the open-fork-join-close pattern and that no task outlives the block, so threads aren't leaked.

for a middle

Explains the concrete problems with raw Futures (orphans, swallowed errors, no cancellation) and how the scope fixes each.

for a senior

Connects it to structured programming, error/cancellation propagation semantics, and the virtual-threads pairing; knows it's a preview API.

for a principal

Frames it as a concurrency discipline/architecture choice, reasons about migrating an existing Future/Executor codebase, observability, and the preview-stability trade-off for production adoption.

## The problem **Concurrency** means running more than one task at the same time. Traditionally in Java you do this by submitting work to an `ExecutorService` and getting back a `Future` (a handle to a result that isn't ready yet), or by starting raw `Thread`s. This is *unstructured*: once you submit a task, its lifetime is no longer tied to the method that started it. Several things go wrong: - **Thread/task leaks (orphans).** If your method returns or throws before a submitted task finishes, that task keeps running in the background with nobody waiting on it. It is an *orphan* — a thread that outlived the code that created it. - **Swallowed errors.** If task A fails, task B often keeps running pointlessly, and the failure may sit unnoticed inside a `Future` until someone calls `get()`. - **No cancellation propagation.** Cancelling the overall operation does not automatically cancel the in-flight subtasks. - **Hard to read.** The relationship between "the thing I'm doing" and "the helper tasks it spawned" is invisible in the code and in thread dumps. ## Structured programming, applied to threads Decades ago, *structured programming* replaced `goto` with blocks (`if`, `while`, function calls): control flow enters a block at the top and leaves at the bottom, so lifetimes nest cleanly. **Structured concurrency** applies the same principle to concurrent tasks: *if a task splits into concurrent subtasks, they must all complete (or be cancelled) before the parent block exits.* The lifetime of every subtask is **nested** inside the block that created it. ## How Java expresses it: `StructuredTaskScope` Java's API (preview since JDK 21, JEP 453/462/480/505) is the class `StructuredTaskScope`. The pattern is: ```java try (var scope = new StructuredTaskScope<String>()) { Subtask<String> a = scope.fork(() -> fetchUser()); // start subtask Subtask<String> b = scope.fork(() -> fetchOrder()); // start subtask scope.join(); // wait for ALL of them return a.get() + b.get(); // read results } // close() here: guarantees nothing started inside is still running ``` - **`fork(Callable)`** starts a *subtask* on a new (usually virtual) thread and returns a `Subtask` handle. It does **not** block. - **`join()`** blocks the parent until every forked subtask has finished (or the scope's policy decides to stop early). - **try-with-resources** ensures `scope.close()` runs no matter how the block exits — normal return, exception, or otherwise. `close()` is what enforces the structure: it cannot return while any subtask is still alive, so an orphan is impossible. ## Why this is better 1. **No orphans.** The compiler-visible block boundary plus `close()` guarantee every subtask is done before you move on. 2. **Automatic error propagation.** With a shutdown-on-failure policy, if one subtask throws, the scope interrupts the others and `join()` surfaces the error to the parent — you don't have to poll each `Future`. 3. **Cancellation propagation.** Cancelling/interrupting the parent thread propagates down to the children. 4. **Readability and observability.** The code reads like sequential code, and tooling (thread dumps) can show the parent-child tree. ## Virtual threads connection Structured concurrency shines with **virtual threads** (lightweight threads managed by the JVM, cheap enough to have millions). Forking a subtask per unit of work is fine because virtual threads are cheap; the scope keeps their lifetimes disciplined. The two features were designed together. ## Status note The API is a *preview/incubating* feature and its exact shape has evolved across JDK 21–25 (e.g. factory methods, `Joiner` policies). The *concept* — scope-bound lifetimes, fork/join, error and cancellation propagation — is stable and is what interviewers test.

  • What guarantees that no subtask outlives the scope?
    The scope's close() (run by try-with-resources on every exit path) blocks until all forked subtasks have terminated, so the block cannot complete while a subtask is still alive.
  • Why is structured concurrency a natural fit for virtual threads?
    Virtual threads are cheap, so forking one subtask per unit of work is affordable; the scope then keeps those many short-lived threads disciplined and observable.

saying these in an interview costs you the question

  • Saying fork() blocks or runs the task on the current thread — it starts a new thread and returns immediately.
  • Claiming it makes code parallel/faster by itself; it structures lifetimes, the speedup comes from running tasks concurrently.
  • Forgetting to call join() before reading results — Subtask.get() is illegal before join().
  • Thinking it replaces ExecutorService for everything; it targets a group of subtasks scoped to one operation.

context

open as a page

What are the different ways to create and start a virtual thread in Java, and how do they differ?

level: juniorimportance: must knowfreq 72%

basics

~10 s

Use Thread.startVirtualThread(runnable) to create and start one immediately, Thread.ofVirtual().start(runnable) for the same via a builder, .unstarted(runnable) to build without starting, or Executors.newVirtualThreadPerTaskExecutor() to get one virtual thread per submitted task.

open as a page

Why is ScopedValue preferred over ThreadLocal, especially with virtual threads?

level: middleimportance: must knowfreq 55%

basics

~20 s

ThreadLocal data is mutable and lives until you manually remove it, which is easy to forget and wastes memory — bad when you have millions of virtual threads. ScopedValue is immutable, has a clear bounded lifetime, and cleans itself up automatically, so it is cheaper and safer.

open as a page

Walk through the lifecycle of a StructuredTaskScope: what do fork(), join(), Subtask.get(), and close() each do, and in what order must they be called?

level: middleimportance: must knowfreq 62%

basics

~20 s

Open the scope (usually in try-with-resources). fork() starts each subtask and returns a handle without blocking. join() waits for all subtasks to finish. After join, get() reads a successful subtask's result. close() (automatic) shuts the scope and waits for everything to stop.

open as a page

What happens when a virtual thread executes a blocking call such as Thread.sleep or blocking I/O?

level: middleimportance: must knowfreq 80%

basics

~20 s

The virtual thread pauses and unmounts from its carrier (the platform thread running it), freeing that carrier to run other virtual threads. When the blocking call is ready to continue, the virtual thread is remounted and resumes. The carrier is never wasted just waiting.

open as a page

Why should you NOT pool virtual threads, and what should you do instead?

level: middleimportance: must knowfreq 70%

basics

~20 s

Virtual threads are cheap and disposable, so you create a fresh one per task instead of reusing them from a pool. Pooling exists to reuse expensive resources; virtual threads aren't expensive, so a pool only adds a bottleneck. Use Executors.newVirtualThreadPerTaskExecutor().

open as a page

For which kinds of workloads do virtual threads help, and where do they give no benefit?

level: middleimportance: must knowfreq 65%

basics

~20 s

Virtual threads shine for I/O-bound work — tasks that spend most of their time waiting on the network, disk, or other services. For CPU-bound work they give no benefit, because the real limit is the number of CPU cores, not the number of threads.

open as a page

Why were virtual threads added to Java? What problem do they solve?

level: middleimportance: must knowfreq 78%

basics

~20 s

Regular Java threads map one-to-one to OS threads, which are expensive and limited, so you can only have a few thousand. Virtual threads are cheap, so you can have millions and write simple blocking code that scales.

open as a page

What specific code constructs cause a virtual thread to pin, and which common blocking operations do NOT?

level: middleimportance: must knowfreq 60%

basics

~10 s

Pinning is caused by blocking inside a synchronized block/method (on older JDKs) and inside native/JNI calls. Ordinary blocking like socket I/O, Thread.sleep, BlockingQueue, and ReentrantLock do NOT pin—the virtual thread unmounts normally.

open as a page

What is virtual-thread pinning in Java, and why does it matter?

level: middleimportance: must knowfreq 70%

basics

~20 s

Pinning is when a virtual thread can't detach from its OS carrier thread while it blocks, so it keeps that carrier busy. That wastes a scarce carrier and can stall other virtual threads, hurting throughput.

open as a page

Compare the ShutdownOnFailure and ShutdownOnSuccess policies. When would you use each, and how do they change error and cancellation propagation?

level: seniorimportance: must knowfreq 58%

basics

~20 s

ShutdownOnFailure (fan-out, need all): the moment any subtask fails, the scope cancels the rest and the failure is surfaced — use it when you need every result. ShutdownOnSuccess (race, need one): the first success cancels the rest — use it when any one winner is enough.

open as a page

How does a virtual thread actually execute? Explain mounting, unmounting, and carrier threads.

level: seniorimportance: must knowfreq 70%

basics

~20 s

A virtual thread doesn't have its own OS thread. To run, the JVM mounts it onto a carrier (a real platform thread). When it blocks, the JVM unmounts it and stores its stack on the heap, freeing the carrier for other virtual threads.

open as a page

How do you mitigate virtual-thread pinning, and why does replacing synchronized with ReentrantLock work?

level: seniorimportance: must knowfreq 55%

basics

~20 s

Replace blocking synchronized blocks/methods with java.util.concurrent.locks.ReentrantLock using lock()/unlock() in a try/finally. ReentrantLock lets the virtual thread unmount while it waits, so the carrier stays free. Also keep critical sections short and avoid blocking inside them.

open as a page

What is a ScopedValue in Java, and how do you bind and read one?

level: juniorimportance: should knowfreq 35%

basics

~20 s

A ScopedValue is a way to share a fixed piece of data with code called inside a block, without passing it as a parameter. You bind it with ScopedValue.where(KEY, value).run(task); inside that task, KEY.get() returns the value. Outside, it is unbound.

open as a page

How do you create virtual threads, and why is creating millions of them cheap?

level: juniorimportance: should knowfreq 58%

basics

~10 s

Use Thread.ofVirtual().start(...) or Executors.newVirtualThreadPerTaskExecutor(). They're cheap because a virtual thread doesn't reserve a big OS-thread stack — its stack lives on the heap and grows as needed, so millions fit.

open as a page

When do you use .run() versus .call() on a ScopedValue binding, and what are common usage pitfalls?

level: middleimportance: should knowfreq 25%

basics

~20 s

Use .run() when the task returns nothing and throws only unchecked exceptions; use .call() when the task returns a value or throws a checked exception. Common mistakes: reading the value outside the scope (throws NoSuchElementException) and expecting the binding to outlive the run/call call.

open as a page

How do you use Thread.Builder (Thread.ofVirtual()) to configure virtual threads, and when would you prefer it over the one-liner?

level: middleimportance: should knowfreq 48%

basics

~20 s

Thread.ofVirtual() returns a Thread.Builder you can configure — set a name (optionally with an auto-incrementing counter), an uncaught-exception handler, thread-local inheritance — then call .start(runnable) to run it or .unstarted(runnable) to get it ready but not started. Prefer it over startVirtualThread when you need that configuration or a Thread reference before it runs.

open as a page

ScopedValue has no set() method — how do you provide a different value to part of a call, and why is this design chosen?

level: seniorimportance: should knowfreq 30%

basics

~20 s

You can't change a bound value; instead you open a new nested binding with where() around the inner code. The inner value applies only inside that nested block and the outer value is restored when it ends. This keeps data flow predictable and stack-structured.

open as a page

How do ScopedValue bindings interact with structured concurrency and forked subtasks?

level: seniorimportance: should knowfreq 28%

basics

~20 s

When you fork subtasks inside a structured-concurrency scope, those subtasks automatically see the scoped values bound in the parent. You don't pass them in — the bindings are inherited because the subtasks run within the parent's bounded scope, and they're shared, not copied.

open as a page

How does cancellation and error propagation work across the parent/child boundary in a StructuredTaskScope, and what must a subtask do to be cancellable?

level: seniorimportance: should knowfreq 45%

basics

~20 s

Errors flow up: a failing subtask can shut the scope down and surface to the parent. Cancellation flows down: shutting down interrupts the children. But interruption is cooperative — a subtask only stops if it responds to interruption, e.g. by being in a blocking call or checking the interrupt flag.

open as a page

Why is Executors.newVirtualThreadPerTaskExecutor() typically used with try-with-resources, and what does closing it do?

level: seniorimportance: should knowfreq 44%

basics

~20 s

The executor is an AutoCloseable ExecutorService, so try-with-resources closes it automatically at the end of the block. Closing initiates an orderly shutdown and blocks until all submitted tasks finish, so the block won't exit while work is still running.

open as a page

Your service spawns one virtual thread per request, but a downstream API rate-limits you and starts failing. How do you bound concurrency correctly?

level: seniorimportance: should knowfreq 40%

basics

~20 s

Keep one virtual thread per request, but guard the downstream call with a Semaphore sized to what the API allows. Each thread acquires a permit before calling and releases it after, so only that many calls happen at once — without limiting how many threads exist.

open as a page

Why should you avoid heavy or expensive ThreadLocal use with virtual threads, and what's the alternative?

level: seniorimportance: should knowfreq 45%

basics

~20 s

Each virtual thread keeps its own copy of every ThreadLocal value. With millions of virtual threads, expensive per-thread values multiply into a lot of memory. So avoid large/mutable ThreadLocals; prefer ScopedValue for sharing read-only context.

open as a page

How do you detect virtual-thread pinning in a running Java application?

level: seniorimportance: should knowfreq 48%

basics

~10 s

Run with -Djdk.tracePinnedThreads to print a stack trace whenever a thread blocks while pinned (older JDKs), or record a JFR profile and look for jdk.VirtualThreadPinned events. Both show exactly where the pinning happens.

open as a page

When would you choose structured concurrency over CompletableFuture or a shared ExecutorService, and what are its current limitations and adoption risks?

level: principalimportance: should knowfreq 38%

basics

~20 s

Use structured concurrency for a self-contained fan-out within one request, where you want clear lifetimes, automatic cancellation, and errors that surface to the caller. CompletableFuture suits long-lived async pipelines. The main risks: it's still a preview API and shines mainly with virtual threads.

open as a page

Given how virtual-thread creation and blocking work, when are virtual threads the wrong tool, and what design pitfalls remain?

level: principalimportance: should knowfreq 38%

basics

~20 s

Virtual threads help when many tasks spend most of their time blocked on I/O. They don't speed up CPU-bound work, can be undermined by pinning (synchronized/native code keeping the carrier busy), and break old assumptions like thread pooling and thread-local caching. Don't pool them and don't treat them as faster threads.

open as a page

When advising a team migrating a thread-pool-based service to virtual threads, what design principles and pitfalls would you set as guidelines?

level: principalimportance: should knowfreq 35%

basics

~20 s

Stop pooling virtual threads — one per task. Limit concurrency at the downstream resource with semaphores, not by thread count. Use virtual threads only for I/O-bound work. Avoid heavy ThreadLocals and prefer ScopedValue. Watch for pinning on synchronized/native calls.

open as a page

When do virtual threads NOT help, and what are the main pitfalls when adopting them?

level: principalimportance: should knowfreq 52%

basics

~20 s

Virtual threads help concurrency for blocking/I/O-bound work, not raw CPU speed. They don't help CPU-bound tasks, and you can lose the benefit through pinning (e.g. blocking in synchronized) or by overloading a small downstream resource.

open as a page

How did JEP 491 change synchronized pinning, and what should still concern an architect adopting virtual threads at scale?

level: principalimportance: should knowfreq 35%

basics

~20 s

JEP 491 (JDK 24) reworked the runtime so a virtual thread can unmount even while holding a synchronized monitor, so synchronized no longer pins. Native/JNI blocking can still pin, and you must still watch carrier-pool sizing and thread-locals at scale.

open as a page