Thread Virtuali in Java
Thread leggeri pianificati dalla JVM (Java 21+) per applicazioni concorrenti ad alto throughput — cosa risolvono e come cambiano il dimensionamento dei pool.
Ogni capitolo di questa parte del libro fino ad ora ha descritto un platform thread — un Thread Java che si mappa uno a uno su un thread del sistema operativo. I platform thread sono potenti ma costosi: ognuno occupa circa 1 MB di stack nativo e il SO limita un processo a decine di migliaia di essi. Per il lavoro CPU-bound è sufficiente. Per il lavoro I/O-bound — un server web con un thread per richiesta che aspetta principalmente un database — è un limite invalicabile che ha rappresentato la tensione centrale nel design dei server Java per due decenni.
Java 21 ha introdotto i thread virtuali per risolvere esattamente questo caso. Un thread virtuale è un Thread Java pianificato dalla JVM (non dal SO) su un piccolo pool di thread carrier a livello di OS. Sono economici — milioni per JVM sono la norma — e il blocco su I/O parcheggia il thread virtuale senza parcheggiare il carrier. Il codice appare uguale a prima; il modello di costo è diverso.
Cosa cambia (e cosa no)
I thread virtuali sono java.lang.Thread. La classe è la stessa; i metodi sono gli stessi; Thread.currentThread() funziona ancora. Ciò che è diverso è come vengono pianificati e quanto costano:
- Un platform thread costa circa 1 MB di stack nativo ed è pianificato dal SO.
- Un thread virtuale costa circa 1 KB inizialmente (cresce secondo necessità) ed è pianificato dalla JVM.
- Bloccare un platform thread blocca il thread OS sottostante.
- Bloccare un thread virtuale parcheggia il thread virtuale; il thread OS carrier va a eseguire un thread virtuale diverso.
Questo quarto punto è il punto fondamentale. Quando un thread virtuale chiama Socket.read(), Thread.sleep(), BlockingQueue.take(), Lock.lock(), o qualsiasi altra API JDK bloccante, la JVM lo sgancia dal suo carrier e il carrier raccoglie un altro thread virtuale da eseguire. Il thread virtuale bloccato non consuma quasi nulla mentre attende.
Creare thread virtuali
Tre modi:
// 1. Direct
Thread t = Thread.ofVirtual().start(() -> doWork());
// 2. Builder
Thread t2 = Thread.ofVirtual().name("vt-", 0).start(this::work); // names "vt-0", "vt-1", ...
// 3. Executor — the production form
try (ExecutorService es = Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 0; i < 10_000; i++) {
es.submit(() -> handleRequest());
}
}La forma con executor è quella utilizzata da quasi ogni server. Assegna un thread virtuale per ogni task sottomesso; non c'è nessun pool da dimensionare perché il pool carrier si dimensiona da solo.
Puoi anche ottenere un platform thread quando ne hai specificamente bisogno:
Thread t = Thread.ofPlatform().name("compute").start(() -> doCpu());Utile per lavoro genuinamente CPU-bound, dove la mappatura uno a uno con il SO è quello che vuoi.
Quando i thread virtuali vincono
La forma per cui i thread virtuali sono ottimizzati:
- Molti task concorrenti (centinaia, migliaia, milioni).
- Ogni task trascorre la maggior parte del tempo bloccato su I/O, code o lock.
- Il lavoro non è dominato dalla CPU.
Questa è esattamente la forma del server web: ogni richiesta è un task che aspetta principalmente un database, un servizio upstream o il client. Con i platform thread, un server con 1000 richieste lente concorrenti necessita di 1000 platform thread — 1 GB di stack nativo e un carico significativo sullo scheduler del SO. Con i thread virtuali, lo stesso carico di lavoro gira su 8 circa carrier; i 1000 thread virtuali costano pochi MB in totale.
Il modello mentale: smetti di pensare ai pool di thread per il lavoro I/O. Sottometti un thread virtuale per richiesta e lascia che il runtime gestisca il resto.
Quando i thread virtuali non vincono
Alcuni casi in cui non aiutano o addirittura peggiorano le cose:
- Lavoro CPU-bound. Un thread virtuale che fa puro calcolo non può essere parcheggiato — deve girare su un carrier per tutto il tempo. Non sarà più veloce del numero di carrier, che è il numero di CPU. Per il lavoro CPU, i platform thread (e fork/join) rimangono lo strumento giusto.
- Blocchi
synchronizedattorno a I/O. Un thread virtuale dentrosynchronized (obj) { blockingIO(); }si fissa al suo carrier — la JVM non può smontarlo durante la chiamata bloccante perché il monitor è legato al thread OS. Questo è un vero rischio: un server che usasynchronizedper proteggere una chiamata al database non scala con i thread virtuali. La soluzione è usareReentrantLockinvece (che la macchina dei thread virtuali gestisce correttamente). - Archiviazione
ThreadLocalcon molti thread. I thread virtuali supportanoThreadLocal, ma il conteggio può esplodere — milioni di thread virtuali × N thread-local × dimensione del valore = molta memoria. Java 21 ha aggiunto i scoped value (ScopedValue) come alternativa strutturata. - Codice che presuppone che un thread sia raro (ad es. che costruisce una connessione per thread). Una connessione per thread virtuale è una connessione per richiesta, che il database detesta. Usa un vero connection pool.
Il riassunto: i thread virtuali rendono economica la concorrenza I/O-bound, ma non trasformano il lavoro CPU-bound ed espongono percorsi di codice che si fissano ai carrier.
Pinning: l'unica cosa da tenere d'occhio
Un thread virtuale fissato non può essere smontato. Le due cause di pinning:
- Blocchi
synchronizedche includono una chiamata bloccante. - Chiamate a metodi nativi che bloccano in JNI.
Puoi rilevare il pinning tramite la proprietà di sistema:
java -Djdk.tracePinnedThreads=full ...Se un thread virtuale si blocca mentre è fissato, la JVM stampa uno stack trace. In produzione, la soluzione è sostituire synchronized con ReentrantLock attorno alla regione bloccante. I futuri JDK stanno lavorando per rimuovere il pinning da synchronized (JEP 491 in corso); per ora, considera qualsiasi synchronized attorno a una chiamata I/O come un anti-pattern dei thread virtuali.
E wait, notify e join?
Tutti funzionano — i thread virtuali possono aspettare su monitor intrinseci, essere notificati ed essere sottoposti a join. Il runtime gestisce il parcheggio e lo smontaggio nel modo corretto. Il vincolo riguarda solo i blocchi sincronizzati: tenere il monitor attraverso una chiamata bloccante dentro il blocco fissa; chiamare wait() per rilasciare il monitor e parcheggiare va bene.
synchronized (lock) {
lock.wait(); // OK — releases monitor, parks, no pin
}
synchronized (lock) {
socket.read(buf); // BAD — holds monitor through blocking read; pins
}Dimensionare il pool — non c'è nessun pool
Il cambiamento concettuale che i thread virtuali abilitano: smettila di dimensionare. Ogni executor che hai configurato in questo libro aveva una manopola per il conteggio dei thread. Con newVirtualThreadPerTaskExecutor, il conteggio è "quante richieste sono in volo". Il pool carrier (che non configuri direttamente) si dimensiona da solo in base al numero di CPU; i thread virtuali sono solo un meccanismo di contabilità.
In un server che usa thread virtuali:
- I connection pool contano ancora. Un thread virtuale che aspetta una connessione va bene; avviarne 10.000 che vogliono tutti un pool da 5 connessioni rende solo visibile il collo di bottiglia.
- I rate limit contano ancora. I thread virtuali rimuovono il limite dei thread, non il limite del servizio downstream.
- La memoria conta ancora. Ogni thread virtuale ha uno stack e degli eventuali
ThreadLocal. Milioni di essi sono milioni di stack.
I thread virtuali rimuovono il soffitto del conteggio dei thread; non rimuovono i vincoli sottostanti che quel soffitto nascondeva.
Un esempio pratico: un milione di thread virtuali contro un platform thread
Il programma seguente mette in sleep 100.000 task per 200 ms ciascuno, in parallelo. Con i platform thread (limitati a un numero ragionevole) ci vuole molto tempo e usa molta RAM. Con i thread virtuali finisce in appena più del sleep del singolo task.
Cosa trarre dall'esecuzione:
- I 100.000 thread virtuali hanno terminato in circa un secondo di tempo reale — vicino al singolo sleep di 200 ms più l'overhead di creazione e pianificazione di 100.000 thread, non 100.000 × 200 ms. Questo è l'intero punto dei thread virtuali: la concorrenza (quante cose sono in volo) è disaccoppiata dal parallelismo (quanti core stanno eseguendo lavoro CPU). Il numero esatto varia per macchina, ma rimane nello stesso intervallo di pochi secondi indipendentemente da quanto si alza il conteggio dei task.
- L'esecuzione con 5.000 task e il pool di platform thread con 100 worker ha richiesto circa
5000 / 100 * 200 = ~10 secondi— i task si sono accodati perché il pool poteva eseguirne solo 100 alla volta. Per finire nello stesso tempo reale della versione con thread virtuali, il pool di platform thread avrebbe avuto bisogno di 100.000 thread, che è vicino o oltre il limite del SO sulla maggior parte dei sistemi. Thread.currentThread().isVirtual()ha distinto i due tipi di thread a runtime. Anche i nomi differiscono — i thread virtuali tipicamente hanno una rappresentazione generica anziché un nome impostato dall'utente, a meno che non ne imposti uno tramite il builder. Utile per il logging quando si mescolano i due tipi.- L'avvertimento sul pinning è l'unica avvertenza più importante per i thread virtuali in produzione. Un blocco
synchronizedattorno a qualsiasi chiamata bloccante (I/O database, I/O file, rete) vanifica la maggior parte del beneficio perché il carrier non può essere rilasciato durante l'attesa. SostituiresynchronizedconReentrantLockmantiene il thread virtuale parcheggiabile. - La forma
try (ExecutorService vexec = ...)ha fatto la cosa giusta alla chiusura — ha eseguitoshutdown()e atteso il completamento di ogni task sottomesso. Con 100.000 task in volo quell'attesa era reale (200 ms ciascuno, tutti parcheggiati insieme, tutti completati quasi contemporaneamente). Senza il try-with-resources, l'executor sarebbe rimasto vivo mantenendo thread non-daemon e il programma si sarebbe bloccato.
Fine della parte 15
Questo è l'ultimo capitolo della parte Multithreading and Concurrency. Siamo partiti da "un thread è una cosa a livello di OS" attraverso i lock, gli atomici e le collection concorrenti che si usano per rendere corretto lo stato condiviso, nell'executor framework che nasconde la gestione dei thread, poi CompletableFuture e ForkJoinPool per la composizione, e infine i thread virtuali per il carico di lavoro I/O-heavy che i server moderni affrontano realmente.
Il pattern in tutto ciò: scegli il tool più piccolo che risolve il tuo problema specifico. Un contatore? AtomicInteger. Un flag? volatile. Un producer/consumer? BlockingQueue. Molte chiamate I/O parallele? Thread virtuali. La keyword synchronized è ancora giusta quando lo è; Lock è per quando non lo è; gli executor e i future di alto livello sono per quando hai superato entrambi. Scendi nello stack solo quando l'astrazione sopra non fa quello di cui hai bisogno.
La prossima parte del libro è Annotations — cosa fanno effettivamente i marcatori @ attaccati a classi, metodi e campi, quelli integrati in java.lang, e le regole per scriverne di proprie.