Salta al contenuto principale

05 - Java Virtual Threads (Project Loom): Thread Platform vs Thread Virtuali e Structured Concurrency

I virtual thread di Java 21 rendono economica l'attesa, ma il pinning di synchronized costa 4x su Java 21 e StructuredTaskScope è ancora in anteprima.

Cosa ne pensi di questo articolo?

05 - Java Virtual Threads (Project Loom): Thread Platform vs Thread Virtuali e Structured Concurrency
ConcurrencyFoto:panumas nikhomkhaiPexels

In Breve

I virtual thread di Java 21 rendono economica l'attesa, ma il pinning di synchronized costa 4x su Java 21 e StructuredTaskScope è ancora in anteprima.

Concurrency 15 min

Come i virtual thread di Java 21 permettono di scrivere codice bloccante che scala come quello asincrono, dove si inceppano (il pinning di synchronized) e perché StructuredTaskScope è ancora in anteprima: tutto provato con JDK 21 e JDK 25.

java virtual threads project loom structured concurrency java StructuredTaskScope Java 21

Il problema: un thread per richiesta, ma i thread sono pochi

Un server che dedica un thread a ogni richiesta è il modello più semplice da scrivere, da leggere in un debugger e da analizzare in uno stack trace. Il suo limite è che ogni thread Java classico (platform thread) è legato a un thread del sistema operativo, e di quelli ce ne sono pochi: se ogni richiesta passa la maggior parte del tempo ad aspettare un database, il tuo throughput è fissato dal numero di thread, non dalla CPU. La risposta storica è stata la programmazione asincrona (CompletableFuture, Reactor), che scala ma frantuma il codice in callback.

Il JEP 444, che ha reso stabili i virtual thread in Java 21, dichiara come obiettivo di far scalare «con un uso quasi ottimale dell'hardware» le applicazioni scritte nello stile thread-per-request. Un virtual thread è ancora un Thread, con lo stesso stack trace e gli stessi strumenti, ma è schedulato dalla JVM: quando si blocca su un'operazione bloccante, cede il thread di sistema su cui girava (il carrier) a un altro virtual thread. Per il confronto con gli altri modelli, vedi il primo articolo della serie.

Tutti i listati sono stati compilati ed eseguiti con OpenJDK 21.0.13 (javac 21); il confronto sul pinning è stato eseguito anche con OpenJDK 25.0.5. I tempi sono misure singole su una macchina sola e cambiano da un'esecuzione all'altra.

Creare virtual thread: tre modi, un caso reale

Ci sono tre punti d'ingresso. Thread.ofVirtual() crea un singolo thread con builder, utile per provare. Executors.newVirtualThreadPerTaskExecutor() crea un nuovo virtual thread per ogni task inviato, ed è la forma da usare in pratica; a differenza di un pool, non c'è nulla da dimensionare né da riutilizzare, perché un virtual thread costa poco. Il terzo, nel listato, è un milione di task.

import java.util.*;
import java.util.concurrent.*;

public class Api {
    public static void main(String[] args) throws Exception {
        // 1. Un virtual thread con nome
        Thread vt = Thread.ofVirtual().name("mio-virtual").start(() ->
            System.out.println("dentro: virtuale=" + Thread.currentThread().isVirtual()
                + ", nome=" + Thread.currentThread().getName()));
        vt.join();

        // 2. Un executor che crea un virtual thread per ogni task
        try (ExecutorService exec = Executors.newVirtualThreadPerTaskExecutor()) {
            Future<String> f = exec.submit(() -> {
                Thread.sleep(100);              // sospende il virtual thread, non il carrier
                return "risultato";
            });
            System.out.println(f.get());
        }

        // 3. Un milione di task, un virtual thread ciascuno
        long t0 = System.nanoTime();
        long somma = 0;
        try (ExecutorService exec = Executors.newVirtualThreadPerTaskExecutor()) {
            List<Future<Long>> futures = new ArrayList<>();
            for (int i = 0; i < 1_000_000; i++) {
                final long id = i;
                futures.add(exec.submit(() -> id * 2));
            }
            for (Future<Long> f : futures) somma += f.get();
        }
        System.out.println("somma = " + somma);
        System.out.println("tempo: " + (System.nanoTime() - t0) / 1_000_000 + " ms (cambia da una macchina all'altra)");
    }
}

// Output:
// dentro: virtuale=true, nome=mio-virtual
// risultato
// somma = 999999000000
// tempo: 569 ms (cambia da una macchina all'altra)

Il primo thread conferma isVirtual() == true. Il secondo task dorme 100 ms con Thread.sleep, che sospende solo il virtual thread. Il terzo esegue un milione di task, uno per virtual thread, e la somma 999.999.000.000 è esatta (2 per la somma di 0..999.999). Il blocco try con l'executor è importante: da Java 21 ExecutorService è AutoCloseable, e close() attende la fine di tutti i task, per cui nessun virtual thread sopravvive al blocco. Il tempo (569 ms qui) è un ordine di grandezza; con un thread di sistema per task lo stesso programma non sarebbe praticabile su una macchina normale, ma non l'ho provato.

Mount e unmount: che cosa succede mentre aspetti

Un virtual thread viene montato su un carrier mentre esegue codice, e smontato quando si blocca su un'operazione che la JVM sa sospendere (I/O di rete, sleep, lock di java.util.concurrent). Lo scheduler è un ForkJoinPool il cui parallelismo, dice il JEP, è per impostazione predefinita uguale al numero di processori disponibili e si cambia con la proprietà jdk.virtualThreadScheduler.parallelism. Per vedere l'effetto la riduco a un solo carrier: se i tre task dormissero tenendo occupato il carrier, ci vorrebbero 300 ms.

import java.util.concurrent.*;

public class Montaggio {
    public static void main(String[] args) throws Exception {
        // Con un solo carrier (-Djdk.virtualThreadScheduler.parallelism=1) tre virtual thread
        // che dormono insieme prendono 100 ms in totale, non 300.
        long t0 = System.nanoTime();
        try (ExecutorService exec = Executors.newVirtualThreadPerTaskExecutor()) {
            for (int i = 1; i <= 3; i++) {
                final int n = i;
                exec.submit(() -> {
                    String prima = Thread.currentThread().toString();
                    try { Thread.sleep(100); } catch (InterruptedException e) { }
                    String dopo = Thread.currentThread().toString();
                    System.out.println("vt" + n + " prima: " + prima.substring(prima.indexOf('/') + 1)
                        + " | dopo: " + dopo.substring(dopo.indexOf('/') + 1));
                });
            }
        }
        System.out.println("totale: " + (System.nanoTime() - t0) / 1_000_000 + " ms");
    }
}

// Output:
// vt1 prima: runnable@ForkJoinPool-1-worker-1 | dopo: runnable@ForkJoinPool-1-worker-1
// vt2 prima: runnable@ForkJoinPool-1-worker-1 | dopo: runnable@ForkJoinPool-1-worker-1
// vt3 prima: runnable@ForkJoinPool-1-worker-1 | dopo: runnable@ForkJoinPool-1-worker-1
// totale: 156 ms

Tutti e tre i task girano sullo stesso carrier (ForkJoinPool-1-worker-1) ma il totale è 156 ms, non 300: mentre uno dorme, il carrier è libero per gli altri. Il listato stampa il nome del carrier prima e dopo sleep; con un solo carrier non può cambiare, con più carrier un virtual thread può riprendere su uno diverso da quello di partenza. Questo spiega perché non devi mai fare affidamento sull'identità del thread di sistema, né usare ThreadLocal come se fosse legato al carrier.

Il pinning: quando un virtual thread non può smontarsi

Il JEP 444 elenca due casi in cui un virtual thread resta inchiodato al suo carrier mentre si blocca: dentro un blocco o metodo synchronized, e durante l'esecuzione di un metodo nativo. Se in quel momento fa I/O, il carrier e il thread di sistema restano bloccati. La conseguenza pratica non è un errore, è una perdita di scalabilità: con pochi carrier, poche attese dentro synchronized bastano a fermare tutto il resto. Il listato lo misura con un solo carrier e quattro task da 200 ms, ciascuno con un proprio lock (nessuna contesa: l'unico costo è il pinning).

import java.util.concurrent.*;
import java.util.concurrent.locks.ReentrantLock;

public class Pinning {
    static long misura(Runnable lavoro) throws Exception {
        long t0 = System.nanoTime();
        try (ExecutorService exec = Executors.newVirtualThreadPerTaskExecutor()) {
            for (int i = 0; i < 4; i++) exec.submit(lavoro);
        }
        return (System.nanoTime() - t0) / 1_000_000;
    }

    static void dormi() {
        try { Thread.sleep(200); } catch (InterruptedException e) { }
    }

    public static void main(String[] args) throws Exception {
        // Ogni task ha il PROPRIO lock: nessuna contesa, il solo costo e' il pinning.
        long conSynchronized = misura(() -> {
            Object lock = new Object();
            synchronized (lock) { dormi(); }
        });
        long conReentrantLock = misura(() -> {
            ReentrantLock lock = new ReentrantLock();
            lock.lock();
            try { dormi(); } finally { lock.unlock(); }
        });
        System.out.println("Java " + Runtime.version().feature()
            + " - 4 task da 200 ms su 1 carrier");
        System.out.println("  synchronized:    " + conSynchronized + " ms");
        System.out.println("  ReentrantLock:   " + conReentrantLock + " ms");
    }
}

// Output:
// Java 21 - 4 task da 200 ms su 1 carrier
//   synchronized:    831 ms
//   ReentrantLock:   200 ms
//
// Java 25 - 4 task da 200 ms su 1 carrier
//   synchronized:    213 ms
//   ReentrantLock:   200 ms

Con Java 21 il synchronized impiega 831 ms, cioè quattro volte 200 ms in sequenza più un piccolo sovraccarico, mentre ReentrantLock impiega 200 ms: i quattro task dormono insieme. Con Java 25 la differenza sparisce (213 ms contro 200 ms), perché il JEP 491, consegnato in Java 24, fa sì che i virtual thread bloccati in synchronized rilascino il carrier. Il vecchio consiglio «sostituisci synchronized con ReentrantLock» vale quindi solo se devi restare su Java 21, e solo per i blocchi che racchiudono operazioni bloccanti lunghe. Non vale per i metodi nativi, che continuano a inchiodare.

Per scovare il pinning in Java 21 il JEP 444 indica la proprietà -Djdk.tracePinnedThreads=short, che stampa il punto in cui il thread si blocca mentre è inchiodato, e un evento di Java Flight Recorder; il JEP 491 spiega che dalla versione senza pinning la proprietà non serve più e resta l'evento jdk.VirtualThreadPinned per i casi residui.

StructuredTaskScope: concorrenza strutturata, ancora in anteprima

Una correzione importante rispetto a quanto circola: StructuredTaskScope non è stabile in Java 25. In Java 21 è un'API di anteprima (JEP 453); in Java 25 è alla quinta anteprima (JEP 505), e nel JEP 505 l'API è cambiata: ShutdownOnFailure e ShutdownOnSuccess non compaiono più, al loro posto c'è StructuredTaskScope.open() con un Joiner che decide quando la scope termina. L'idea resta la stessa: i task figli (fork) vivono dentro un blocco try e non possono sopravvivergli. Il listato usa l'API di Java 21 e va compilato con --enable-preview; senza, javac risponde «StructuredTaskScope is a preview API and is disabled by default».

import java.util.concurrent.*;
import java.util.concurrent.StructuredTaskScope.Subtask;

public class Scope21 {
    static String utente() throws Exception { Thread.sleep(50); return "mario"; }

    static String ordini() throws Exception {
        try { Thread.sleep(2000); return "3 ordini"; }
        catch (InterruptedException e) { System.out.println("ordini: interrotto"); throw e; }
    }

    static String impostazioni() throws Exception {
        Thread.sleep(100);
        throw new IllegalStateException("servizio impostazioni non disponibile");
    }

    public static void main(String[] args) throws Exception {
        long t0 = System.nanoTime();
        try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
            Subtask<String> u = scope.fork(Scope21::utente);
            Subtask<String> o = scope.fork(Scope21::ordini);
            Subtask<String> s = scope.fork(Scope21::impostazioni);
            scope.join().throwIfFailed();
            System.out.println(u.get() + ", " + o.get() + ", " + s.get());
        } catch (ExecutionException e) {
            System.out.println("fallito: " + e.getClass().getSimpleName()
                + " con causa " + e.getCause());
        }
        System.out.println("tempo: " + (System.nanoTime() - t0) / 1_000_000 + " ms");

        // Prima risposta valida: gli altri vengono cancellati
        try (var scope = new StructuredTaskScope.ShutdownOnSuccess<String>()) {
            scope.fork(() -> { Thread.sleep(300); return "primario"; });
            scope.fork(() -> { Thread.sleep(50);  return "cache"; });
            scope.fork(() -> { Thread.sleep(150); return "secondario"; });
            System.out.println("vince: " + scope.join().result());
        }
    }
}

// Output:
// ordini: interrotto
// fallito: ExecutionException con causa java.lang.IllegalStateException: servizio impostazioni non disponibile
// tempo: 143 ms
// vince: cache

Nella prima scope tre task: utente riesce subito, ordini aspetterebbe due secondi, impostazioni fallisce dopo 100 ms. Con ShutdownOnFailure il fallimento chiude la scope: ordini riceve un'interruzione («ordini: interrotto») e il blocco finisce in 143 ms invece di 2000. Nota il tipo dell'eccezione: throwIfFailed lancia una ExecutionException con l'errore originale come causa; non è vero che «lo stack trace resta senza wrapping», come si legge spesso. La seconda scope, ShutdownOnSuccess, restituisce il primo risultato disponibile (la cache a 50 ms) e cancella gli altri due.

L'API di Java 25, secondo il JEP 505, è la stessa idea con meno classi: il caso comune diventa try (var scope = StructuredTaskScope.open()) { ...; scope.join(); }, in cui il join propaga l'eccezione del primo task fallito. Non ho potuto eseguirlo: sulla macchina c'è la sola JRE di Java 25, senza javac utilizzabile con --release 25. Il frammento viene dal JEP, non da un'esecuzione mia.

Perché non CompletableFuture: il confronto in un programma

Nel codice che usa CompletableFuture.allOf, un fallimento non cancella i fratelli. Ancora peggio, allOf completa solo quando tutti hanno terminato, anche se uno è fallito subito. Il listato fa partire un task che fallisce a 50 ms e uno che avrebbe finito a 300 ms.

import java.util.concurrent.*;

public class CfOrfano {
    public static void main(String[] args) throws Exception {
        CompletableFuture<String> utente = CompletableFuture.supplyAsync(() -> {
            sleep(50);
            throw new IllegalStateException("utente non trovato");
        });
        CompletableFuture<String> ordini = CompletableFuture.supplyAsync(() -> {
            sleep(300);
            System.out.println("ordini: finito comunque, per nessuno");
            return "3 ordini";
        });
        try {
            CompletableFuture.allOf(utente, ordini).get();
        } catch (ExecutionException e) {
            System.out.println("allOf: " + e.getCause());
        }
        sleep(500);   // diamo tempo al task orfano di mostrarsi
    }

    static void sleep(long ms) { try { Thread.sleep(ms); } catch (InterruptedException e) { } }
}

// Output:
// ordini: finito comunque, per nessuno
// allOf: java.lang.IllegalStateException: utente non trovato

L'ordine delle righe è la prova: «ordini: finito comunque, per nessuno» compare prima dell'errore di allOf, perché il futuro composto non scatta che a lavoro finito. Chi attende il risultato ha aspettato 300 ms per sapere di un fallimento avvenuto a 50 ms, e il lavoro degli ordini è stato svolto a vuoto. Con StructuredTaskScope la stessa situazione ha impiegato 143 ms e ha interrotto il task superfluo. È il vantaggio concreto della concorrenza strutturata: la durata dei task figli è legata al blocco in cui sono nati.

Quanto vale davvero: 10.000 attese da 100 ms

Il caso dove i virtual thread brillano è quello di molte attese brevi. Il confronto del listato è fra un pool fisso di 200 thread di sistema e un virtual thread per task, su 10.000 task che dormono 100 ms. Il calcolo atteso per il pool è semplice: 10.000 diviso 200 fa 50 giri da 100 ms, cioè circa 5000 ms.

import java.util.*;
import java.util.concurrent.*;

public class Bench {
    static long esegui(ExecutorService exec) throws Exception {
        long t0 = System.nanoTime();
        try (exec) {
            List<Future<?>> fs = new ArrayList<>();
            for (int i = 0; i < 10_000; i++) {
                fs.add(exec.submit(() -> { Thread.sleep(100); return null; }));
            }
            for (Future<?> f : fs) f.get();
        }
        return (System.nanoTime() - t0) / 1_000_000;
    }

    public static void main(String[] args) throws Exception {
        System.out.println("10.000 task da 100 ms di attesa");
        System.out.println("  pool fisso di 200 platform thread: " + esegui(Executors.newFixedThreadPool(200)) + " ms");
        System.out.println("  virtual thread per task:           " + esegui(Executors.newVirtualThreadPerTaskExecutor()) + " ms");
    }
}

// Output:
// 10.000 task da 100 ms di attesa
//   pool fisso di 200 platform thread: 5048 ms
//   virtual thread per task:           154 ms

Il pool impiega 5048 ms, coerente con la previsione; i virtual thread 154 ms, poco più dei 100 ms di una singola attesa. Non è un benchmark di un server: sleep non usa CPU né rete, quindi il programma mostra solo che l'attesa non occupa un thread di sistema. Per carichi dominati dalla CPU i virtual thread non offrono alcun vantaggio, perché il numero di carrier è quello dei processori.

Spring Boot: una riga, e che cosa resta uguale

In Spring Boot 3.2 o successivo, con Java 21, la proprietà spring.threads.virtual.enabled=true fa usare i virtual thread al server web e agli esecutori configurati automaticamente. La documentazione di Spring Boot precisa che l'AsyncTaskExecutor automatico diventa un SimpleAsyncTaskExecutor con virtual thread. Questo blocco è uno schema da adattare, non un programma eseguibile: dipende da un progetto Spring completo, che non ho avviato.

# application.properties (Spring Boot 3.2 o successivo, Java 21 o successivo)
spring.threads.virtual.enabled=true

# Il pool di connessioni al database resta, e resta dimensionato:
spring.datasource.hikari.maximum-pool-size=20

La seconda riga è lì per correggere un'idea sbagliata: il pool di connessioni al database non diventa superfluo. Un virtual thread per richiesta significa potenzialmente migliaia di richieste che chiedono una connessione; è il pool, con il suo massimo, a proteggere il database. Il limite si sposta dal numero di thread al numero di connessioni, ed è giusto così. Altrettanto vale per qualunque altra risorsa limitata: un virtual thread non crea capacità, la rende solo più facile da usare.

Quando i virtual thread non servono

  • Carichi dominati dalla CPU (calcolo, compressione): il numero di core resta il limite.
  • Codice che tiene lock synchronized attorno a I/O su Java 21, o che chiama spesso codice nativo: il pinning annulla il vantaggio.
  • Servizi già reattivi e funzionanti: riscriverli per i virtual thread è un costo senza urgenza.

L'elenco ha un criterio unico: i virtual thread rendono economica l'attesa, non il calcolo. Se il tuo sistema aspetta poco, ha pochi thread o è già asincrono, il guadagno è piccolo. Se invece è un servizio bloccante che tiene migliaia di richieste in attesa di un database o di un'API, è il caso per cui sono nati.

Che cosa sai fare adesso

Sai creare virtual thread e scegliere l'executor giusto, sai prevedere che cosa succede quando uno si blocca, riconoscere il pinning di synchronized su Java 21 e sapere che da Java 24 non c'è più. Sai usare StructuredTaskScope sapendo che è in anteprima, e perché è meglio di CompletableFuture.allOf quando un fallimento deve interrompere i fratelli.

Il prossimo articolo porta la stessa domanda su Rust: Future, Pin e Tokio. Per la cancellazione strutturata nell'altro ecosistema, vedi asyncio.TaskGroup; per i difetti dei lock, race condition e deadlock. Per un altro modo di rendere economica la concorrenza, con le goroutine, vedi l'articolo su goroutine e channel.

Fonti

Tempi e conteggi (569 ms, 156 ms, 831 ms contro 200 ms, 143 ms, 5048 ms contro 154 ms) vengono da esecuzioni singole con OpenJDK 21.0.13; il confronto sul pinning è ripetuto con OpenJDK 25.0.5.

Commenti

Caricamento commenti...

Accedi per commentare

Discuti questo articolo nella community

Unisciti al gruppo Telegram per discutere con altri sviluppatori, fare domande e condividere le tue esperienze.

Ti è piaciuto questo articolo?

Esplora altri contenuti sul blog o scopri i miei progetti