1. Thread classici: come funziona e dove fa male
Ricordiamo prima come funzionano i thread normali in Java (detti anche di piattaforma o nativi). Quelli creati tramite new Thread(...).
Quando chiamate new Thread(() -> { ... }).start();, la JVM non lancia semplicemente un pezzo di codice. Chiede al sistema operativo di creare un vero thread di esecuzione. Il sistema operativo gli assegna uno stack separato (di solito alcuni megabyte) e riserva altre risorse di servizio.
Questo thread vive finché il suo compito è in esecuzione e per tutto questo tempo occupa un posto nella tabella dei thread del sistema operativo. Più thread di questo tipo ci sono, più memoria va ai loro stack e maggiore è il carico sul sistema operativo. Ecco perché, con un gran numero di thread in esecuzione simultaneamente, l’applicazione può iniziare a «soffocare» — il sistema spende troppo tempo a commutare tra di loro.
Esempio: thread classico
Thread thread = new Thread(() -> {
System.out.println("Ciao dal thread!");
});
thread.start();
Sembra semplice, vero? Ma provate a crearne non uno o due, bensì, diciamo, diecimila — e il vostro programma inizierà rapidamente a soffocare. Finirà la memoria oppure il sistema dirà che è stato raggiunto il limite di thread. Non è un bug di Java, ma una conseguenza naturale dell’architettura: i thread sono oggetti pesanti e costosi.
Perché succede? Ogni thread riceve il proprio stack (di solito 1–2 megabyte), più un intero set di strutture di servizio dal sistema operativo. Inoltre, il sistema operativo non è felice quando gli si presentano decine di migliaia di thread — ha dei limiti, spesso piuttosto rigidi.
E anche se la memoria non finisce, nasce un altro problema — la commutazione di contesto. Quando i thread sono troppi, il sistema salta continuamente tra di loro, salvando e ripristinando il loro stato. Tutto ciò richiede tempo e riduce le prestazioni, così il «massiccio parallelismo» non porta il guadagno atteso.
Il problema «un thread — una richiesta»
Nelle vecchie applicazioni server (per esempio su Tomcat o Jetty) si usava spesso il modello «thread‑per‑request»: per ogni richiesta in arrivo si assegnava un thread separato. È comodo, ma se avete 10_000 utenti, vi servono 10_000 thread! Il server fatica, e inizia la corsa alla memoria, non alla velocità di elaborazione.
Risultato:
I thread classici sono ottimi per un numero ridotto di attività parallele, ma non si scalano a decine o centinaia di migliaia.
2. Cosa sono i thread virtuali (Virtual Threads)?
Qui entrano in gioco i thread virtuali. Non sono semplicemente «un altro thread», ma un’idea architetturale completamente diversa.
I thread virtuali sono thread gestiti non dal sistema operativo, ma dalla stessa JVM. Sono implementati interamente dentro Java e possono essere creati in quantità enormi (decine e centinaia di migliaia) senza «gonfiare» la memoria e senza rallentamenti.
In breve:
- Platform Thread (thread di piattaforma): un thread normale che corrisponde direttamente a un thread del sistema operativo.
- Virtual Thread (thread virtuale): un thread leggero gestito dalla JVM, non dal sistema operativo.
Come funziona?
I thread virtuali sono thread «leggeri» che vivono non nel sistema operativo, ma dentro la JVM. Lavorano sopra un piccolo pool di thread reali, chiamati platform threads. Si può immaginare che la JVM li gestisca come un direttore d’orchestra: ha un numero limitato di musicisti (thread reali), ma distribuisce abilmente tra loro le parti (thread virtuali).
Schema architetturale:
+-------------------+ +-------------------+
| Virtual Thread 1 |---\ | Platform Thread |
| Virtual Thread 2 |---->====> | (Carrier Thread) |
| Virtual Thread 3 |---/ +-------------------+
... (Sistema operativo)
Un Carrier Thread è un normale thread del sistema operativo su cui la JVM esegue molti thread virtuali. Se un thread virtuale si blocca all’improvviso — per esempio, attende dati dal disco o dalla rete — la JVM lo «congela», liberando il carrier thread per altri compiti.
Perché è rivoluzionario?
Perché ora si può scrivere codice abituale e lineare — senza callback infiniti, CompletableFuture e catene «infernali» di thenApply — e allo stesso tempo scalare l’applicazione a migliaia di operazioni simultanee.
I thread virtuali occupano solo decine di kilobyte (invece dei megabyte dei thread normali) e si creano praticamente all’istante. Perciò li si può avviare e distruggere a migliaia, senza temere che il sistema operativo crolli sotto il loro peso. Questo rende finalmente la programmazione concorrente in Java facile e naturale.
3. Vantaggi dei thread virtuali
Scalabilità
Con i thread virtuali potete permettervi il lusso di avviare decine e centinaia di migliaia di attività parallele. Per esempio, elaborare ogni richiesta di rete in un thread separato — senza preoccuparvi che il server «scoppi».
Dimostrazione: 100 000 thread virtuali
for (int i = 0; i < 100_000; i++) {
Thread.ofVirtual().start(() -> {
// Qui può esserci qualsiasi logica
try {
Thread.sleep(1000); // Simulazione di lavoro
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});
}
System.out.println("Tutti i thread sono stati avviati!");
Questo codice gira tranquillamente su un normale laptop!
Provate a fare lo stesso con i thread classici — e vedrete OutOfMemoryError o trasformerete il computer in un «mattone».
Semplicità di programmazione
I thread virtuali permettono di scrivere il consueto codice «bloccante», senza trasformarlo in «spaghetti» di chiamate asincrone. Per esempio, potete usare tranquillamente Thread.sleep, InputStream.read, Socket.accept — la JVM si occuperà di non bloccare l’intero carrier thread.
Migliore leggibilità e manutenzione del codice
Invece di schemi complessi con callback e CompletableFuture scrivete codice lineare e comprensibile. Questo riduce i bug e facilita la manutenzione.
Niente reinvenzione della ruota
In passato, per elaborare migliaia di richieste in parallelo, bisognava usare framework asincroni, librerie reattive (Netty, Vert.x, Project Reactor), che richiedono uno stile di programmazione particolare. Ora si può farne a meno — e ottenere comunque scalabilità.
4. Architettura: come funzionano i thread virtuali «sotto il cofano»
Mapping sui carrier threads
La JVM crea un piccolo pool di thread reali (carrier threads) — di solito quanti sono i core del processore. Tutti i thread virtuali «viaggiano» su questi carrier threads, come passeggeri sugli autobus.
- Quando un thread virtuale si blocca (per esempio, attende una risposta dalla rete), la JVM lo «scarica» dal carrier thread e lo mette in coda.
- Non appena il thread può riprendere, la JVM lo «rimette» su un carrier thread libero.
Analogia:
Immaginate di avere 4 taxi (carrier threads) e di servire 10 000 clienti (virtual threads). Appena un cliente arriva a destinazione e scende, il taxi ne prende subito un altro. Nessuno resta fermo, e i taxi non «si rompono» sotto il peso dei passeggeri.
Pianificazione e commutazione
La JVM decide autonomamente quale thread virtuale eseguire in un dato momento. Se un thread si blocca su I/O, non impedisce agli altri di lavorare.
5. Limitazioni e particolarità dei thread virtuali
Non è tutto oro ciò che è virtuale
Non per calcoli prolungati: Se avete un compito che occupa costantemente la CPU (heavy CPU‑bound), il thread virtuale non darà un incremento di prestazioni. Perché i carrier threads restano comunque limitati dal numero di core.
Alcuni lock non sono efficienti: I vecchi meccanismi di sincronizzazione (per esempio la sincronizzazione con synchronized su oggetti con mutex nativi) possono impedire alla JVM di «congelare» il thread virtuale. In tali casi il carrier thread attenderà insieme al thread virtuale, riducendo la scalabilità.
Non tutte le librerie vanno d’accordo con i thread virtuali: Se una libreria effettua chiamate native o usa lock specifici, i thread virtuali possono comportarsi in modo inatteso.
Esempio: quando non conviene usare i Virtual Threads
Se avete un compito che gira in un ciclo infinito e fa calcoli, il thread virtuale non darà alcun vantaggio. Ci si scontra comunque con il numero di core.
Thread.ofVirtual().start(() -> {
while (true) {
// Conteggio all'infinito
}
});
Risultato:
Un carrier thread sarà occupato da questo thread virtuale, e le altre attività attenderanno il loro turno.
6. Confronto: Platform Thread vs Virtual Thread
| Caratteristica | Platform Thread (classico) | Virtual Thread (virtuale) |
|---|---|---|
| Gestito da | dal sistema operativo | dalla JVM |
| Memoria per thread | Megabyte | Decine di kilobyte |
| Numero di thread | Di solito < 10_000 | Migliaia, centinaia di migliaia |
| Costo di creazione | Costoso | Economico |
| Scalabilità | Limitata | Quasi illimitata |
| Adatto a | Compiti lunghi, CPU‑bound | Compiti brevi, I/O‑bound |
| Commutazione dei thread | SO | JVM |
| Compatibilità | 100% | Quasi sempre, ma con alcune sfumature |
7. Esempio: come apparirebbe un server prima e dopo i Virtual Threads
Prima (Platform Threads)
ServerSocket serverSocket = new ServerSocket(8080);
while (true) {
Socket client = serverSocket.accept();
new Thread(() -> handleClient(client)).start();
}
Problema:
Dopo 5 000 connessioni il server inizierà ad «annaspare».
Dopo (Virtual Threads, Java 21+)
ServerSocket serverSocket = new ServerSocket(8080);
while (true) {
Socket client = serverSocket.accept();
Thread.ofVirtual().start(() -> handleClient(client));
}
Magia:
Ora è possibile gestire decine di migliaia di connessioni — senza pensare ai limiti dei thread!
9. Errori tipici nel passaggio ai thread virtuali
Errore n. 1: aspettarsi un’accelerazione per i compiti computazionali. I thread virtuali non accelerano i compiti che saturano completamente la CPU. Per tali compiti si è comunque limitati dal numero di core.
Errore n. 2: utilizzare vecchie sincronizzazioni bloccanti. Se usate vecchi lock (per esempio synchronized su oggetti che possono essere «bloccati» nativamente), i thread virtuali possono non essere scaricati dal carrier thread, perdendo tutti i vantaggi.
Errore n. 3: ignorare il comportamento delle librerie di terze parti. Alcune librerie di terze parti potrebbero non essere pronte a lavorare con i thread virtuali (per esempio se usano JNI o lock nativi).
Errore n. 4: aspettarsi una crescita magica delle prestazioni. I thread virtuali non sono una panacea. Non accelerano tutto, ma rendono il parallelismo economico e comodo per i compiti I/O‑bound.
GO TO FULL VERSION