Table of Contents
- 2.1 Introduction and course agenda
- 2.2 Prerequisites
- 2.3 Set the throughput of an application
- 2.4 Set synchronous and asynchronous execution
- 2.5 Define concurrent execution
- 2.6 Blocking code and why to avoid it
- 2.7 Analyze the performance of a web request
- 2.8 Use concurrent programming to increase throughput
- 2.9 Module 2 Summary
- 3.1 Introduction and module agenda
- 3.2 Handle web requests with responsive programming
- 3.3 Analyze responsive model costs
- 3.4 Build a new thread model
- 3.5 Demo: Create your first Virtual Threads
- 3.6 Demo: Launch 1 million Virtual Threads
- 3.7 Module 3 Summary
- 4.1 Introduction and module agenda
- 4.2 Les Carrier Threads
- 4.3 How Virtual Threads are executed by Carrier Threads
- 4.4 Demo: What happens when a Virtual Thread blocks
- 4.5 Demo: Analyze the operation of Continuations
- 4.6 Continuations prevent blocking of Carrier Threads
- 4.7 The pinning — Virtual Thread pinned to its Carrier Thread
- 4.8 Demo: Solve pinning with ReentrantLock
- 4.9 When to refactor to ReentrantLock?
- 4.10 Comparison: Reactive Programming vs Virtual Threads
- 4.11 Module 4 Summary
- 5.1 Introduction and module agenda
- 5.2 Eliminate Loose Threads with Structured Concurrency
- 5.3 Demo: Premier StructuredTaskScope — Weather Forecast
- 5.4 Demo: ShutdownOnSuccess for weather
- 5.5 Demo: StructuredTaskScope for booking simple flights
- 5.6 Demo: Create a FlightScope by extending StructuredTaskScope
- 5.7 Demo: ShutdownOnFailure for multi-segment flights
- 5.8 Demo: Single and multi-segment flight queries with best price
- 5.9 Demo: Add weather forecast to travel system
- 5.10 Module 5 Summary
- 6.1 Introduction and module agenda
- 6.2 Why ThreadLocal variables are problematic with Virtual Threads
- 6.3 Demo: Declare ScopedValues and bind them
- 6.4 Demo: License check with ScopedValue
- 6.5 Module 6 Summary
1. Course Overview
This course, presented by Jose (Java Champion and JavaOne Rockstar), covers concurrent programming in Java with Virtual Threads in two and a half hours. It shows how to write reactive applications with Virtual Threads and a simple programming model.
Course outline:
- Why reactive programming incurs high maintenance costs, and how a lighter threading model solves this problem.
- What Virtual Threads are, how they work internally with Continuations, and how to use them.
- Build an application with the Structured Concurrency API based on Virtual Threads —
ShutdownOnSuccess,ShutdownOnFailurestrategies, and custom strategy. - Use ScopedValues instead of ThreadLocal variables to share data securely.
2. Improve reactive programming with Virtual Threads
Total module duration: 25m 53s
2.1 Introduction and course agenda
This course focuses on increasing the throughput of an application. Until now, the main solution was reactive programming, which has been around for many years. This course presents a new approach based on the technology introduced in Java 21: Virtual Threads.
Virtual Threads are adopted by all major application servers and frameworks:
- Tomcat, JEE, Spring Boot, Quarkus, Micronaut, Helidon (Helidon v4 rebuilt its entire kernel on Virtual Threads).
Course Agenda:
- Define throughput and blocking code.
- Explain how reactive programming solves the problem.
- Introduce Virtual Threads and their internal workings.
- Structured Concurrency (new concurrent programming paradigm).
- ThreadLocal variables and the ScopedValues alternative.
2.2 Prerequisites
To take this course, you must master:
- The Java language: Lambda expressions, Collections, Streams, basic Java APIs.
- Concurrent programming: Threads, Future, ExecutorService.
- The notions of race conditions, synchronization, locking.
Important: All code uses APIs added in Java 21. Any earlier versions will not work.
The IDE used is IntelliJ IDEA (Community or Ultimate). Any IDE can be used (Eclipse, NetBeans, VS Code).
2.3 Define the throughput of an application
The throughput of an application is the number of operations it can process during a given period:
| Application Type | Throughput measurement |
|---|---|
| Web server | Number of HTTP requests per second |
| Database | Number of transactions per second |
| Message broker | Number of messages per second |
The objective is to obtain the best possible throughput for a given hardware (CPU, memory). Reactive programming is, so far, the best approach to achieve this.
2.4 Define synchronous and asynchronous execution
Synchronous: the code is executed immediately when the program reads it.
// Exemple synchrone — exécuté immédiatement
double result = Math.sqrt(2);
Asynchronous: the code will be executed in the future. It is passed as an argument to a method which will invoke it itself, not your code.
// Exemple asynchrone — le Consumer est appelé par forEach, pas par votre code
List<String> list = List.of("a", "b", "c");
list.forEach(s -> System.out.println(s)); // peut être appelé 0, 1 ou plusieurs fois
Key point: Asynchronous programming is not related to concurrent programming. A
forEachexecutes in the main thread, no thread is created.
Asynchronous code characteristics:
- May be run multiple times, or not at all.
- The number of invocations is often only known at runtime.
- Does not necessarily imply multithreading.
2.5 Set concurrent execution
Confusion between asynchronous and concurrent is common because they are often used together. The key difference: concurrent means the code is running in another thread.
Two patterns to execute code in another thread:
// Pattern 1 : Créer un thread manuellement
Runnable task = () -> System.out.println("In another thread");
Thread thread = new Thread(task);
thread.start();
// Pattern 2 : Utiliser un ExecutorService (préférable)
ExecutorService executor = Executors.newFixedThreadPool(10);
Future<String> future = executor.submit(() -> {
// Requête sur un serveur web
return fetchFromServer();
});
// L'ExecutorService retourne un Future immédiatement
// La tâche sera exécutée dans le futur
String result = future.get(); // Récupère le résultat (ou l'exception)
Important rules:
- Concurrent code is always asynchronous (executed in the future).
- Asynchronous code is not always concurrent (can run in the same thread).
2.6 Blocking code and why to avoid it
Non-blocking code: code that executes on a CPU core and actively uses it (instructions executing, CPU busy).
Blocking code: code that executes on a CPU core but does nothing (the core does not execute any instructions, waste of resources).
Two situations that cause the crash:
- An I/O request (typically network) is launched, and the thread waits for the response.
- The thread waits at the gate of a
synchronizedblock occupied by another thread.
What happens when blocking:
- The thread scheduler detects that the thread is not doing anything.
- It removes the thread from its CPU core (context switch).
- Later, when the data is available or the lock is released, the thread scheduler replaces the thread on a CPU.
Context switching: very costly operation for the application and other processors. It is absolutely necessary to avoid blocking threads.
Reactive frameworks were developed precisely to allow business code to be written in a non-blocking manner.
2.7 Analyze the performance of a web request
Example of request/response cycle with the JDK HttpClient API:
// Préparer la requête (nanoseconds, CPU actif)
URI uri = URI.create("https://example.com/api");
HttpClient client = HttpClient.newHttpClient();
HttpRequest request = HttpRequest.newBuilder(uri).build();
// Envoyer la requête (BLOCAGE : centaines de millisecondes, CPU à 0%)
HttpResponse<String> response = client.send(request, BodyHandlers.ofString());
// Analyser la réponse (nanoseconds, CPU actif)
int status = response.statusCode();
String body = response.body();
// Unmarshalling JSON → objets Java
Time analysis:
| Step | Time | CPU Usage |
|---|---|---|
| Creation of request objects | ~100 nanoseconds | ~100% |
| Waiting for network response | ~100-300 milliseconds | ~0% |
| Analysis of the response | ~100 nanoseconds | ~100% |
Ratio: $\frac{1\text{ ms}}{0.0001\text{ ms}} = 10^4$ to $10^6$ between waiting time and calculation time.
Average CPU usage for this cycle: approximately 0.0001% → huge waste.
Intuitive solution: the one-request-per-thread model
When a thread is waiting, the scheduler removes it from the CPU and installs another thread. So multiple threads can keep the CPU busy. It would take 1 million threads to reach 100% CPU utilization (ratio of 1 million between waiting time and calculation time).
2.8 Using concurrent programming to increase throughput
Is the one-request-per-thread model viable?
A platform thread (Java thread wrapping an OS thread / kernel thread) is an expensive resource:
| Resource | Cost |
|---|---|
| Memory per thread | ~1 Megabyte (or more) |
| Creation time | ~1 millisecond |
| Context switch | ~a few microseconds |
1:1 relationship between Java thread and platform thread (OS thread).
If we wanted 1 million platform threads:
- Memory: ~1 terabyte → way too much
- Creation time: ~1000 seconds (16+ minutes) → unacceptable
- Context switches: ~1 minute and a half overhead
The one-request-per-thread model has been abandoned a long time ago. This is why we pool the threads: we create them when the application starts and we reuse them.
Both solutions to the problem:
- Reactive programming: make each thread handle multiple requests simultaneously (sometimes thousands).
- Virtual Threads: create a new thread model ~1000x lighter than platform threads, allowing millions of threads.
| Approach | Model | Advantage | Disadvantage |
|---|---|---|---|
| Responsive Programming | N requests / thread | Maximum performance | Complex code, difficult to maintain |
| VirtualThreads | 1 request / thread (virtual) | Simple and imperative code | New model to understand |
2.9 Module 2 Summary
- The factor of 1 million between waiting time and calculation time for a network request.
- Java platform threads wrap OS threads — expensive resource.
- The one-request-per-thread model is not viable with platform threads.
- Two solutions: reactive programming (N requests/thread) or Virtual Threads (1 request/light thread).
3. Introduction to Virtual Threads
Total module duration: 19m 25s
3.1 Module Introduction and Agenda
This module answers the question: why do we need Virtual Threads? Reactive programming already exists and works. The answer lies in its very high maintenance cost.
Module plan:
- Reactive programming and its costs.
- Why Virtual Threads are an alternative.
- First Virtual Threads in action.
3.2 Managing web requests with reactive programming
Reactive programming divides the request/response cycle into small non-blocking steps, each modeled by a lambda expression.
Example with the CompletableFuture API:
// Étape 1 : Supplier — crée la requête (rien n'est exécuté ici)
Supplier<HttpRequest> requestSupplier = () -> {
URI uri = URI.create("https://example.com/api");
return HttpRequest.newBuilder(uri).build();
};
// Étape 2 : Function — envoie la requête (partie critique, potentiellement bloquante)
// C'est le framework qui exécute cette lambda, pas votre code
Function<HttpRequest, HttpResponse<String>> sendRequest = request -> {
try {
return HttpClient.newHttpClient().send(request, BodyHandlers.ofString());
} catch (Exception e) { throw new RuntimeException(e); }
};
// Étape 3 : Gestion des exceptions
Function<Throwable, HttpResponse<String>> errorHandler = ex -> {
System.err.println("Error: " + ex.getMessage());
return null;
};
// Étape 4 : Analyse du résultat
Function<HttpResponse<String>, MyObject> responseProcessor = response -> {
if (response.statusCode() == 200) {
return parseJson(response.body()); // Unmarshalling JSON
}
return null;
};
// Assemblage avec CompletableFuture
CompletableFuture.supplyAsync(requestSupplier)
.thenApply(sendRequest)
.exceptionally(errorHandler)
.thenApply(responseProcessor);
Key principles:
- No blocking code in lambdas — this is the responsibility of the developer.
- The framework detects network requests and records a callback which will be notified when the data arrives.
- The thread that executes lambdas is never blocked — it is constantly fed with new lambdas.
- Reactive frameworks only need a few threads, sometimes just one.
3.3 Analyze Responsive Model Costs
Despite its excellent performance, reactive programming has a very high maintenance cost:
| Problem | Description |
|---|---|
| Difficult programming model | Complex code to write, easy bugs |
| Blocking code in lambdas | Forgetting a blocking call = catastrophic performance bug |
| Difficult reading | Business code buried in the technical code of the framework |
| Implicit types | You have to look at the following lambda to know the return type |
| Hard Unit Testing | Code scattered in lambdas, executed in other threads |
| Complex error handling | Timeouts especially — treat with great care |
| Profiling almost impossible | What we profile is the framework, not the application |
| Very difficult debug | Breakpoints in each lambda, call stack points to the framework |
Conclusion: Reactive programming is a powerful technology but with a very high maintenance cost. This is exactly why Virtual Threads were developed: same or better performance, with a simple programming model.
3.4 Build a new thread model
With Virtual Threads, the one-request-per-thread model becomes viable again, without having to write non-blocking code.
Virtual Thread Mathematics:
- Machine capable of managing 1000 platform threads.
- Need 1 million concurrent requests.
- A Virtual Thread must be at least 1000x lighter than a platform thread.
Objective:
- Memory: order of kilobytes (instead of megabytes).
- Creation time: order of microseconds (instead of milliseconds).
If the objective is not achieved (eg: consumes another 500 KB or is created in 500 µs), the Virtual Thread would be useless because several requests would still have to be sent per thread.
This is exactly what Java 21 Virtual Threads do.
3.5 Demo: Create your first Virtual Threads
File: 03/demos/03_Introducing-virtual-threads/src/main/java/org/paumard/virtualthreads/A_CreatingVirtualThreads.java
package org.paumard.virtualthreads;
public class A_CreatingVirtualThreads {
public static void main(String[] args) throws InterruptedException {
Runnable task = () -> {
System.out.println("I am running in the thread " +
Thread.currentThread().getName());
System.out.println("I am running in daemon thread? " +
Thread.currentThread().isDaemon());
};
// Pattern 1 : Ancienne API — Platform Thread
Thread thread1 = new Thread(task);
thread1.start();
thread1.join();
// Pattern 2 : Factory method ofPlatform() — Platform Thread avec builder
Thread thread2 = Thread.ofPlatform()
.daemon()
.name("Platform thread 2")
.unstarted(task);
thread2.start();
thread2.join();
// Pattern 3 : Factory method ofVirtual() — Virtual Thread
// Note : pas de méthode daemon() — un Virtual Thread est TOUJOURS un daemon thread
Thread thread3 = Thread.ofVirtual()
.name("Virtual thread 3")
.unstarted(task);
thread3.start();
thread3.join();
// Pattern 4 : startVirtualThread() — raccourci pour créer et démarrer
Thread thread4 = Thread.startVirtualThread(task);
thread4.join();
}
}
Key points:
Thread.ofVirtual()returns a builder to create a Virtual Thread.- A Virtual Thread is always a daemon thread — you cannot change this behavior.
- The
Thread.startVirtualThread(task)method is shorthand forThread.ofVirtual().unstarted(task).start(). - A Virtual Thread extends the
Threadclass — everything that is true for a Thread is also true for a Virtual Thread (race conditions, synchronization, deadlocking, etc.).
Maven configuration (pom.xml):
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>org.paumard</groupId>
<artifactId>03_Introducing-virtual-threads</artifactId>
<version>1.0-SNAPSHOT</version>
<properties>
<maven.compiler.source>21</maven.compiler.source>
<maven.compiler.target>21</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
</project>
File: 03/demos/03_Introducing-virtual-threads/src/main/java/org/paumard/virtualthreads/B_CreatingExecutorService.java
package org.paumard.virtualthreads;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.Executors;
public class B_CreatingExecutorService {
public static void main(String[] args) {
var set = ConcurrentHashMap.<String>newKeySet();
Runnable task = () -> set.add(Thread.currentThread().toString());
int N_TASKS = 2000;
// newVirtualThreadPerTaskExecutor : crée un Virtual Thread par tâche (pas de pooling)
try (var es1 = Executors.newVirtualThreadPerTaskExecutor()) {
for (int index = 0; index < N_TASKS; index++) {
es1.submit(task);
}
}
System.out.println("# threads used = " + set.size());
// Résultat : 2000 threads créés (un par tâche)
}
}
Comparison with a FixedThreadPool:
// FixedThreadPool : 10 threads, même avec 200 tâches, seulement 10 threads utilisés
var es2 = Executors.newFixedThreadPool(10);
// → set.size() == 10 pour 200 tâches
// newVirtualThreadPerTaskExecutor : 2000 tâches → 2000 threads
var es3 = Executors.newVirtualThreadPerTaskExecutor();
// → set.size() == 2000 pour 2000 tâches
Important:
newVirtualThreadPerTaskExecutordoes not pool threads. It creates Virtual Threads on demand and lets them complete. This is possible because Virtual Threads are cheap to create — which is not true for platform threads.
3.6 Demo: Launch 1 million Virtual Threads
File: 04/demos/04_Using-Virtual-Threads/src/main/java/org/paumard/virtualthread/D_MillionVirtualThreads.java
package org.paumard.virtualthread;
import java.time.Duration;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.ArrayList;
import java.util.Set;
import java.util.TreeSet;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.locks.ReentrantLock;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.IntStream;
public class D_MillionVirtualThreads {
public static final Pattern POOL_PATTERN =
Pattern.compile("ForkJoinPool-[\\d?]");
public static final Pattern WORKER_PATTERN =
Pattern.compile("worker-[\\d?]+");
public static void main(String[] args) throws InterruptedException {
Set<String> poolNames = ConcurrentHashMap.newKeySet();
Set<String> pThreadNames = ConcurrentHashMap.newKeySet();
int N_THREADS = 1_000_000;
// Crée 1 million de Virtual Threads
var threads = IntStream.range(0, N_THREADS)
.mapToObj(i -> Thread.ofVirtual()
.unstarted(
() -> {
String poolName = readPoolName();
poolNames.add(poolName);
String workerName = readWorkerName();
pThreadNames.add(workerName);
}
)
)
.toList();
Instant begin = Instant.now();
for (var thread : threads) {
thread.start();
}
for (var thread : threads) {
thread.join();
}
Instant end = Instant.now();
System.out.println("# Virtual threads = " + N_THREADS);
System.out.println("# cores = " + Runtime.getRuntime().availableProcessors());
System.out.println("Time = " + Duration.between(begin, end).toMillis() + "ms");
System.out.println("### Pools");
poolNames.forEach(System.out::println);
System.out.println("### Platform threads: " + pThreadNames.size());
}
private static String readWorkerName() {
String name = Thread.currentThread().toString();
Matcher workerMatcher = WORKER_PATTERN.matcher(name);
if (workerMatcher.find()) {
return workerMatcher.group();
}
return "not found";
}
private static String readPoolName() {
String name = Thread.currentThread().toString();
Matcher poolMatcher = POOL_PATTERN.matcher(name);
if (poolMatcher.find()) {
return poolMatcher.group();
}
return "pool not found";
}
}
Results on a 12-core machine:
- 1 million Virtual Threads launched in ~1 second.
- ~1 microsecond on average to create a Virtual Thread (vs. 1 millisecond for a platform thread).
- A single ForkJoinPool created — dedicated to running Virtual Threads.
- 12 platform threads (carrier threads) used to execute 1 million Virtual Threads.
Conclusion: It is possible and practical to have 1 million Virtual Threads on an ordinary machine.
3.7 Module 3 Summary
- Reactive programming is a powerful technology with a very high maintenance cost.
- Virtual Threads use an API identical to that of platform threads.
- Virtual Threads support the same concurrency issues (race conditions, locking, deadlocking).
- The next module explains how Virtual Threads work internally and why it is okay to block them.
4. Use Virtual Threads to increase throughput
Total module duration: 33m 37s
4.1 Module Introduction and Agenda
This module explains everything you need to know to use Virtual Threads effectively in an application. Virtual Threads are a powerful tool, provided you use them in the right situation.
Module plan:
- How Virtual Threads work internally (Carrier Threads, ForkJoinPool).
- What happens when a Virtual Thread executes blocking code (prohibited in reactive programming, allowed with Virtual Threads).
- The
Continuationobject — JDK internal API. - pinning — a caveat of Virtual Threads.
- Performance comparison: reactive programming vs Virtual Threads.
4.2 Carrier Threads
A Virtual Thread runs on top of a platform thread, called a carrier thread.
Difference between carrier thread and platform thread:
- Platform thread: generic platform thread.
- Carrier thread: platform thread used in the specific context of Virtual Threads execution.
Carrier threads live in a ForkJoinPool dedicated to the execution of Virtual Threads.
File: 04/demos/04_Using-Virtual-Threads/src/main/java/org/paumard/virtualthread/A_DisplayingVirtualThreads.java
package org.paumard.virtualthread;
import java.util.concurrent.Executors;
public class A_DisplayingVirtualThreads {
public static void main(String[] args) throws InterruptedException {
Runnable task =
() -> System.out.println("I am running in the thread " +
Thread.currentThread());
var thread = Thread.ofVirtual()
.name("My virtual thread")
.unstarted(task);
thread.start();
thread.join();
// Avec newVirtualThreadPerTaskExecutor
try (var es = Executors.newVirtualThreadPerTaskExecutor()) {
es.submit(task);
}
}
}
Typical output (toString of a Virtual Thread):
I am running in the thread VirtualThread[#22,My virtual thread]/runnable@ForkJoinPool-1/worker-1
This output reveals:
- VirtualThread[#22,My virtual thread]: name and identifier of the Virtual Thread.
- ForkJoinPool-1/worker-1: the carrier thread (platform thread) on which it executes.
4.3 How Virtual Threads are executed by Carrier Threads
Operation of the dedicated ForkJoinPool:
- When a Virtual Thread is created and launched, it is sent to the dedicated ForkJoinPool.
- An available carrier thread will execute it.
- The ForkJoinPool creates as many carrier threads as CPU cores (e.g.: 8, 10, 12).
- Each carrier thread has its own waitlist.
- Virtual Threads are stored in these waitlists.
ExecutorService vs ForkJoinPool difference:
| ExecutorService | ForkJoinPool | |
|---|---|---|
| Waitlist | only 1 for all threads | 1 per thread |
| Work stealing | No | Yes |
Work stealing: if the waitlist of a carrier thread is empty (thread starvation), this carrier thread can “steal” Virtual Threads from the waitlists of other carrier threads. This ensures that all carrier threads remain busy as long as there are Virtual Threads to execute.
Two important points:
-
Overhead: executing a
Runnablein a Virtual Thread is more expensive than executing it directly in a platform thread (wrapping + ForkJoinPool overhead). Do not use Virtual Threads for non-blocking tasks (in-memory computations). -
Non-preemptive scheduler: the Virtual Threads scheduler (unlike that of platform threads) is not preemptive. A Virtual Thread running a long CPU task will remain on its carrier thread until the end, without interruption — another reason not to use Virtual Threads for intensive computing.
Golden rule: Virtual Threads are designed to execute blocking code (network, I/O). Don’t use them for anything else.
4.4 Demo: What happens when a Virtual Thread blocks
File: 04/demos/04_Using-Virtual-Threads/src/main/java/org/paumard/virtualthread/B1_BlockingVirtualThreads.java
package org.paumard.virtualthread;
import java.time.Duration;
import java.time.temporal.ChronoUnit;
import java.util.ArrayList;
public class B1_BlockingVirtualThreads {
public static void main(String[] args) throws InterruptedException {
// task1 : affiche le thread courant, se bloque 3 fois
Runnable task1 =
() -> {
System.out.println(Thread.currentThread());
sleepFor(10, ChronoUnit.MICROS);
System.out.println(Thread.currentThread());
sleepFor(10, ChronoUnit.MICROS);
System.out.println(Thread.currentThread());
sleepFor(10, ChronoUnit.MICROS);
System.out.println(Thread.currentThread());
};
// task2 : même comportement mais sans afficher
Runnable task2 =
() -> {
sleepFor(10, ChronoUnit.MICROS);
sleepFor(10, ChronoUnit.MICROS);
sleepFor(10, ChronoUnit.MICROS);
};
int N_THREADS = 10;
var threads = new ArrayList<Thread>();
for (int index = 0; index < N_THREADS; index++) {
var thread =
index == 0 ?
Thread.ofVirtual().unstarted(task1) :
Thread.ofVirtual().unstarted(task2);
threads.add(thread);
}
for (Thread thread : threads) {
thread.start();
}
for (Thread thread : threads) {
thread.join();
}
}
private static void sleepFor(int amount, ChronoUnit unit) {
try {
Thread.sleep(Duration.of(amount, unit));
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
}
Key observation: With 10 threads, the task1 shows something like:
VirtualThread[#22]/runnable@ForkJoinPool-1/worker-1
VirtualThread[#22]/runnable@ForkJoinPool-1/worker-6 ← different worker après le blocage !
VirtualThread[#22]/runnable@ForkJoinPool-1/worker-9 ← encore différent !
VirtualThread[#22]/runnable@ForkJoinPool-1/worker-3
Virtual Thread jumps from one carrier thread to another after each block — behavior never seen before with platform threads.
Internal explanation:
Thread.sleep()checks if the current thread is aVirtualThread.- If yes, it calls
sleepNanos()which is a method of theVirtualThreadclass. - This method calls
yieldContinuation()which invokesContinuation.yield(). Continuation.yield()detaches the Virtual Thread from its carrier thread.
4.5 Demo: Analyze how Continuations work
File: 04/demos/04_Using-Virtual-Threads/src/main/java/org/paumard/virtualthread/B2_PlayWithContinuation.java
package org.paumard.virtualthread;
import jdk.internal.vm.Continuation;
import jdk.internal.vm.ContinuationScope;
public class B2_PlayWithContinuation {
// Options JVM requises (API interne, ne pas utiliser en production) :
// --enable-preview --add-exports java.base/jdk.internal.vm=ALL-UNNAMED
public static void main(String[] args) {
var scope = new ContinuationScope("My scope");
var continuation = new Continuation(
scope,
() -> {
System.out.println("Running in a continuation");
Continuation.yield(scope); // Suspend l'exécution ici
System.out.println("After the call to yield"); // Repris lors du prochain run()
});
System.out.println("Running in the main method");
continuation.run(); // Exécute jusqu'au yield → affiche "Running in a continuation"
System.out.println("Back in the main method");
continuation.run(); // Reprend après le yield → affiche "After the call to yield"
System.out.println("Back again in the main method");
}
}
Output:
Running in the main method
Running in a continuation
Back in the main method
After the call to yield
Back again in the main method
Behavior of a Continuation:
continuation.run(): executes theRunnableuntilContinuation.yield(scope)is called.Continuation.yield(scope): suspend the execution of theRunnable. Return to calling code.- Second call to
continuation.run(): resume execution after theyield.
Important: All this happens in the same thread (the main thread). No thread is created.
Warning:
jdk.internal.vm.Continuationis an JDK internal API — not for use in production applications. You need to add--add-exports java.base/jdk.internal.vm=ALL-UNNAMEDto use it.
4.6 Continuations prevent Carrier Threads from blocking
The JDK has been completely refactored so that each blocking call checks if the code is running in a Virtual Thread. If so, it uses the Continuation object to handle the blocking.
Complete mechanism:
Virtual Thread lance une tâche
↓
Tâche "montée" sur un Carrier Thread (via Continuation.run)
↓
Tâche appelle du code bloquant (ex: Thread.sleep, I/O réseau)
↓
Le code détecte qu'il s'exécute dans un Virtual Thread
↓
Appel à Continuation.yield
↓
Virtual Thread "démonté" du Carrier Thread
↓
Stack du Virtual Thread déplacée vers la HEAP memory (mémoire principale)
↓
Carrier Thread libre pour exécuter d'autres Virtual Threads
↓
Callback enregistré sur le gestionnaire I/O
↓
[Temps s'écoule... données disponibles]
↓
Callback invoqué : Continuation.run() appelé
↓
Stack du Virtual Thread déplacée de la HEAP vers la waitlist d'un Carrier Thread
↓
Un Carrier Thread disponible (ou via work stealing) exécute le Virtual Thread
↓
Le Virtual Thread reprend son exécution après le point de blocage
Cost of blocking a Virtual Thread:
- Move your stack to heap memory (low cost).
- vs. block a platform thread: cost of the context switch (high).
What fundamentally differs from platform threads:
- With platform threads: the stack of a thread is linked to the thread, impossible to move it.
- With Virtual Threads: the stack is stored in the heap, can be moved at will.
Consequence: Millions of stacks of blocked Virtual Threads coexist in the heap memory. When data becomes available, each stack is placed back in the waitlist of a carrier thread.
4.7 Pinning — Virtual Thread pinned to its Carrier Thread
There is a case where moving the stack to the heap is not possible: pinning.
Why does pinning exist?
In Java, all references point to objects in heap memory. There is never a reference to an object in the stack. This allows the stack to be moved safely.
But if native code (C, C++) is called from Java, this code may have pointers to the stack. If we moved the stack while this code was running, these pointers would become invalid and the application would crash.
Two situations causing pinning:
-
Calling native code: if Java code calls native code that manipulates addresses on the stack and blocks, the Virtual Thread is pinned on its carrier thread.
-
synchronizedblock: Although it does not call native code, Java synchronization internally uses a address on the stack in its implementation. This also causes pinning.
Consequence of pinning: the carrier thread is blocked for the duration of pinning. If this blockage lasts for milliseconds (network request in a synchronized block), it may become a performance issue.
4.8 Demo: Resolve pinning with ReentrantLock
File: 04/demos/04_Using-Virtual-Threads/src/main/java/org/paumard/virtualthread/C_BlockingVirtualThreads.java
package org.paumard.virtualthread;
import java.time.Duration;
import java.time.temporal.ChronoUnit;
import java.util.ArrayList;
import java.util.concurrent.locks.ReentrantLock;
public class C_BlockingVirtualThreads {
private static int counter = 0;
public static void main(String[] args) throws InterruptedException {
var lock = new ReentrantLock();
// Solution : remplacer synchronized par ReentrantLock
// → le Virtual Thread ne sera plus épinglé sur son carrier thread
Runnable task1 =
() -> {
System.out.println(Thread.currentThread());
lock.lock();
try {
counter++;
sleepFor(1, ChronoUnit.MICROS);
} finally {
lock.unlock();
}
System.out.println(Thread.currentThread());
lock.lock();
try {
counter++;
sleepFor(1, ChronoUnit.MICROS);
} finally {
lock.unlock();
}
System.out.println(Thread.currentThread());
lock.lock();
try {
counter++;
sleepFor(1, ChronoUnit.MICROS);
} finally {
lock.unlock();
}
System.out.println(Thread.currentThread());
};
Runnable task2 =
() -> {
lock.lock();
try {
counter++;
sleepFor(1, ChronoUnit.MICROS);
} finally {
lock.unlock();
}
lock.lock();
try {
counter++;
sleepFor(1, ChronoUnit.MICROS);
} finally {
lock.unlock();
}
lock.lock();
try {
counter++;
sleepFor(1, ChronoUnit.MICROS);
} finally {
lock.unlock();
}
};
int N_THREADS = 2_000;
var threads = new ArrayList<Thread>();
for (int index = 0; index < N_THREADS; index++) {
var thread =
index == 0 ?
Thread.ofVirtual().unstarted(task1) :
Thread.ofVirtual().unstarted(task2);
threads.add(thread);
}
for (Thread thread : threads) {
thread.start();
}
for (Thread thread : threads) {
thread.join();
}
System.out.println("# threads = " + N_THREADS);
System.out.println("counter = " + counter);
}
private static void sleepFor(int amount, ChronoUnit unit) {
try {
Thread.sleep(Duration.of(amount, unit));
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
}
Problem with synchronized (code before refactoring):
// Avec synchronized, le Virtual Thread est ÉPINGLÉ (pinned)
// → ne saute plus d'un carrier thread à l'autre
synchronized (lockObject) {
counter++;
sleepFor(1, ChronoUnit.MICROS); // Bloque le carrier thread !
}
Solution with ReentrantLock:
// Avec ReentrantLock, le Virtual Thread n'est PAS épinglé
// → saute d'un carrier thread à l'autre normalement
lock.lock();
try {
counter++;
sleepFor(1, ChronoUnit.MICROS); // Le carrier thread est libéré pendant le sleep
} finally {
lock.unlock();
}
Observation: With synchronized, the task1 always remains on worker-1. With ReentrantLock, it jumps from one worker to another.
Identical semantics: ReentrantLock and synchronized have exactly the same locking semantics. Refactoring does not introduce any regression.
4.9 When to refactor to ReentrantLock?
Do not refactor all synchronized blocks blindly!
The decision rule:
| Location | Action |
|---|---|
synchronized block protecting a structure in memory (List, Map) — traversal in hundreds of nanoseconds | No action necessary — pinning is negligible |
synchronized block containing a network call (millisecond block) | Consider refactoring to ReentrantLock |
Recommended approach:
- Assess the situation: Are there any performance issues?
- If yes, identify the faulty
synchronizedblocks (with network calls). - Refactor only these blocks to
ReentrantLock.
4.10 Comparison: Reactive Programming vs Virtual Threads
Both approaches are based on the same principle: platform threads should never block.
| Appearance | Responsive Programming | VirtualThreads |
|---|---|---|
| Responsibility not to block | Developer — bug if forgotten | JDK — automatically supported |
| Programming model | Complex, callbacks, lambdas | Simple, imperative, blocking |
| Code readability | Difficult | As simple as the business process |
| Unit tests | Difficult | Easy |
| Debug | Very difficult | Easy |
| Profiling | Almost impossible | Possible |
| Performance | Excellent | Equivalent — the difference comes from the framework |
From the platform thread point of view:
With reactive programming: the platform thread is supplied with lambdas by the framework. Each lambda works, waits for data without blocking the thread, then the thread moves on to the next one.
With Virtual Threads: the carrier thread is supplied with Virtual Threads. Each Virtual Thread works, blocks (stack sent to the heap), and the carrier thread moves on to the next one. When the data arrives, the Virtual Thread is placed back in the waitlist.
From a platform thread perspective, there is NO performance difference. The performance difference comes from the framework, not the application code.
The most important difference: with reactive programming, not blocking is your responsibility (heavy load, hard to fix performance bugs). With Virtual Threads, it is the responsibility of the JDK — much safer situation.
4.11 Module 4 Summary
- Virtual Threads run on top of carrier threads in a dedicated ForkJoinPool.
- The
Continuationobject is at the heart of the mechanism — it allows execution to be suspended and resumed. - Virtual Threads are designed to execute blocking code only — do not use them for intensive computing.
- When a Virtual Thread blocks, the carrier thread is not blocked — the stack is moved to the heap.
- pinning can block a carrier thread — watch out for
synchronizedblocks containing network calls. - Solution for pinning: replace
synchronizedwithReentrantLock(only if necessary after evaluation). - Performance: Reactive programming and Virtual Threads are equivalent from a platform thread perspective.
5. Introduction to Structured Concurrency
Total module duration: 55m 23s
5.1 Module Introduction and Agenda
Structured Concurrency addresses two problems:
Problem 1: Code blocking on multiple threads
// Sans Virtual Threads : bloquer 3 platform threads est problématique
ExecutorService executor = Executors.newFixedThreadPool(2);
Future<String> futureText = executor.submit(() -> readText(url));
Future<String[]> futureLinks = executor.submit(() -> readLinks(url));
// Bloque le thread main + 2 threads de l'executor
String text = futureText.get(5, TimeUnit.SECONDS); // timeout
String[] links = futureLinks.get(5, TimeUnit.SECONDS); // timeout
Page page = new Page(text, links);
With Virtual Threads, blocking is cheap — this problem is fixed.
Problem 2: Loose Threads
// Si futureText.get() lève une TimeoutException...
String text = futureText.get(5, TimeUnit.SECONDS); // TimeoutException !
// futureLinks.get() n'est jamais appelé → le thread exécutant readLinks
// continue de tourner indéfiniment → LOOSE THREAD
String[] links = futureLinks.get(5, TimeUnit.SECONDS);
This lost thread is unrecoverable. After a while, the application inexplicably slows down. Reboot → it starts again. Then the lost threads accumulate again. Insidious and costly problem.
Structured Concurrency is built on Virtual Threads and solves the problem of loose threads by design.
Module plan:
- What is Structured Concurrency and how it eliminates loose threads.
- The API:
StructuredTaskScope,ShutdownOnSuccess,ShutdownOnFailure. - Create your own strategy by extending
StructuredTaskScope.
5.2 Eliminate Loose Threads with Structured Concurrency
Analogy with goto in imperative programming:
In early languages, the goto instruction allowed you to jump anywhere in the code. This was problematic because it was impossible to follow the execution flow. The goto has been replaced by control structures (if/else, for loops, while, method calls).
Current concurrent programming suffers from the same problem:
- Starting a task in a thread is like a
goto— the program execution flow is broken. - It is impossible to know from which line of code the application launched this task.
- This is why loose threads are so difficult to fix.
Structured Concurrency fixes this by giving each Virtual Thread a bounded lifecycle:
Code principal
↓
Création d'un StructuredTaskScope
├─ Virtual Thread 1 (tâche A) ─ cycle de vie borné par le scope
├─ Virtual Thread 2 (tâche B) ─ cycle de vie borné par le scope
└─ Virtual Thread 3 (tâche C) ─ cycle de vie borné par le scope
↓
scope.join() ← attend la fin de toutes les tâches
↓
scope.close() ← interrompt tous les Virtual Threads encore en cours, nettoie
↓
Retour au code principal
When the scope is closed, everything is cleaned. No more lost threads possible.
Why only possible with Virtual Threads? Creating one thread per task and letting it finish is only feasible if threads are cheap. With platform threads, we must pool them and reuse them — a life cycle limited by task is impossible.
5.3 Demo: Premier StructuredTaskScope — Weather Forecast
File: 05/demos/05_Introducing-Structured-Concurrency/src/main/java/org/paumard/structuredconcurrency/A_structuredtaskscope/A_Weather.java
package org.paumard.structuredconcurrency.A_structuredtaskscope;
import org.paumard.structuredconcurrency.A_structuredtaskscope.model.Weather;
import java.util.concurrent.StructuredTaskScope;
public class A_Weather {
// Nécessite : --enable-preview
public static void main(String[] args) {
var weather = Weather.readWeather();
System.out.println("weather = " + weather);
}
}
Note: The
StructuredTaskScopeAPI is available as a preview feature since Java 21 and Java 22. It requires--enable-previewat compilation and execution. Demo projects are configured accordingly.
Basic pattern of StructuredTaskScope:
// Nécessite : --enable-preview
try (var scope = new StructuredTaskScope<Weather>()) {
// 1. Fork des tâches (Callable ou Runnable)
var f1 = scope.fork(Weather::readFromInternationalWF);
var f2 = scope.fork(Weather::readFromGlobalWF);
var f3 = scope.fork(Weather::readFromPlanetEarthWeatherForecast);
// 2. Appel obligatoire à join() avant d'analyser les résultats
scope.join(); // Bloquant — attend la fin de toutes les tâches
// 3. Analyser les résultats via les objets Subtask
System.out.println("F1: " + f1.state());
if (f1.state() == StructuredTaskScope.Subtask.State.SUCCESS) {
System.out.println(" " + f1.get());
}
System.out.println("F2: " + f2.state());
if (f2.state() == StructuredTaskScope.Subtask.State.SUCCESS) {
System.out.println(" " + f2.get());
}
System.out.println("F3: " + f3.state());
if (f3.state() == StructuredTaskScope.Subtask.State.SUCCESS) {
System.out.println(" " + f3.get());
}
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
What to remember:
StructuredTaskScopeimplementsAutoCloseable→ use withtry-with-resources.scope.fork()acceptsCallableandRunnable.scope.join()is required before calling.get()on subtasks — otherwise runtime exception.- The
Subtask<T>object returned byfork()has 3 states:SUCCESS,FAILED,UNAVAILABLE. - Status
UNAVAILABLEmeans that the task has been interrupted or has not yet been completed.
5.4 Demo: ShutdownOnSuccess for weather
File: 05/demos/05_Introducing-Structured-Concurrency/src/main/java/org/paumard/structuredconcurrency/B_shutdownonsuccess/model/Weather.java
package org.paumard.structuredconcurrency.B_shutdownonsuccess.model;
import java.time.Duration;
import java.time.temporal.ChronoUnit;
import java.util.Random;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.StructuredTaskScope;
public record Weather(String weather, String server) {
private static Random random = new Random();
public static Weather readWeather() {
// ShutdownOnSuccess : retourne le premier résultat valide
// et interrompt toutes les autres tâches
try (var scope = new StructuredTaskScope.ShutdownOnSuccess<Weather>()) {
var f1 = scope.fork(Weather::readFromInternationalWF);
var f2 = scope.fork(Weather::readFromGlobalWF);
var f3 = scope.fork(Weather::readFromPlanetEarthWeatherForecast);
scope.join();
// Afficher l'état de chaque subtask
System.out.println("F1: " + f1.state());
if (f1.state() == StructuredTaskScope.Subtask.State.SUCCESS) {
System.out.println(" " + f1.get());
}
System.out.println("F2: " + f2.state());
if (f2.state() == StructuredTaskScope.Subtask.State.SUCCESS) {
System.out.println(" " + f2.get());
}
System.out.println("F3: " + f3.state());
if (f3.state() == StructuredTaskScope.Subtask.State.SUCCESS) {
System.out.println(" " + f3.get());
}
// scope.result() retourne le résultat du premier Callable ayant réussi
return scope.result();
} catch (InterruptedException | ExecutionException e) {
throw new RuntimeException(e);
}
}
private enum WeatherForecast {
Sunny, Rainy, Cloudy
}
public static Weather readFromInternationalWF() {
sleepFor(random.nextInt(70, 120), ChronoUnit.MILLIS);
return new Weather(
WeatherForecast.values()[random.nextInt(0, 3)].name(),
"International Weather Forecast");
}
public static Weather readFromGlobalWF() {
// throw null; // Pour simuler une défaillance
sleepFor(random.nextInt(80, 100), ChronoUnit.MILLIS);
return new Weather(
WeatherForecast.values()[random.nextInt(0, 3)].name(),
"Global Weather Forecast");
}
public static Weather readFromPlanetEarthWeatherForecast() {
sleepFor(random.nextInt(80, 110), ChronoUnit.MILLIS);
return new Weather(
WeatherForecast.values()[random.nextInt(0, 3)].name(),
"Planet Earth Weather Forecast");
}
private static void sleepFor(int amount, ChronoUnit unit) {
try {
Thread.sleep(Duration.of(amount, unit));
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
}
File: 05/demos/05_Introducing-Structured-Concurrency/src/main/java/org/paumard/structuredconcurrency/B_shutdownonsuccess/B_Weather.java
package org.paumard.structuredconcurrency.B_shutdownonsuccess;
import org.paumard.structuredconcurrency.B_shutdownonsuccess.model.Weather;
public class B_Weather {
// --enable-preview
public static void main(String[] args) {
var weather = Weather.readWeather();
System.out.println("weather = " + weather);
}
}
Behavior of ShutdownOnSuccess:
| Result | Description |
|---|---|
| f2 responds first (SUCCESS) | f1 and f3 are UNAVAILABLE (discontinued) |
| f1 and f2 arrive at the same time | Both can be SUCCESS |
| Global Weather Forecast still failing | ShutdownOnSuccess waits for the first available SUCCESS (not exceptions) |
Summary
ShutdownOnSuccess: Starts multiple tasks, returns first valid result, interrupts all other tasks. Ideal when all sources give equivalent results and you want the fastest.
5.5 Demo: StructuredTaskScope for booking simple flights
File: 05/demos/05_Introducing-Structured-Concurrency/src/main/java/org/paumard/structuredconcurrency/C_shutdownonfailure/model/Flight.java
package org.paumard.structuredconcurrency.C_shutdownonfailure.model;
import java.time.Duration;
import java.time.temporal.ChronoUnit;
import java.util.Collection;
import java.util.Comparator;
import java.util.Random;
import java.util.concurrent.ConcurrentLinkedQueue;
import java.util.concurrent.StructuredTaskScope;
public record Flight(String from, String to, int price, String airline) {
private static Random random = new Random();
public static class FlightException extends RuntimeException {
}
// FlightScope étend StructuredTaskScope pour implémenter la stratégie métier
private static class FlightScope extends StructuredTaskScope<Flight> {
// volatile garantit la visibilité entre threads (prévient les race conditions)
private volatile Collection<Flight> flights = new ConcurrentLinkedQueue<>();
private volatile Collection<Throwable> exceptions = new ConcurrentLinkedQueue<>();
@Override
protected void handleComplete(Subtask<? extends Flight> subtask) {
// Cette méthode est appelée dans le Virtual Thread exécutant chaque Callable
// → appelée 3 fois, depuis 3 threads différents → doit être thread-safe
switch (subtask.state()) {
case UNAVAILABLE ->
throw new IllegalStateException("Task should be done");
case SUCCESS ->
this.flights.add(subtask.get());
case FAILED ->
this.exceptions.add(subtask.exception());
}
}
public FlightException exceptions() {
FlightException flightException = new FlightException();
// Suppressed exceptions : attacher plusieurs exceptions à une seule
this.exceptions.forEach(flightException::addSuppressed);
return flightException;
}
public Flight bestFlight() {
return this.flights.stream()
.min(Comparator.comparingInt(Flight::price))
.orElseThrow(this::exceptions);
}
}
public static Flight readFlight(String from, String to) {
FlightQuery query = new FlightQuery(from, to);
try (var scope = new FlightScope()) {
scope.fork(query::readFromAlphaAirlines);
scope.fork(query::readFromGlobalAirlines);
scope.fork(query::readFromPlanetAirlines);
scope.join();
return scope.bestFlight();
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
public static Flight readFromAlphaAirlines(String from, String to) {
if (from.equals("New York")) {
throw null; // Simule une défaillance
}
sleepFor(random.nextInt(80, 100), ChronoUnit.MILLIS);
if (from.equals("Atlanta") || to.equals("Atlanta")) {
return new Flight(from, to, random.nextInt(30, 50), "Alpha Air Lines");
} else {
return new Flight(from, to, random.nextInt(70, 120), "Alpha Air Lines");
}
}
public static Flight readFromGlobalAirlines(String from, String to) {
sleepFor(random.nextInt(90, 110), ChronoUnit.MILLIS);
if (from.equals("Atlanta") || to.equals("Atlanta")) {
return new Flight(from, to, random.nextInt(40, 50), "Alpha Air Lines");
} else {
return new Flight(from, to, random.nextInt(60, 90), "Global Air Lines");
}
}
public static Flight readFromPlanetAirlines(String from, String to) {
sleepFor(random.nextInt(70, 120), ChronoUnit.MILLIS);
if (from.equals("Atlanta") || to.equals("Atlanta")) {
return new Flight(from, to, random.nextInt(30, 50), "Alpha Air Lines");
} else {
return new Flight(from, to, random.nextInt(70, 90), "Planet Air Lines");
}
}
private static void sleepFor(int amount, ChronoUnit unit) {
try {
Thread.sleep(Duration.of(amount, unit));
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
}
File: 05/demos/05_Introducing-Structured-Concurrency/src/main/java/org/paumard/structuredconcurrency/C_shutdownonfailure/C_BookFlight.java
package org.paumard.structuredconcurrency.C_shutdownonfailure;
import org.paumard.structuredconcurrency.C_shutdownonfailure.model.Flight;
import org.paumard.structuredconcurrency.C_shutdownonfailure.model.FlightQuery;
import java.time.Duration;
import java.time.Instant;
public class C_BookFlight {
public static void main(String[] args) {
String from = "New York";
String to = "San Francisco";
var start = Instant.now();
Flight bestFlight = Flight.readFlight(from, to);
var end = Instant.now();
System.out.println("Best flight = " + bestFlight +
" in " + Duration.between(start, end).toMillis());
}
}
Results:
- Sequential version: ~300ms (3 requests × ~100ms each).
- Version with
StructuredTaskScope: ~100ms (3 requests in parallel).
5.6 Demo: Create a FlightScope by extending StructuredTaskScope
Why extend StructuredTaskScope?
- Readability:
scope.bestFlight()is much clearer thanscope.result()or parsing subtasks manually. - Separation of responsibilities: the business logic (finding the best flight) is isolated in
FlightScope, separated from the technical code. - Testability: business methods can be unit tested without worrying about concurrent execution.
Pattern to extend StructuredTaskScope:
private static class FlightScope extends StructuredTaskScope<Flight> {
// Champs thread-safe pour accumuler résultats et exceptions
// volatile : prévient les race conditions sur la LECTURE de la référence
// ConcurrentLinkedQueue : thread-safe pour les ADD
private volatile Collection<Flight> flights = new ConcurrentLinkedQueue<>();
private volatile Collection<Throwable> exceptions = new ConcurrentLinkedQueue<>();
// Méthode à surcharger obligatoirement
// Appelée dans le Virtual Thread de chaque tâche — exécutée de façon concurrente
@Override
protected void handleComplete(Subtask<? extends Flight> subtask) {
switch (subtask.state()) {
case UNAVAILABLE -> throw new IllegalStateException("Task should be done");
case SUCCESS -> this.flights.add(subtask.get());
case FAILED -> this.exceptions.add(subtask.exception());
}
}
// Méthode métier — logique purement métier
public Flight bestFlight() {
return this.flights.stream()
.min(Comparator.comparingInt(Flight::price))
.orElseThrow(this::exceptions); // Si aucun vol, lance les exceptions collectées
}
// Rapport d'erreurs avec suppressed exceptions (Java 7+)
public FlightException exceptions() {
FlightException flightException = new FlightException();
this.exceptions.forEach(flightException::addSuppressed);
return flightException;
}
}
Subtle point: race conditions on flights and exceptions
Thread principal Virtual Thread 1, 2, 3
↓ ↓
new FlightScope() scope.fork() crée les Virtual Threads
↓
this.flights = new ConcurrentLinkedQueue<>()
↑
ÉCRITURE de la référence
par le thread principal LECTURE de la référence
par les Virtual Threads
pour appeler .add()
Solution: declare flights and exceptions volatile — ensures that reading the reference by Virtual Threads sees the value written by the main thread.
Alternatives:
final: also works if the collections are not reassigned.volatile: more explicit to indicate a concurrent context.
5.7 Demo: ShutdownOnFailure for multi-segment flights
Use case: booking a multi-segment flight (New York → Atlanta → San Francisco). Both segments must be available. If one fails, there is no point continuing the other.
Pattern with ShutdownOnFailure:
// ShutdownOnFailure : toutes les tâches doivent réussir.
// Si l'une échoue, le scope interrompt toutes les autres.
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
var t1 = scope.fork(flightQuery1::readFromAlphaAirlines); // Callable<Flight>
var t2 = scope.fork(flightQuery2::readFromAlphaAirlines); // Callable<Flight>
scope.join(); // Attend que toutes les tâches réussissent ou que l'une échoue
// Si on arrive ici, toutes les tâches sont en SUCCESS
Flight leg1 = t1.get();
Flight leg2 = t2.get();
return new MultiLegFlight(leg1, leg2);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
Behavior:
- If
t1fails (exception) → scope interruptst2immediately. - Result: fast response (~26ms) instead of waiting the remaining ~100ms.
- If no task fails → both subtasks are SUCCESS, we can call
.get()without checking the status.
Comparison of the three scopes:
| Scope | Behavior | Use cases |
|---|---|---|
StructuredTaskScope | Wait for all tasks, manual analysis | Custom business logic |
ShutdownOnSuccess | Returns the first valid result | Several equivalent sources |
ShutdownOnFailure | All tasks must succeed | Atomic operations, dependencies |
5.8 Demo: Single and multi-segment flight queries with best price
Travel app architecture:
Travel.readTravel(query0, query1, query2)
├── TravelScope (StructuredTaskScope<Travel>)
│ ├── Flight.readFlight(query0) ── FlightScope
│ │ ├── AlphaAirlines (Virtual Thread)
│ │ ├── GlobalAirlines (Virtual Thread)
│ │ └── PlanetAirlines (Virtual Thread)
│ └── MultiLegFlight.readMultiLegFlight(query1, query2) ── MultiLegFlightScope
│ ├── AlphaAirlines (Virtual Thread)
│ ├── GlobalAirlines (Virtual Thread)
│ └── PlanetAirlines (Virtual Thread)
└── → min(Travel::price) → bestTravel
The final application code combines several levels of nested StructuredTaskScope, each with its own strategy:
// Pattern TravelScope — étend StructuredTaskScope<Travel>
private static class TravelScope extends StructuredTaskScope<Travel> {
private volatile Collection<Travel> travels = new ConcurrentLinkedQueue<>();
private volatile Collection<Throwable> exceptions = new ConcurrentLinkedQueue<>();
@Override
protected void handleComplete(Subtask<? extends Travel> subtask) {
switch (subtask.state()) {
case UNAVAILABLE -> throw new IllegalStateException("Task should be done");
case SUCCESS -> this.travels.add(subtask.get());
case FAILED -> this.exceptions.add(subtask.exception());
}
}
public Travel bestTravel() {
return this.travels.stream()
.min(Comparator.comparingInt(Travel::price))
.orElseThrow(this::travelException);
}
}
5.9 Demo: Add weather forecast to travel system
File: 05/demos/05_Introducing-Structured-Concurrency/src/main/java/org/paumard/structuredconcurrency/E_travelandforecast/E_BookTravel.java
package org.paumard.structuredconcurrency.E_travelandforecast;
import org.paumard.structuredconcurrency.E_travelandforecast.model.Page;
import org.paumard.structuredconcurrency.E_travelandforecast.model.travel.Travel;
import org.paumard.structuredconcurrency.E_travelandforecast.model.travel.TravelQuery;
import org.paumard.structuredconcurrency.E_travelandforecast.model.weather.Weather;
import java.time.Duration;
import java.time.Instant;
import java.util.concurrent.StructuredTaskScope;
public class E_BookTravel {
public static void main(String[] args) {
String from = "New York";
String via = "Atlanta";
String to = "San Francisco";
TravelQuery travelQuery0 = new TravelQuery(from, to);
TravelQuery travelQuery1 = new TravelQuery(from, via);
TravelQuery travelQuery2 = new TravelQuery(via, to);
var begin = Instant.now();
// StructuredTaskScope externe : lance météo et voyage en parallèle
try (var scope = new StructuredTaskScope<>()) {
var task1 = scope.fork(
() -> Weather.readWeather()); // Météo via ShutdownOnSuccess
var task2 = scope.fork(
() -> Travel.readTravel(travelQuery0, travelQuery1, travelQuery2)); // Voyage
scope.join();
var weather = task1.get();
var travel = task2.get();
Page page = new Page(travel, weather);
System.out.println("page = " + page);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
var end = Instant.now();
System.out.println("Done in " + Duration.between(begin, end).toMillis() + " ms");
}
}
Page Template:
package org.paumard.structuredconcurrency.E_travelandforecast.model;
import org.paumard.structuredconcurrency.E_travelandforecast.model.travel.Travel;
import org.paumard.structuredconcurrency.E_travelandforecast.model.weather.Weather;
public record Page(Travel travel, Weather weather) {
}
Typical result:
page = Page[travel=Flight[from=New York, to=San Francisco, price=78, airline=Global Airlines],
weather=Weather[weather=Sunny, server=Global Weather Forecast]]
Done in 187 ms
What the system accomplished:
- Direct flight reservation New York → San Francisco (comparison of 3 companies in parallel).
- Multi-segment flight booking via Atlanta (comparison of 3 companies × 2 segments in parallel).
- Best price selection (direct vs multi-segment).
- Weather forecast from 3 servers in parallel (first to respond).
- All this with simple imperative code, in ~200ms.
Maven configuration (pom.xml):
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>org.paumard</groupId>
<artifactId>05_Introducing-Structured-Concurrency</artifactId>
<version>1.0-SNAPSHOT</version>
<properties>
<maven.compiler.source>21</maven.compiler.source>
<maven.compiler.target>21</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.11.0</version>
<configuration>
<release>21</release>
<compilerArgs>--enable-preview</compilerArgs>
</configuration>
</plugin>
</plugins>
</build>
</project>
5.10 Module 5 Summary
- Structured Concurrency is the missing piece of imperative concurrency programming.
- It eliminates loose threads by design — no longer possible to have lost threads.
- It restores a traceable execution flow in concurrent code (as control structures replaced
goto). - Basic pattern: create scope → fork tasks → join → analyze → scope closes automatically.
- Three strategies available:
StructuredTaskScope(manual),ShutdownOnSuccess(first success),ShutdownOnFailure(all must succeed). - Extensible via
handleComplete()to implement its own strategy. - API is available as preview feature since Java 21 → requires
--enable-preview.
6. Replace ThreadLocals with ScopedValues
Total module duration: 25m 31s
6.1 Module Introduction and Agenda
ScopedValues were added to replace ThreadLocal variables. This module answers the questions:
- Why do we need ThreadLocal variables?
- Why do they have problems with Virtual Threads?
- How do ScopedValues solve these problems?
Module plan:
- Why ThreadLocal variables exist and their problems.
- Create and use ScopedValues.
- ScopedValues in the training travel app.
6.2 Why ThreadLocal variables are problematic with Virtual Threads
Origin of ThreadLocal variables:
Added in Java 2 (1998), before ExecutorService. At the time, the one-request-per-thread model was standard. In a web framework, your code (Servlet, EJB, REST service, Spring Bean) is called by the framework — you do not control the arguments passed.
Typical use case:
Framework appelle Méthode A (avec des informations sensibles, ex. clé de licence)
↓
Méthode A doit passer ces infos à Méthode B
↓
MAIS : le framework appelle Méthode B, pas votre code !
↓
Solution : stocker dans le ThreadLocal du thread courant
↓
Méthode B lit depuis le ThreadLocal du même thread
Why it works: the framework guarantees that the same thread calls Method A and Method B (one-request-per-thread model).
Frameworks use ThreadLocal for:
- HTTP Sessions.
- Authentication (tokens).
- Database transactions.
- And many more.
ThreadLocal variable issues with ExecutorServices and Virtual Threads:
Issue 1: Mandatory cleanup
With ExecutorService, threads are pooled and reused. If a thread processes a request and stores data in a ThreadLocal without cleaning it, the next request processed by that same thread will have access to that data.
// MAUVAIS : oublier de nettoyer le ThreadLocal
ThreadLocal<String> authToken = new ThreadLocal<>();
public void handleRequest(String token) {
authToken.set(token);
processRequest(); // Si une exception est levée ici...
authToken.remove(); // ...cette ligne n'est jamais atteinte !
}
// CORRECT : utiliser try-finally
public void handleRequest(String token) {
authToken.set(token);
try {
processRequest();
} finally {
authToken.remove(); // Toujours exécuté
}
}
Issue 2: Mutability
ThreadLocal variables are mutable. This:
- Prevents some JVM optimizations.
- Makes them memory expensive.
Problem 3: Not suitable for Virtual Threads
With Virtual Threads, processing can pass from one carrier thread to another. Transmitting ThreadLocals from the right place to the right time is complex and expensive.
ScopedValues solve all of these problems:
| Property | ThreadLocal | ScopedValue | |----------|--------||-------------| | Mutability | Mutable | Immutable | | Life cycle | Thread bound (unbounded) | Limited to a method call | | Transmission to threads | Automatic (but dangerous) | Only via StructuredTaskScope | | Risk of leakage | High (forgot to remove) | None by design | | Memory cost | High (mutability) | Low (immutability) |
Important: Virtual Threads support ThreadLocal variables. Migrating to ScopedValues is recommended but not forced.
6.3 Demo: Declaring ScopedValues and linking them
File: 06/demos/06_Replacing-ThreadsLocals-with-ScopedValues/src/main/java/org/paumard/scopedvalues/A_scopedvalues/A_UsingScopedValues.java
package org.paumard.scopedvalues.A_scopedvalues;
import java.util.concurrent.Callable;
import java.util.concurrent.StructuredTaskScope;
public class A_UsingScopedValues {
public static void main(String[] args) throws InterruptedException {
// 1. Déclarer un ScopedValue
// En pratique : souvent public static final pour être accessible depuis partout
ScopedValue<String> scopedValue = ScopedValue.newInstance();
// 2. Tâche qui lit le ScopedValue
Callable<String> task = () -> {
if (scopedValue.isBound()) {
System.out.println("Scoped value bound to " + scopedValue.get());
return scopedValue.get();
} else {
System.out.println("Scoped value not bound");
return "Unbound";
}
};
// 3. Lier une valeur au ScopedValue et l'exécuter dans ce contexte
// ScopedValue.where(scopedValue, "KEY").run(runnable)
// La liaison n'est valide QUE dans le contexte de ce run()
ScopedValue.where(scopedValue, "KEY")
.run(() -> {
// Dans ce Runnable : scopedValue.get() == "KEY"
try (var scope = new StructuredTaskScope<String>()) {
scope.fork(task); // Le Virtual Thread peut lire le ScopedValue
scope.join();
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
});
// En dehors du run() : scopedValue n'est plus lié
}
}
Key rules for ScopedValues:
-
Declaration:
ScopedValue.newInstance()— oftenpublic static final. -
Binding:
ScopedValue.where(scopedValue, value).run(runnable)or.call(callable). -
Scope: the binding is only valid inside the
Runnable/Callablepassed to.run()/.call(). -
Transmission to threads:
| Type of thread created | Access to ScopedValue |
|---|---|
Thread.ofPlatform().start(task) | NO — thread can survive scope |
Thread.ofVirtual().start(task) | NO — same |
Virtual Thread via StructuredTaskScope.fork() | YES — life cycle bounded by the scope |
Key principle: no loose ScopedValue bindings. A thread that can live longer than the scope of the ScopedValue has no access to it.
Full pattern:
// Déclaration (généralement public static final)
public static final ScopedValue<String> licenceKey = ScopedValue.newInstance();
// Utilisation
ScopedValue.where(licenceKey, "KEY_1").run(() -> {
// licenceKey.isBound() == true ici
// licenceKey.get() == "KEY_1"
// Les Virtual Threads créés via StructuredTaskScope peuvent lire licenceKey
try (var scope = new StructuredTaskScope<>()) {
scope.fork(() -> {
// licenceKey.isBound() == true ici aussi
String key = licenceKey.get(); // == "KEY_1"
return null;
});
scope.join();
}
// licenceKey toujours accessible ici
});
// licenceKey.isBound() == false après le run()
6.4 Demo: License verification with ScopedValue
File: 06/demos/06_Replacing-ThreadsLocals-with-ScopedValues/src/main/java/org/paumard/scopedvalues/B_travelandforecast/B_BookTravel.java
package org.paumard.scopedvalues.B_travelandforecast;
import org.paumard.scopedvalues.B_travelandforecast.model.Page;
import org.paumard.scopedvalues.B_travelandforecast.model.travel.Travel;
import org.paumard.scopedvalues.B_travelandforecast.model.travel.TravelQuery;
import org.paumard.scopedvalues.B_travelandforecast.model.weather.Weather;
import java.time.Duration;
import java.time.Instant;
import java.util.concurrent.StructuredTaskScope;
public class B_BookTravel {
// ScopedValue déclaré public static final pour être accessible depuis Weather
public static final ScopedValue<String> licenceKey = ScopedValue.newInstance();
public static void main(String[] args) {
String from = "New York";
String via = "Atlanta";
String to = "San Francisco";
TravelQuery travelQuery0 = new TravelQuery(from, to);
TravelQuery travelQuery1 = new TravelQuery(from, via);
TravelQuery travelQuery2 = new TravelQuery(via, to);
var begin = Instant.now();
// Lier la licence et exécuter dans ce contexte
ScopedValue.where(licenceKey, "KEY_1")
.run(() -> {
Page page = readPage(travelQuery0, travelQuery1, travelQuery2);
System.out.println("page = " + page);
});
// Sans licence : IllegalStateException "Incorrect license"
// Page page = readPage(travelQuery0, travelQuery1, travelQuery2);
var end = Instant.now();
System.out.println("Done in " + Duration.between(begin, end).toMillis() + " ms");
}
private static Page readPage(TravelQuery travelQuery0,
TravelQuery travelQuery1,
TravelQuery travelQuery2) {
try (var scope = new StructuredTaskScope<>()) {
var task1 = scope.fork(
() -> Weather.readWeather()); // Weather vérifie la licence
var task2 = scope.fork(
() -> Travel.readTravel(travelQuery0, travelQuery1, travelQuery2));
scope.join();
var weather = task1.get();
var travel = task2.get();
return new Page(travel, weather);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
}
Page Template:
package org.paumard.scopedvalues.B_travelandforecast.model;
import org.paumard.scopedvalues.B_travelandforecast.model.travel.Travel;
import org.paumard.scopedvalues.B_travelandforecast.model.weather.Weather;
public record Page(Travel travel, Weather weather) {
}
Checking the license in the Weather record (compact constructor):
// Dans le record Weather — compact constructor pour valider à la création
public record Weather(String weather, String server) {
// Compact constructor
public Weather {
// Vérifie que la licence est bien liée et valide
if (!B_BookTravel.licenceKey.isBound() ||
!B_BookTravel.licenceKey.get().equals("KEY_1")) {
throw new IllegalStateException("Incorrect license");
}
}
// ...
}
Maven configuration (pom.xml):
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>org.paumard</groupId>
<artifactId>06_Replacing-ThreadsLocals-with-ScopedValues</artifactId>
<version>1.0-SNAPSHOT</version>
<properties>
<maven.compiler.source>21</maven.compiler.source>
<maven.compiler.target>21</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.11.0</version>
<configuration>
<release>21</release>
<compilerArgs>--enable-preview</compilerArgs>
</configuration>
</plugin>
</plugins>
</build>
</project>
6.5 Module 6 Summary
- ScopedValues replace ThreadLocal variables — an old API that needed an update.
- Two main problems with ThreadLocal:
- Mutabless → expensive in memory, prevent JVM optimizations.
- Thread-related (unbounded lifecycle) → risk of leak if
remove()is forgotten.
- ScopedValues are immutable → less expensive, more optimizable.
- Life cycle bounded by design → no loose ScopedValue bindings possible.
- Transmission to threads: only via
StructuredTaskScope(bounded lifecycle). - Virtual Threads support ThreadLocal → gradual migration possible.
- ScopedValues API is in preview feature since Java 21 → requires
--enable-preview.
7. General course summary
This course covers the newest technology added to the concurrent programming space in Java: Virtual Threads.
Key Takeaways
Virtual Threads:
- Compatible with platform threads (same
ThreadAPI). - Lightweight: ~1 µs to create, stored in the heap when blocked.
- Only for blocking code (network, I/O) — not for intensive computing.
- Make the one-request-per-thread pattern work again.
- Make writing asynchronous reactive code based on callbacks obsolete.
Internal mechanism:
- Run on top of carrier threads in a dedicated
ForkJoinPool. - The
Continuationobject allows you to pause and resume execution. - When a Virtual Thread blocks, its stack is moved to the heap → the carrier thread is released.
- Pinning: avoid
synchronizedblocks with blocking code → useReentrantLock.
Structured Concurrency:
- Eliminates loose threads by design.
- Restores execution flow traceability in concurrent code.
- Three strategies:
StructuredTaskScope,ShutdownOnSuccess,ShutdownOnFailure. - Extensible via
handleComplete(). - Preview feature since Java 21.
ScopedValues:
- Override ThreadLocal variables.
- Immutable and with bounded life cycle.
- Passed only to Virtual Threads created via
StructuredTaskScope. - Preview feature since Java 21.
Final Comparison: Reactive vs Virtual Threads
| Criterion | Responsive Programming | VirtualThreads |
|---|---|---|
| Performance (throughput) | Excellent | Equivalent |
| Programming model | Callbacks, lambdas | Simple imperative |
| Non-blocking liability | Developer | JDK |
| Readability | Difficult | Easy |
| Testability | Difficult | Easy |
| Debuggability | Very difficult | Easy |
| Profiling | Almost impossible | Possible |
| Loose threads | Persistent problem | Eliminated (with Structured Concurrency) |
Additional resources
- OpenJDK Loom project: monitoring future developments of Virtual Threads, Structured Concurrency and ScopedValues.
- Demo source code: available on GitHub (author Jose Paumard’s account).
- Complete Lab downloadable on GitHub.
- Author’s YouTube channel: talks in English and French.
8. Code File Reference
Module 3 — Introduction to Virtual Threads
| File | Description |
|---|---|
| 03/demos/03_Introducing-virtual-threads/pom.xml | Maven configuration (Java 21, without preview) |
| A_CreatingVirtualThreads.java | 4 patterns for creating Virtual Threads |
| B_CreatingExecutorService.java | newVirtualThreadPerTaskExecutor |
Module 4 — Using Virtual Threads
| File | Description |
|---|---|
| 04/demos/04_Using-Virtual-Threads/pom.xml | Maven configuration (Java 21, with --enable-preview) |
| A_DisplayingVirtualThreads.java | Show a Virtual Thread (carrier thread visible) |
| B1_BlockingVirtualThreads.java | Observe carrier thread jump after blocking |
| B2_PlayWithContinuation.java | Internal API Continuation (educational demo) |
| C_BlockingVirtualThreads.java | Pinning + solution with ReentrantLock |
| D_MillionVirtualThreads.java | Launch 1 million Virtual Threads |
Module 5 — Structured Concurrency
| File | Description |
|---|---|
| 05/demos/05_Introducing-Structured-Concurrency/pom.xml | Maven configuration (Java 21, with --enable-preview) |
| A_Weather.java | First pattern StructuredTaskScope |
| B_shutdownonsuccess/model/Weather.java | ShutdownOnSuccess — weather (first result) |
| B_Weather.java | Hand for ShutdownOnSuccess weather |
| C_shutdownonfailure/model/Flight.java | FlightScope custom — best flight |
| C_BookFlight.java | Hand for simple flight booking |
| E_travelandforecast/model/Page.java | Record Page (travel + weather) |
| E_BookTravel.java | Complete travel + weather app |
Module 6 — ScopedValues
| File | Description |
|---|---|
| 06/demos/06_Replacing-ThreadsLocals-with-ScopedValues/pom.xml | Maven configuration (Java 21, with --enable-preview) |
| A_UsingScopedValues.java | First pattern ScopedValue |
| B_travelandforecast/model/Page.java | Record Page with ScopedValues |
| B_BookTravel.java | Complete application with license verification via ScopedValue |
Search Terms
concurrent · programming · java · virtual · threads · backend · architecture · full-stack · web · agenda · carrier · reactive · analyze · concurrency · scopedvalues · structured · structuredtaskscope · thread · throughput · weather · blocking · comparison · continuations · define