Java Virtual Threads in Profondità
Un'analisi approfondita dei virtual thread Java: pinning, scheduling e come migrare il codice dai platform thread.
I platform thread — l'unico tipo disponibile in Java fino a JDK 21 — mappano uno a uno sui thread del sistema operativo. Sono costosi: ognuno riserva circa un megabyte di stack e il pianificatore OS può gestirne solo alcune migliaia prima che il context-switching consumi la CPU. I virtual thread, introdotti dal Project Loom, abbattono questo limite. Sono thread leggeri gestiti dalla JVM, non dall'OS, quindi un singolo programma può eseguirne milioni. Questo capitolo va oltre l'introduzione: come vengono pianificati, cos'è il pinning, come la structured concurrency collega i loro cicli di vita e dove conviene usarli (e dove no).
Se sei nuovo all'argomento, leggi prima l'introduzione ai virtual thread; questo capitolo presuppone che tu sappia già come avviarne uno. Sarà utile anche una conoscenza di base del multithreading in Java e dell'executor framework.
Platform thread vs. virtual thread
Un virtual thread è comunque un java.lang.Thread — la stessa API, la stessa Runnable. La differenza sta in ciò che lo supporta. Un platform thread è un thread OS per tutta la sua vita. Un virtual thread viene eseguito su un piccolo pool di platform thread detti carrier: quando si blocca su I/O, la JVM lo smonta dal suo carrier, libera quel carrier per un altro virtual thread, e rimonta il virtual thread quando l'I/O si completa. Bloccare un virtual thread è economico; bloccare un platform thread spreca una risorsa scarsa.
| Aspetto | Platform thread | Virtual thread |
|---|---|---|
| Supportato da | Un thread OS | Un carrier thread del pool |
| Costo in memoria | ~1 MB di stack fisso | Poche centinaia di byte, cresce su richiesta |
| Numero pratico | Migliaia | Milioni |
| Ideale per | Lavoro CPU-bound | Lavoro I/O-bound, alta concorrenza |
| Quando si blocca | Spreca il thread OS | Si smonta; il carrier viene riutilizzato |
| Ciclo di vita | Pooling e riuso | Uno per task, usa e getta |
Il modello mentale si ribalta. Con i platform thread si dimensiona con cura un pool e si riutilizzano i thread. Con i virtual thread se ne crea uno per task e lo si lascia terminare — sono abbastanza economici da essere usa e getta.
Creare virtual thread
Esistono tre modi idiomatici. Per un singolo task si usa il builder Thread.ofVirtual() o la scorciatoia Thread.startVirtualThread; per molti task si usa un executor virtual-thread-per-task.
// One-off, started immediately.
Thread t = Thread.startVirtualThread(() ->
System.out.println("hi from " + Thread.currentThread()));
t.join();
// Builder: configure before starting.
Thread named = Thread.ofVirtual().name("worker-", 0).unstarted(() -> doWork());
named.start();
// Many tasks: the executor creates a fresh virtual thread per submitted task.
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 0; i < 1_000; i++) {
executor.submit(() -> handleRequest());
}
} // close() waits for every task to finishNon mettere mai in pool i virtual thread. Un tradizionale fixed thread pool limita la concorrenza intenzionalmente; avvolgere i virtual thread in Executors.newFixedThreadPool(...) vanifica completamente il loro vantaggio. Lo strumento giusto è newVirtualThreadPerTaskExecutor(), che non impone alcun limite di dimensione.
Scheduling, carrier e pinning
I virtual thread sono pianificati da un ForkJoinPool dedicato il cui numero di worker corrisponde per impostazione predefinita al numero di core CPU. Questi worker sono i carrier thread. Quando un virtual thread raggiunge una chiamata bloccante nel JDK — Thread.sleep, letture socket, BlockingQueue.take — il runtime lo smonta in modo che il carrier possa eseguire qualcos'altro.
A volte un virtual thread non può essere smontato e rimane agganciato al suo carrier. Questo si chiama pinning e vanifica lo scopo: un virtual thread bloccato ma appuntato tiene in ostaggio un carrier. Due situazioni lo causano:
| Causa del pinning | Perché accade | Soluzione |
|---|---|---|
All'interno di un blocco/metodo synchronized | Il monitor è legato al carrier | Sostituire con ReentrantLock |
| All'interno di una chiamata nativa (JNI) | Il runtime non può catturare lo stack nativo | Evitare blocchi nel codice nativo |
// Pins the carrier while sleeping — bad.
synchronized (lock) {
Thread.sleep(1000); // the virtual thread cannot unmount here
}
// Does not pin — good.
lock.lock();
try {
Thread.sleep(1000); // the virtual thread unmounts freely
} finally {
lock.unlock();
}Puoi diagnosticare il pinning eseguendo con -Djdk.tracePinnedThreads=full, che stampa uno stack trace ogni volta che un virtual thread appunta il suo carrier.
Structured concurrency
Creare thread in modo casuale comporta fughe: se un subtask fallisce, i suoi fratelli continuano ad essere eseguiti e bisogna ricordarsi di cancellarli. La structured concurrency (StructuredTaskScope, un'API in anteprima) fa sì che un gruppo di subtask si comporti come una singola unità di lavoro — vengono avviati insieme, attesi insieme e cancellati insieme. Quando lo scope padre termina, tutti i figli sono garantiti come completati.
import java.util.concurrent.StructuredTaskScope;
Response handle() throws Exception {
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
var user = scope.fork(() -> fetchUser()); // subtask 1
var order = scope.fork(() -> fetchOrder()); // subtask 2
scope.join(); // wait for both
scope.throwIfFailed(); // propagate the first failure, cancel the rest
return new Response(user.get(), order.get());
} // both subtasks are guaranteed finished or cancelled here
}ShutdownOnFailure cancella i subtask rimanenti nel momento in cui uno lancia un'eccezione; ShutdownOnSuccess ritorna non appena il primo subtask ha successo (utile per mettere in gara chiamate ridondanti). In entrambi i casi non ci sono thread orfani. Per l'API completa e altri pattern, consulta il capitolo sulla structured concurrency.
Esempio pratico: diecimila task concorrenti
Il programma seguente invia 10.000 task I/O-bound — ognuno dorme semplicemente 50 ms per simulare una chiamata di rete — a un executor virtual-thread-per-task. Conta quanti carrier thread distinti hanno effettivamente eseguito il lavoro e confronta il tempo di esecuzione reale con quello che si otterrebbe eseguendo gli stessi task uno dopo l'altro.
Cosa ricavare dall'esecuzione:
- 10.000 task vengono completati, eppure l'intera esecuzione termina in meno di un secondo — ben lontano dai ~500.000 ms che gli stessi sleep richiederebbero in esecuzione sequenziale, perché tutta l'attesa si sovrappone.
- Il numero di carrier thread è uguale al numero di core CPU (
Carrier threadscorrisponde adAvailable cores): migliaia di virtual thread sono multiplexati su quella piccola manciata di platform thread. Thread.sleepsmonta il virtual thread dal suo carrier, ed è esattamente per questo che così pochi carrier riescono a servire così tanti task contemporaneamente — il carrier non rimane mai inattivo ad aspettare.- La chiusura di
newVirtualThreadPerTaskExecutor()in un blocco try-with-resources blocca finché ogni task inviato non è terminato, quindi il conteggio completati raggiunge sempre 10.000 prima che venga stampata la misurazione del tempo. isVirtual()restituiscetrueeisDaemon()restituiscetrue— i virtual thread sono sempre thread daemon, quindi non tengono mai la JVM in vita da soli.
Quando usare i virtual thread (e quando no)
I virtual thread rappresentano un vantaggio quando i tuoi task trascorrono la maggior parte del tempo in attesa — sulla rete, un database, un file o un servizio downstream. Questa è la forma comune del lavoro lato server, quindi il consiglio tipico è semplice: usa un virtual thread per richiesta e scrivi codice bloccante normale.
Non rappresentano un'accelerazione per il lavoro CPU-bound. Un task che elabora numeri non si blocca mai, quindi non si smonta mai; eseguirne un milione aggiunge solo overhead di scheduling. Per il calcolo puro, dimensiona un pool in base al numero di core. Altre due cose da tenere a mente:
- Controlla i percorsi critici alla ricerca di blocchi
synchronizedche avvolgono chiamate bloccanti e migrarli aReentrantLockper evitare il pinning. - Non mettere in cache o in pool i virtual thread, e non fare affidamento sullo stato thread-local per limitare la concorrenza — usa un semaforo o un altro limitatore esplicito se devi controllare la velocità.