Advanced

Concurrent Programming in Java with Virtual Threads

It shows how to write reactive applications with Virtual Threads and a simple programming model.

Table of Contents

  1. Course Overview
  2. Improve reactive programming with Virtual Threads
  1. Introduction to Virtual Threads
  1. Use Virtual Threads to increase throughput
  1. Introduction to Structured Concurrency
  1. Replace ThreadLocals with ScopedValues
  1. General course summary
  2. Code File Reference

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:

  1. Why reactive programming incurs high maintenance costs, and how a lighter threading model solves this problem.
  2. What Virtual Threads are, how they work internally with Continuations, and how to use them.
  3. Build an application with the Structured Concurrency API based on Virtual Threads — ShutdownOnSuccess, ShutdownOnFailure strategies, and custom strategy.
  4. 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:

  1. Define throughput and blocking code.
  2. Explain how reactive programming solves the problem.
  3. Introduce Virtual Threads and their internal workings.
  4. Structured Concurrency (new concurrent programming paradigm).
  5. 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 TypeThroughput measurement
Web serverNumber of HTTP requests per second
DatabaseNumber of transactions per second
Message brokerNumber 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 forEach executes 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:

  1. An I/O request (typically network) is launched, and the thread waits for the response.
  2. The thread waits at the gate of a synchronized block occupied by another thread.

What happens when blocking:

  1. The thread scheduler detects that the thread is not doing anything.
  2. It removes the thread from its CPU core (context switch).
  3. 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:

StepTimeCPU 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:

ResourceCost
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:

  1. Reactive programming: make each thread handle multiple requests simultaneously (sometimes thousands).
  2. Virtual Threads: create a new thread model ~1000x lighter than platform threads, allowing millions of threads.
ApproachModelAdvantageDisadvantage
Responsive ProgrammingN requests / threadMaximum performanceComplex code, difficult to maintain
VirtualThreads1 request / thread (virtual)Simple and imperative codeNew 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:

  1. Reactive programming and its costs.
  2. Why Virtual Threads are an alternative.
  3. 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:

ProblemDescription
Difficult programming modelComplex code to write, easy bugs
Blocking code in lambdasForgetting a blocking call = catastrophic performance bug
Difficult readingBusiness code buried in the technical code of the framework
Implicit typesYou have to look at the following lambda to know the return type
Hard Unit TestingCode scattered in lambdas, executed in other threads
Complex error handlingTimeouts especially — treat with great care
Profiling almost impossibleWhat we profile is the framework, not the application
Very difficult debugBreakpoints 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 for Thread.ofVirtual().unstarted(task).start().
  • A Virtual Thread extends the Thread class — 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: newVirtualThreadPerTaskExecutor does 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:

  1. How Virtual Threads work internally (Carrier Threads, ForkJoinPool).
  2. What happens when a Virtual Thread executes blocking code (prohibited in reactive programming, allowed with Virtual Threads).
  3. The Continuation object — JDK internal API.
  4. pinning — a caveat of Virtual Threads.
  5. 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:

  1. When a Virtual Thread is created and launched, it is sent to the dedicated ForkJoinPool.
  2. An available carrier thread will execute it.
  3. The ForkJoinPool creates as many carrier threads as CPU cores (e.g.: 8, 10, 12).
  4. Each carrier thread has its own waitlist.
  5. Virtual Threads are stored in these waitlists.

ExecutorService vs ForkJoinPool difference:

ExecutorServiceForkJoinPool
Waitlistonly 1 for all threads1 per thread
Work stealingNoYes

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:

  1. Overhead: executing a Runnable in 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).

  2. 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 a VirtualThread.
  • If yes, it calls sleepNanos() which is a method of the VirtualThread class.
  • This method calls yieldContinuation() which invokes Continuation.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:

  1. continuation.run(): executes the Runnable until Continuation.yield(scope) is called.
  2. Continuation.yield(scope): suspend the execution of the Runnable. Return to calling code.
  3. Second call to continuation.run(): resume execution after the yield.

Important: All this happens in the same thread (the main thread). No thread is created.

Warning: jdk.internal.vm.Continuation is an JDK internal API — not for use in production applications. You need to add --add-exports java.base/jdk.internal.vm=ALL-UNNAMED to 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:

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

  2. synchronized block: 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:

LocationAction
synchronized block protecting a structure in memory (List, Map) — traversal in hundreds of nanosecondsNo action necessary — pinning is negligible
synchronized block containing a network call (millisecond block)Consider refactoring to ReentrantLock

Recommended approach:

  1. Assess the situation: Are there any performance issues?
  2. If yes, identify the faulty synchronized blocks (with network calls).
  3. 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.

AppearanceResponsive ProgrammingVirtualThreads
Responsibility not to blockDeveloper — bug if forgottenJDK — automatically supported
Programming modelComplex, callbacks, lambdasSimple, imperative, blocking
Code readabilityDifficultAs simple as the business process
Unit testsDifficultEasy
DebugVery difficultEasy
ProfilingAlmost impossiblePossible
PerformanceExcellentEquivalent — 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 Continuation object 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 synchronized blocks containing network calls.
  • Solution for pinning: replace synchronized with ReentrantLock (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:

  1. What is Structured Concurrency and how it eliminates loose threads.
  2. The API: StructuredTaskScope, ShutdownOnSuccess, ShutdownOnFailure.
  3. 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 StructuredTaskScope API is available as a preview feature since Java 21 and Java 22. It requires --enable-preview at 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:

  • StructuredTaskScope implements AutoCloseable → use with try-with-resources.
  • scope.fork() accepts Callable and Runnable.
  • scope.join() is required before calling .get() on subtasks — otherwise runtime exception.
  • The Subtask<T> object returned by fork() has 3 states: SUCCESS, FAILED, UNAVAILABLE.
  • Status UNAVAILABLE means 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:

ResultDescription
f2 responds first (SUCCESS)f1 and f3 are UNAVAILABLE (discontinued)
f1 and f2 arrive at the same timeBoth can be SUCCESS
Global Weather Forecast still failingShutdownOnSuccess 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?

  1. Readability: scope.bestFlight() is much clearer than scope.result() or parsing subtasks manually.
  2. Separation of responsibilities: the business logic (finding the best flight) is isolated in FlightScope, separated from the technical code.
  3. 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 t1 fails (exception) → scope interrupts t2 immediately.
  • 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:

ScopeBehaviorUse cases
StructuredTaskScopeWait for all tasks, manual analysisCustom business logic
ShutdownOnSuccessReturns the first valid resultSeveral equivalent sources
ShutdownOnFailureAll tasks must succeedAtomic 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:

  1. Why ThreadLocal variables exist and their problems.
  2. Create and use ScopedValues.
  3. 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:

  1. Declaration: ScopedValue.newInstance() — often public static final.

  2. Binding: ScopedValue.where(scopedValue, value).run(runnable) or .call(callable).

  3. Scope: the binding is only valid inside the Runnable / Callable passed to .run() / .call().

  4. Transmission to threads:

Type of thread createdAccess 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:
  1. Mutabless → expensive in memory, prevent JVM optimizations.
  2. 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 Thread API).
  • 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 Continuation object 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 synchronized blocks with blocking code → use ReentrantLock.

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

CriterionResponsive ProgrammingVirtualThreads
Performance (throughput)ExcellentEquivalent
Programming modelCallbacks, lambdasSimple imperative
Non-blocking liabilityDeveloperJDK
ReadabilityDifficultEasy
TestabilityDifficultEasy
DebuggabilityVery difficultEasy
ProfilingAlmost impossiblePossible
Loose threadsPersistent problemEliminated (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

FileDescription
03/demos/03_Introducing-virtual-threads/pom.xmlMaven configuration (Java 21, without preview)
A_CreatingVirtualThreads.java4 patterns for creating Virtual Threads
B_CreatingExecutorService.javanewVirtualThreadPerTaskExecutor

Module 4 — Using Virtual Threads

FileDescription
04/demos/04_Using-Virtual-Threads/pom.xmlMaven configuration (Java 21, with --enable-preview)
A_DisplayingVirtualThreads.javaShow a Virtual Thread (carrier thread visible)
B1_BlockingVirtualThreads.javaObserve carrier thread jump after blocking
B2_PlayWithContinuation.javaInternal API Continuation (educational demo)
C_BlockingVirtualThreads.javaPinning + solution with ReentrantLock
D_MillionVirtualThreads.javaLaunch 1 million Virtual Threads

Module 5 — Structured Concurrency

FileDescription
05/demos/05_Introducing-Structured-Concurrency/pom.xmlMaven configuration (Java 21, with --enable-preview)
A_Weather.javaFirst pattern StructuredTaskScope
B_shutdownonsuccess/model/Weather.javaShutdownOnSuccess — weather (first result)
B_Weather.javaHand for ShutdownOnSuccess weather
C_shutdownonfailure/model/Flight.javaFlightScope custom — best flight
C_BookFlight.javaHand for simple flight booking
E_travelandforecast/model/Page.javaRecord Page (travel + weather)
E_BookTravel.javaComplete travel + weather app

Module 6 — ScopedValues

FileDescription
06/demos/06_Replacing-ThreadsLocals-with-ScopedValues/pom.xmlMaven configuration (Java 21, with --enable-preview)
A_UsingScopedValues.javaFirst pattern ScopedValue
B_travelandforecast/model/Page.javaRecord Page with ScopedValues ​​
B_BookTravel.javaComplete 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

Interested in this course?

Contact us to book it or get a custom training plan for your team.