Virtual Threads and Structured Concurrency
Project Loom and Virtual Threads (JEP 444, Java 21)
Section titled “Project Loom and Virtual Threads (JEP 444, Java 21)”The Problem with Platform Threads
Section titled “The Problem with Platform Threads”A platform thread in Java maps 1:1 to an operating system thread. OS threads are expensive Resources: each consumes a stack (default 1 MB on 64-bit JVMs), kernel metadata, and scheduling Overhead. A machine with 8 GB of RAM can run roughly 8,000 threads before exhausting memory on stack Space alone. In practice, the scheduler overhead degrades performance long before that.
Most server workloads are I/O-bound. A thread handling an HTTP request spends the vast majority of Its time waiting for database queries, network calls, or file I/O. During that wait, the thread’s Stack sits in memory doing nothing. The traditional solution — thread pools bounded to some Reasonable size (200-500 threads) — works but introduces complexity: every blocking operation must Be non-blocking or async, and async code is hard to write, hard to read, and hard to debug.
Virtual threads solve this by decoupling the Java-level thread from the OS thread.
What Is a Virtual Thread
Section titled “What Is a Virtual Thread”A virtual thread is a lightweight thread managed by the JVM rather than the operating system. It has Its own stack, its own thread-local variables, and its own interrupt state — but the stack is Allocated on the heap as a linked list of stack frames (called “continuations”), not as a contiguous Block of memory. When a virtual thread blocks on I/O, the JVM unmounts it from its carrier (the OS Thread) and mounts a different virtual thread. When the I/O completes, the JVM remounts the original Virtual thread, potentially on a different carrier.
This means you can have millions of virtual threads running on a small number of carrier threads ( matching the number of CPU cores). The memory footprint of a virtual thread that is Blocked on I/O is a few hundred bytes, not 1 MB.
Creating Virtual Threads
Section titled “Creating Virtual Threads”// Create and start a single virtual threadThread vt = Thread.ofVirtual().name("worker-1").start(() -> { System.out.println("Running on virtual thread: " + Thread.currentThread());});
vt.join();
// Create a virtual thread without starting itThread unstarted = Thread.ofVirtual().name("worker-2").unstarted(() -> { System.out.println("Deferred start");});unstarted.start();
// Using a virtual thread executor (the recommended pattern)try (var executor = Executors.newVirtualThreadPerTaskExecutor()) { IntStream.range(0, 10_000).forEach(i -> { executor.submit(() -> { Thread.sleep(Duration.ofSeconds(1)); return i; }); });}Executors.newVirtualThreadPerTaskExecutor() creates a new virtual thread for every submitted task. There is no pooling — virtual threads are cheap enough that pooling is unnecessary and Counterproductive. The executor implements AutoCloseable; closing it waits for all submitted tasks To complete.
How Virtual Threads Work Internally
Section titled “How Virtual Threads Work Internally”The JVM maintains a pool of carrier threads ( ForkJoinPool worker threads, by default equal to the Number of available processors). When a virtual thread performs a blocking operation:
- Mounting: The virtual thread is mounted onto a carrier thread. Its stack frames are copied onto the carrier’s stack.
- Blocking: When the virtual thread calls a blocking operation (
Thread.sleepSocket read, file I/O), the JVM does not park the carrier thread. Instead, it saves the virtual thread’s state as a heap-allocated continuation and unmounts the virtual thread. - Unmounting: The carrier thread is now free to execute another virtual thread.
- Remounting: When the blocking operation completes (I/O is ready, sleep expires), the JVM schedules the virtual thread to be mounted on any available carrier thread. It may not be the same carrier that originally ran it.
This mounting/unmounting is transparent to the application code. From the virtual thread’s Perspective, it called a blocking method and the method returned. The thread identity (Thread.currentThread()) remains consistent.
When Virtual Threads Help
Section titled “When Virtual Threads Help”Virtual threads excel in I/O-bound workloads where the ratio of wait time to compute time is high. Examples:
- HTTP servers handling thousands of concurrent connections
- Database connection pools with many slow queries
- Microservice architectures with inter-service calls
- Batch processing that makes many external API calls
When Virtual Threads Do NOT Help
Section titled “When Virtual Threads Do NOT Help”Virtual threads provide no benefit for CPU-bound work. If your code is computing SHA-256 hashes, Doing matrix multiplication, or sorting large arrays, the bottleneck is CPU, not thread count. Virtual threads cannot make a CPU do more work than it physically can. In fact, creating millions of Virtual threads that all compete for CPU time will degrade performance due to scheduling overhead.
// This gains nothing from virtual threads -- the bottleneck is CPUtry (var executor = Executors.newVirtualThreadPerTaskExecutor()) { IntStream.range(0, 100_000).forEach(i -> { executor.submit(() -> { return IntStream.range(0, 100_000) .map(j -> j * j) .sum(); // Pure computation, no I/O }); });}Pinning: The One Problem to Watch
Section titled “Pinning: The One Problem to Watch”A virtual thread can get “pinned” to its carrier thread when it performs a blocking operation inside A synchronized block or method. The JVM cannot unmount a virtual thread while it holds a monitor Lock because the lock state is tied to the carrier thread. If many virtual threads are pinned Simultaneously, you effectively revert to platform thread behavior.
// Pinning: virtual thread holds monitor lock during blocking I/Opublic class PinnedExample { private final Object lock = new Object();
public void fetchData() { synchronized (lock) { // This causes pinning httpClient.send(request); // Blocking I/O inside synchronized } }}Solution: Replace synchronized with ReentrantLock:
public class UnpinnedExample { private final ReentrantLock lock = new ReentrantLock();
public void fetchData() throws Exception { lock.lock(); try { httpClient.send(request); } finally { lock.unlock(); } }}ReentrantLock is j.u.c. Aware — the JVM can unmount a virtual thread that blocks while holding a ReentrantLock. Starting in JDK 24, synchronized pinning is being eliminated, but for JDK 21-23, You should use ReentrantLock in hot paths.
Thread-Local Variables and Virtual Threads
Section titled “Thread-Local Variables and Virtual Threads”ThreadLocal variables work with virtual threads, but using them heavily is a problem. Each virtual Thread gets its own copy of every ThreadLocal variable. If you have 1 million virtual threads and A ThreadLocal holding a 1 KB SimpleDateFormatThat is 1 GB of heap just for thread locals.
Use ScopedValue (discussed below) instead of ThreadLocal when working with virtual threads.
Structured Concurrency (JEP 453, Java 21)
Section titled “Structured Concurrency (JEP 453, Java 21)”The Problem with Unstructured Concurrency
Section titled “The Problem with Unstructured Concurrency”Traditional Java concurrency is unstructured: you create a thread, submit tasks to an executor, and Collect results with Future.get(). There is no relationship between the parent task and the child Tasks. If a child task fails, the parent must manually cancel the remaining children. If the parent Is interrupted, cleanup is manual. This leads to leaked threads, resource exhaustion, and subtle Bugs.
// Unstructured: if fetchUser() fails, fetchOrders() keeps runningExecutorService executor = Executors.newFixedThreadPool(10);Future<User> userFuture = executor.submit(() -> fetchUser(id));Future<List<Order>> ordersFuture = executor.submit(() -> fetchOrders(id));
User user = userFuture.get(); // If this throws, ordersFuture leaksList<Order> orders = ordersFuture.get();StructuredTaskScope
Section titled “StructuredTaskScope”StructuredTaskScope (JEP 453, incubating in Java 21) enforces a parent-child relationship between Tasks. All child tasks must complete (successfully or exceptionally) before the parent can proceed. If the scope is closed, all child tasks are cancelled. If the parent thread is interrupted, the Scope cancels all children.
import java.util.concurrent.StructuredTaskScope;
public record UserData(User user, List<Order> orders) { }
public UserData fetchUserData(String userId) throws Exception { try (var scope = new StructuredTaskScope.ShutdownOnFailure()) { StructuredTaskScope.Subtask<User> userTask = scope.fork(() -> fetchUser(userId)); StructuredTaskScope.Subtask<List<Order>> ordersTask = scope.fork(() -> fetchOrders(userId));
scope.join(); // Wait for all tasks scope.throwIfFailed(); // Propagate first failure
return new UserData(userTask.get(), ordersTask.get()); } // scope.close() cancels any remaining tasks and waits for them}Shutdown Policies
Section titled “Shutdown Policies”StructuredTaskScope provides two built-in shutdown policies:
ShutdownOnFailure: If any child task fails, cancel all remaining children. Useful when all results are needed.ShutdownOnSuccess: When the first child task succeeds, cancel all remaining children. Useful for “first to respond” patterns like querying multiple caches or endpoints.
// First successful result winspublic Config fetchConfig() throws Exception { try (var scope = new StructuredTaskScope.ShutdownOnSuccess<Config>()) { scope.fork(() -> fetchFromCache()); scope.fork(() -> fetchFromDatabase()); scope.fork(() -> fetchFromRemote());
scope.join(); return scope.result(); // Returns first successful result }}Cancellation and Deadlines
Section titled “Cancellation and Deadlines”Child tasks are cooperative — they must check for interruption. Standard blocking operations (Thread.sleepI/O, Future.get) respond to interruption automatically. For long-running Computations, periodically check Thread.interrupted():
public List<String> expensiveComputation() throws InterruptedException { List<String> results = new ArrayList<>(); for (int i = 0; i < 1_000_000; i++) { if (Thread.interrupted()) { throw new InterruptedException("Computation cancelled"); } results.add(processItem(i)); } return results;}Deadlines can be set on the scope to enforce time bounds:
public UserData fetchUserDataWithDeadline(String userId) throws Exception { try (var scope = new StructuredTaskScope.ShutdownOnFailure()) { scope.joinUntil(Instant.now().plusMillis(500));
StructuredTaskScope.Subtask<User> userTask = scope.fork(() -> fetchUser(userId)); StructuredTaskScope.Subtask<List<Order>> ordersTask = scope.fork(() -> fetchOrders(userId));
scope.joinUntil(Instant.now().plusMillis(500)); scope.throwIfFailed();
return new UserData(userTask.get(), ordersTask.get()); }}Virtual Threads + Structured Concurrency
Section titled “Virtual Threads + Structured Concurrency”The combination of virtual threads and structured concurrency is the intended programming model for Modern Java server applications. Virtual threads eliminate the resource cost of concurrent blocking Operations, and structured concurrency provides lifecycle management:
public class OrderService { private final HttpClient httpClient = HttpClient.newHttpClient();
public OrderSummary processOrder(OrderRequest request) throws Exception { try (var executor = Executors.newVirtualThreadPerTaskExecutor(); var scope = new StructuredTaskScope.ShutdownOnFailure()) {
StructuredTaskScope.Subtask<InventoryCheck> inventory = scope.fork(() -> checkInventory(request)); StructuredTaskScope.Subtask<PaymentResult> payment = scope.fork(() -> processPayment(request)); StructuredTaskScope.Subtask<ShippingQuote> shipping = scope.fork(() -> getShippingQuote(request));
scope.join(); scope.throwIfFailed();
return new OrderSummary( inventory.get(), payment.get(), shipping.get() ); } }}Scoped Values (JEP 446, Java 21 Preview / JEP 482, Java 24)
Section titled “Scoped Values (JEP 446, Java 21 Preview / JEP 482, Java 24)”The Problem with ThreadLocal
Section titled “The Problem with ThreadLocal”ThreadLocal provides per-thread state, but it has several problems in a virtual thread world:
- Memory consumption: Each virtual thread gets its own copy. With millions of virtual threads, this is unsustainable.
- Inheritance:
ThreadLocalvalues are inherited by child threads, which is often undesirable. - Mutation:
ThreadLocalvalues are mutable, making it easy to introduce subtle bugs. - Lifetime:
ThreadLocalvalues live as long as the thread. Virtual threads in a per-task executor are short-lived, so cleanup is frequent but not automatic.
ScopedValue Basics
Section titled “ScopedValue Basics”ScopedValue (JEP 446) provides immutable, dynamically-scoped values that are bound for a bounded Duration and automatically released when the scope exits:
import java.lang.ScopedValue;
private static final ScopedValue<User> CURRENT_USER = ScopedValue.newInstance();
public void handleRequest(Request request) throws Exception { User user = authenticate(request); ScopedValue.where(CURRENT_USER, user).run(() -> { processRequest(request); logAccess(); }); // CURRENT_USER is no longer bound here}
private void processRequest(Request request) { User user = CURRENT_USER.get(); // No null checks, no casting // user is guaranteed to be non-null within the scope}Scoped Values vs ThreadLocal
Section titled “Scoped Values vs ThreadLocal”| Feature | ThreadLocal | ScopedValue |
|---|---|---|
| Mutability | Mutable | Immutable (bound once per scope) |
| Lifetime | Thread lifetime | Scope lifetime |
| Memory footprint | Per-thread copy | Shared reference |
| Inheritance | Inherited by child threads | Inherited by child threads |
| Rebinding | Can rebind per call | Cannot rebind within active scope |
| Virtual thread | Expensive (millions of copies) | Efficient (single shared reference) |
Binding and Rebinding
Section titled “Binding and Rebinding”Scoped values support rebinding in nested scopes:
ScopedValue.where(CURRENT_USER, adminUser).run(() -> { processAsAdmin(); // CURRENT_USER.get() returns adminUser
ScopedValue.where(CURRENT_USER, regularUser).run(() -> { processAsRegular(); // CURRENT_USER.get() returns regularUser });
processAsAdmin(); // CURRENT_USER.get() returns adminUser again});Each where call creates a new binding that shadows the outer binding. When the inner scope exits, The outer binding is restored. This is stack-based scoping, not heap-based per-thread storage.
Scoped Values with Virtual Threads
Section titled “Scoped Values with Virtual Threads”The key advantage: a ScopedValue is stored once per carrier thread, not once per virtual thread. When a virtual thread is unmounted and remounted on a different carrier, the scoped value is still Accessible because it is stored in the virtual thread’s scope, not in the carrier’s ThreadLocal:
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) { ScopedValue.where(CURRENT_USER, user).run(() -> { // All virtual threads forked inside this scope see CURRENT_USER IntStream.range(0, 1000).forEach(i -> { executor.submit(() -> { // This virtual thread can be mounted on any carrier // but CURRENT_USER.get() still returns the correct user doWork(i); }); }); });}Comparison with Other Languages
Section titled “Comparison with Other Languages”Virtual Threads vs Go Goroutines
Section titled “Virtual Threads vs Go Goroutines”| Feature | Java Virtual Threads | Go Goroutines |
|---|---|---|
| Stack model | Heap-allocated continuations | Heap-allocated growable stacks |
| Scheduling | FIFO (JDK 21+) | Work-stealing |
| Pinning risk | Yes (synchronized blocks) | No (no equivalent construct) |
| M:N scheduling | Yes | Yes |
| Typical concurrency | Millions | Hundreds of thousands |
| Blocking I/O | Transparent unmount | Transparent unmount |
Go goroutines are more mature (available since Go 1.0 in 2012) and have no pinning issue because Go Does not have monitor-based locking. Java virtual threads are catching up, and the pinning issue is Being addressed in JDK 24+.
Virtual Threads vs C# async/await
Section titled “Virtual Threads vs C# async/await”C# uses async/await with state machines generated by the compiler. Java virtual threads are Simpler: you write synchronous code and the JVM handles the asynchrony. There is no async keyword, No Task type, no state machine, no ConfigureAwait.
// C#: async/await with explicit Task typespublic async Task<User> GetUserAsync(string id) { var response = await httpClient.GetAsync($"/users/{id}"); var content = await response.Content.ReadAsStringAsync(); return JsonSerializer.Deserialize<User>(content);}// Java: synchronous code with virtual threadspublic User getUser(String id) { var response = httpClient.send(HttpRequest.newBuilder() .uri(URI.create("/users/" + id)).build()); var content = response.body(); return JsonReader.fromJson(content);}The Java approach is simpler to understand and easier to retrofit in existing codebases. The C# Approach is more explicit about where asynchrony occurs, which some developers prefer for Performance reasoning.
Virtual Threads vs Kotlin Coroutines
Section titled “Virtual Threads vs Kotlin Coroutines”Kotlin coroutines are compiler-transformed suspending functions, similar to C# async/await. They use suspend keyword and Dispatchers for thread context. Virtual threads are a runtime feature with No language changes required.
Intuition
Section titled “Intuition”Lightweight concurrency: Virtual threads are like hiring temporary workers — they’re cheap to create and manage, letting you handle thousands of tasks without the overhead of traditional threads.
Why it matters: Virtual threads make concurrent programming accessible — you can handle many more connections without complex thread pool management.
The key insight: Structured concurrency ensures that child tasks complete before their parent — this prevents resource leaks and makes code easier to reason about.
Common Pitfalls
Section titled “Common Pitfalls”Do Not Pool Virtual Threads
Section titled “Do Not Pool Virtual Threads”Virtual threads are designed to be created per-task. Pooling them defeats the purpose — the whole Point is that creation is nearly free and the JVM manages the scheduling:
// Wrong: pooling virtual threadsvar pool = Executors.newFixedThreadPool(200, Thread.ofVirtual().factory());
// Right: one virtual thread per tasktry (var executor = Executors.newVirtualThreadPerTaskExecutor()) { executor.submit(task);}Synchronized Still Pins (JDK 21-23)
Section titled “Synchronized Still Pins (JDK 21-23)”Any blocking operation inside a synchronized block pins the virtual thread to its carrier. Audit Your codebase for synchronized blocks that contain I/O operations and replace with ReentrantLock.
ThreadLocal in Virtual Thread Per-Task Executors
Section titled “ThreadLocal in Virtual Thread Per-Task Executors”In a newVirtualThreadPerTaskExecutorEach task runs on a new virtual thread. If you set a ThreadLocal in one task, it is not visible in another. If you set it in a parent and fork child Tasks, the children inherit it, but the memory overhead is proportional to the number of virtual Threads. Prefer ScopedValue.
Structured Concurrency Is Not Auto-Closeable on Errors
Section titled “Structured Concurrency Is Not Auto-Closeable on Errors”StructuredTaskScope implements AutoCloseableBut closing the scope does not automatically throw Exceptions. You must call throwIfFailed() explicitly after join():
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) { scope.fork(() -> mayFail()); scope.join(); scope.throwIfFailed(); // YOU MUST CALL THIS // process results}Forgetting join()
Section titled “Forgetting join()”Forgetting to call join() means the scope’s close() will wait for children, but you will not Have access to their results:
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) { var task = scope.fork(() -> fetchUser()); // BUG: no join() -- close() waits but you never check the result}Performance Characteristics
Section titled “Performance Characteristics”Memory
Section titled “Memory”A blocked virtual thread consumes roughly 200-500 bytes of heap. A blocked platform thread consumes 1 MB of stack (configurable via -Xss). At 100,000 concurrent blocked requests:
- Platform threads: ~100 GB of stack memory
- Virtual threads: ~20-50 MB of heap
Throughput
Section titled “Throughput”For I/O-bound workloads, virtual threads match or exceed the throughput of reactive Frameworks (Netty, WebFlux) while using dramatically simpler code. The throughput improvement comes From eliminating context-switch overhead: the JVM does not need to make an expensive pthread_create or kernel context switch for each new concurrent operation.
CPU-Bound Work
Section titled “CPU-Bound Work”Virtual threads add scheduling overhead (mounting, unmounting, continuation management). For pure CPU-bound work, platform threads on a bounded thread pool are marginally faster. The difference is small (single-digit percentage) but measurable.
StructuredTaskScope: Advanced Patterns
Section titled “StructuredTaskScope: Advanced Patterns”Combining Results from Multiple Tasks
Section titled “Combining Results from Multiple Tasks”A common pattern is collecting results from parallel tasks and combining them. StructuredTaskScope Makes this safe:
public record EnrichedOrder(Order order, User user, List<Inventory> inventory) { }
public EnrichedOrder enrichOrder(String orderId) throws Exception { try (var scope = new StructuredTaskScope.ShutdownOnFailure()) { StructuredTaskScope.Subtask<Order> orderTask = scope.fork(() -> orderClient.getOrder(orderId)); StructuredTaskScope.Subtask<User> userTask = scope.fork(() -> userClient.getUser("current")); StructuredTaskScope.Subtask<List<Inventory>> invTask = scope.fork(() -> inventoryClient.check(orderId));
scope.join(); scope.throwIfFailed();
return new EnrichedOrder(orderTask.get(), userTask.get(), invTask.get()); }}If any of the three calls fails, the remaining calls are cancelled immediately. No leaked threads, No dangling futures.
Handling Partial Failures
Section titled “Handling Partial Failures”Sometimes you want to collect results from all tasks even if some fail. Use ShutdownOnFailure but Check individual task states:
public record PartialResults( Optional<User> user, Optional<List<Order>> orders, List<Throwable> errors) { }
public PartialResults fetchWithFailures(String userId) throws Exception { try (var scope = new StructuredTaskScope.ShutdownOnFailure()) { var userTask = scope.fork(() -> fetchUser(userId)); var ordersTask = scope.fork(() -> fetchOrders(userId));
scope.join(); // Do NOT call throwIfFailed() -- we want partial results
List<Throwable> errors = new ArrayList<>(); Optional<User> user = Optional.empty(); Optional<List<Order>> orders = Optional.empty();
if (userTask.state() == StructuredTaskScope.Subtask.State.SUCCESS) { user = Optional.of(userTask.get()); } else { errors.add(userTask.exception()); }
if (ordersTask.state() == StructuredTaskScope.Subtask.State.SUCCESS) { orders = Optional.of(ordersTask.get()); } else { errors.add(ordersTask.exception()); }
return new PartialResults(user, orders, errors); }}Custom StructuredTaskScope Policies
Section titled “Custom StructuredTaskScope Policies”You can extend StructuredTaskScope to implement custom shutdown policies:
public class RaceScope<T> extends StructuredTaskScope<T> { private volatile T result; private final ReentrantLock lock = new ReentrantLock(); private final Condition done = lock.newCondition();
@Override protected void handleComplete(Subtask<? extends T> subtask) { if (subtask.state() == Subtask.State.SUCCESS) { lock.lock(); try { if (result == null) { result = subtask.get(); done.signalAll(); shutdown(); } } finally { lock.unlock(); } } }
public T result() throws InterruptedException { lock.lock(); try { while (result == null && !isShutdown()) { done.await(); } return result; } finally { lock.unlock(); } }}Thread Dumps and Virtual Threads
Section titled “Thread Dumps and Virtual Threads”Traditional Thread Dumps
Section titled “Traditional Thread Dumps”A traditional thread dump (jstack``kill -3Or Thread.getAllStackTraces()) shows platform Threads. With virtual threads, a thread dump also shows virtual threads but in a different format:
## Dump all threads including virtual threadsjcmd <pid> Thread.dump_to_file -all threads.txtVirtual threads appear with a VirtualThread prefix. Carrier threads are the ForkJoinPool worker Threads that actually run on OS threads.
Diagnosing Pinning
Section titled “Diagnosing Pinning”The JDK includes a mechanism to detect pinning. Enable it with:
-Djdk.tracePinnedThreads=short # Print when pinning occurs-Djdk.tracePinnedThreads=long # Print with stack traceWhen a virtual thread pins its carrier, you will see output like:
Thread[#42,ForkJoinPool-1-worker-1] pinned at: java.lang.Object.wait(Object.java) com.example.Handler.process(Handler.java:45)This tells you exactly where to add ReentrantLock or restructure the synchronized block.
Monitoring Virtual Thread Count
Section titled “Monitoring Virtual Thread Count”## Get current virtual thread countjcmd <pid> VM.native_memory summary
# Or programmaticallyManagementFactory.getThreadMXBean().getThreadCount();// With -Djdk.management.monitor=1, this includes virtual threadsScoped Values: Advanced Usage
Section titled “Scoped Values: Advanced Usage”Binding Multiple Scoped Values
Section titled “Binding Multiple Scoped Values”Multiple scoped values can be bound simultaneously:
private static final ScopedValue<User> CURRENT_USER = ScopedValue.newInstance();private static final ScopedValue<RequestContext> REQUEST_CTX = ScopedValue.newInstance();
public void handle(Request req) throws Exception { User user = authenticate(req); RequestContext ctx = buildContext(req); ScopedValue.where(CURRENT_USER, user) .where(REQUEST_CTX, ctx) .run(() -> processRequest(req));}Scoped Values in Structured Concurrency
Section titled “Scoped Values in Structured Concurrency”Scoped values propagate to child threads forked inside the binding scope. This makes them ideal for Request-scoped context in server applications:
public void handleRequest(HttpExchange exchange) throws Exception { RequestContext ctx = new RequestContext(exchange); ScopedValue.where(REQUEST_CONTEXT, ctx).run(() -> { try (var scope = new StructuredTaskScope.ShutdownOnFailure()) { scope.fork(() -> queryDatabase()); scope.fork(() -> callExternalService()); scope.join(); scope.throwIfFailed(); } catch (Exception e) { throw new RuntimeException(e); } });}
private String queryDatabase() { // REQUEST_CONTEXT.get() returns the correct context // even though this runs on a different virtual thread return db.query(REQUEST_CONTEXT.get().getSql());}Real-World Migration Guide
Section titled “Real-World Migration Guide”Migrating from Thread Pools to Virtual Threads
Section titled “Migrating from Thread Pools to Virtual Threads”Step 1: Replace Executors.newFixedThreadPool(n) with Executors.newVirtualThreadPerTaskExecutor():
// Beforeprivate final ExecutorService executor = Executors.newFixedThreadPool(200);
// Afterprivate final ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();Step 2: Audit all synchronized blocks that contain blocking I/O. Replace with ReentrantLock:
// Beforesynchronized (dataSource) { return dataSource.getConnection().prepareStatement(sql).executeQuery();}
// AfterdataSourceLock.lock();try { return dataSource.getConnection().prepareStatement(sql).executeQuery();} finally { dataSourceLock.unlock();}Step 3: Replace ThreadLocal with ScopedValue for request-scoped data (logging context, auth Tokens, tracing IDs).
Step 4: Remove all CompletableFuture chaining that was introduced purely to avoid blocking Threads. Synchronous code is now the correct approach:
// Before: async chain to avoid blocking a platform threadpublic CompletableFuture<Response> handle(Request req) { return authenticate(req) .thenCompose(user -> fetchOrders(user)) .thenApply(orders -> buildResponse(orders));}
// After: simple synchronous code on virtual threadpublic Response handle(Request req) { User user = authenticate(req); List<Order> orders = fetchOrders(user); return buildResponse(orders);}Migrating from CompletableFuture to StructuredTaskScope
Section titled “Migrating from CompletableFuture to StructuredTaskScope”CompletableFuture is not going away — it remains the right tool for fire-and-forget tasks and Event-driven composition. But for fan-out/fan-in patterns where you need all results (or want to Cancel on failure), StructuredTaskScope is strictly better:
// Before: CompletableFuture with manual cancellationpublic UserData fetchUserData(String userId) { CompletableFuture<User> userFuture = CompletableFuture.supplyAsync( () -> fetchUser(userId), executor); CompletableFuture<List<Order>> ordersFuture = CompletableFuture.supplyAsync( () -> fetchOrders(userId), executor);
userFuture.exceptionally(ex -> { ordersFuture.cancel(true); // Best-effort cancellation return null; });
return new UserData(userFuture.join(), ordersFuture.join());}
// After: StructuredTaskScope with guaranteed cancellationpublic UserData fetchUserData(String userId) throws Exception { try (var scope = new StructuredTaskScope.ShutdownOnFailure()) { var userTask = scope.fork(() -> fetchUser(userId)); var ordersTask = scope.fork(() -> fetchOrders(userId));
scope.join(); scope.throwIfFailed();
return new UserData(userTask.get(), ordersTask.get()); }}See Also
Section titled “See Also”- Concurrency — traditional concurrency primitives,
ReentrantLock``CompletableFuture - Collections Framework — thread-safe collections for concurrent access
- Records, Sealed Classes, and Pattern Matching — pattern matching used in structured concurrency
- Class Loading and Memory Model — how the JVM manages threads and memory
Summary
Section titled “Summary”This topic covers the core concepts of virtual threads and structured concurrency, including underlying theory, practical implementation, and key applications.
Key concepts include:
- OOP principles (encapsulation, inheritance, polymorphism)
- collections framework
- streams and lambda expressions
- exception handling
- the JVM and garbage collection
Understanding these concepts thoroughly is essential for both examinations and practical programming, and requires both theoretical knowledge and hands-on practice.
Worked Examples
Section titled “Worked Examples”Worked examples demonstrating the application of key concepts are covered in the detailed sub-pages linked above.