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.
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
synchronizedattorno 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
- JEP 444: Virtual Threads - obiettivo thread-per-request, scheduler ForkJoinPool con parallelismo pari ai processori, i due casi di pinning, jdk.tracePinnedThreads, consultata il 2026-11-12
- JEP 491: Synchronize Virtual Threads without Pinning - consegnato in Java 24, rilascio del carrier dentro synchronized, evento jdk.VirtualThreadPinned, consultata il 2026-11-12
- JEP 453: Structured Concurrency (Preview) - l'API è in anteprima in Java 21, consultata il 2026-11-12
- JEP 505: Structured Concurrency (Fifth Preview) - ancora anteprima in Java 25, nuova API con open() e Joiner, consultata il 2026-11-12
- Task Execution and Scheduling (Spring Boot Reference) - spring.threads.virtual.enabled e l'esecutore che ne deriva, consultata il 2026-11-12
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