Skip to content

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)”

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.

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.

// Create and start a single virtual thread
Thread vt = Thread.ofVirtual().name("worker-1").start(() -> {
System.out.println("Running on virtual thread: " + Thread.currentThread());
});
vt.join();
// Create a virtual thread without starting it
Thread 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.

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:

  1. Mounting: The virtual thread is mounted onto a carrier thread. Its stack frames are copied onto the carrier’s stack.
  2. 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.
  3. Unmounting: The carrier thread is now free to execute another virtual thread.
  4. 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.

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

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 CPU
try (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
});
});
}

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/O
public 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.

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 running
ExecutorService 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 leaks
List<Order> orders = ordersFuture.get();

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
}

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 wins
public 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
}
}

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());
}
}

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)”

ThreadLocal provides per-thread state, but it has several problems in a virtual thread world:

  1. Memory consumption: Each virtual thread gets its own copy. With millions of virtual threads, this is unsustainable.
  2. Inheritance: ThreadLocal values are inherited by child threads, which is often undesirable.
  3. Mutation: ThreadLocal values are mutable, making it easy to introduce subtle bugs.
  4. Lifetime: ThreadLocal values live as long as the thread. Virtual threads in a per-task executor are short-lived, so cleanup is frequent but not automatic.

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
}
FeatureThreadLocalScopedValue
MutabilityMutableImmutable (bound once per scope)
LifetimeThread lifetimeScope lifetime
Memory footprintPer-thread copyShared reference
InheritanceInherited by child threadsInherited by child threads
RebindingCan rebind per callCannot rebind within active scope
Virtual threadExpensive (millions of copies)Efficient (single shared reference)

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.

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);
});
});
});
}
FeatureJava Virtual ThreadsGo Goroutines
Stack modelHeap-allocated continuationsHeap-allocated growable stacks
SchedulingFIFO (JDK 21+)Work-stealing
Pinning riskYes (synchronized blocks)No (no equivalent construct)
M:N schedulingYesYes
Typical concurrencyMillionsHundreds of thousands
Blocking I/OTransparent unmountTransparent 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+.

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 types
public 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 threads
public 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.

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.

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.

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 threads
var pool = Executors.newFixedThreadPool(200, Thread.ofVirtual().factory());
// Right: one virtual thread per task
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
executor.submit(task);
}

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 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
}

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

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.

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.

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.

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);
}
}

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();
}
}
}

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:

Terminal window
## Dump all threads including virtual threads
jcmd <pid> Thread.dump_to_file -all threads.txt

Virtual threads appear with a VirtualThread prefix. Carrier threads are the ForkJoinPool worker Threads that actually run on OS threads.

The JDK includes a mechanism to detect pinning. Enable it with:

Terminal window
-Djdk.tracePinnedThreads=short # Print when pinning occurs
-Djdk.tracePinnedThreads=long # Print with stack trace

When 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.

Terminal window
## Get current virtual thread count
jcmd <pid> VM.native_memory summary
# Or programmatically
ManagementFactory.getThreadMXBean().getThreadCount();
// With -Djdk.management.monitor=1, this includes virtual threads

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 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());
}

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():

// Before
private final ExecutorService executor = Executors.newFixedThreadPool(200);
// After
private final ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();

Step 2: Audit all synchronized blocks that contain blocking I/O. Replace with ReentrantLock:

// Before
synchronized (dataSource) {
return dataSource.getConnection().prepareStatement(sql).executeQuery();
}
// After
dataSourceLock.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 thread
public CompletableFuture<Response> handle(Request req) {
return authenticate(req)
.thenCompose(user -> fetchOrders(user))
.thenApply(orders -> buildResponse(orders));
}
// After: simple synchronous code on virtual thread
public 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 cancellation
public 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 cancellation
public 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());
}
}

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 demonstrating the application of key concepts are covered in the detailed sub-pages linked above.