1. In breve, l'essenziale
Hai già un po' di familiarità con le classi del package java.util.concurrent, in particolare con ExecutorService. È una sorta di «gestore di task»: gli invii del lavoro (ad esempio tramite submit()) e lui decide autonomamente quando e con quale thread eseguirlo. Di solito sotto il cofano gira un pool di thread a dimensione fissa, che risparmia risorse e non crea un nuovo thread per ogni task.
Con i thread virtuali, però, cambia tutto! Ora ci si può permettere il lusso: un thread separato per ogni task, senza temere che la JVM esploda per indigestione.
Nuovo approccio: Executors.newVirtualThreadPerTaskExecutor()
In Java 21 è apparso un nuovo modo per creare un ExecutorService che esegue ogni task in un thread virtuale separato:
ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
Differenza importante:
- I vecchi pool di thread (Executors.newFixedThreadPool, Executors.newCachedThreadPool) limitavano il numero di task simultanei a causa dell’elevato costo dei thread del sistema operativo.
- Il nuovo Executor virtuale è quasi illimitato: un thread virtuale leggero per ogni task.
Esempio semplice
Inviamo 10 task all'Executor virtuale:
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public class VirtualExecutorDemo {
public static void main(String[] args) {
ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
for (int i = 1; i <= 10; i++) {
int taskId = i; // catturiamo la variabile per la lambda
executor.submit(() -> {
System.out.println("Task " + taskId + " is running in thread: " +
Thread.currentThread());
});
}
executor.shutdown();
}
}
Cosa succede?
Ogni task verrà eseguito nel proprio thread virtuale e vedrai righe del tipo:
Task 1 is running in thread: VirtualThread[#24]/runnable@ForkJoinPool-1-worker-1
...
2. Parallelismo di massa: migliaia di task — nessun problema!
Per percepire tutta la potenza dei thread virtuali, proviamo a inviare a ExecutorService non 10 ma, diciamo, 100_000 task. Nei pool classici sarebbe come cercare di infilare un elefante nel frigorifero: la JVM esaurirebbe rapidamente la memoria o rallenterebbe terribilmente. Con i thread virtuali — è tutta un’altra storia!
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public class VirtualExecutorMassiveDemo {
public static void main(String[] args) {
ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
for (int i = 1; i <= 100_000; i++) {
int taskId = i;
executor.submit(() -> {
// Per esempio — dormiamo solo 1 ms
try {
Thread.sleep(1);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
// System.out.println("Task " + taskId + " done."); // Non stampiamo, altrimenti ci saranno troppe righe!
});
}
executor.shutdown();
}
}
Attenzione: stampare 100_000 righe a schermo è una cattiva idea: la console «annegherà» più in fretta dei thread virtuali. Meglio non scrivere in console oppure stampare solo i primi pochi task.
3. Come funziona newVirtualThreadPerTaskExecutor
In breve: questo ExecutorService crea un nuovo thread virtuale per ogni task che gli invii. A differenza di un pool fisso, qui non ci sono code di task né rigidi limiti sul numero di thread simultanei (salvo i limiti della tua JVM e dell’hardware).
Architetturalmente:
- I thread virtuali sono mappati su un piccolo pool di thread reali del sistema operativo (carrier).
- La JVM decide autonomamente quando avviare, sospendere e riprendere ciascun thread virtuale.
- Se un thread si blocca (ad esempio su lettura file o attesa di rete), la JVM può «congelare» il thread virtuale e liberare il thread carrier per altri task.
4. Esempio: gestione dei risultati con Future
ExecutorService restituisce un oggetto di tipo Future, se il task restituisce un risultato. Funziona esattamente come con i thread tradizionali:
import java.util.concurrent.*;
public class VirtualExecutorWithResult {
public static void main(String[] args) throws InterruptedException, ExecutionException {
ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
Future<String> future = executor.submit(() -> {
Thread.sleep(500);
return "Hello from virtual thread!";
});
System.out.println("Result: " + future.get()); // Attendiamo il risultato
executor.shutdown();
}
}
Tutto come al solito: puoi inviare task con valore di ritorno, attendere il risultato tramite get(), e le eccezioni vengono gestite in modo standard.
5. Come terminare correttamente l'Executor
È molto importante non dimenticare di terminare ExecutorService, per evitare che il programma rimanga appeso (anche se i thread sono virtuali e non «reali»).
shutdown() e awaitTermination
executor.shutdown(); // Diciamo: non si accettano più task
executor.awaitTermination(1, TimeUnit.MINUTES); // Attendiamo il completamento di tutti i task (massimo 1 minuto)
Perché è importante?
Se non chiami shutdown(), i thread virtuali potrebbero continuare a vivere e il programma non terminerà nemmeno dopo l’esecuzione di main(). È un errore tipico dei principianti.
6. Dettagli utili
Confronto: Executor virtuale vs pool di thread classico
| Pool classico (newFixedThreadPool) | Executor virtuale (newVirtualThreadPerTaskExecutor) | |
|---|---|---|
| Numero di thread | Limitato dalla dimensione del pool | Un thread virtuale per task, quasi illimitato |
| Task in coda | Sì, se tutti i thread sono occupati | In genere no: il task ottiene subito un thread |
| Costo del thread | Elevato (stack, risorse dell’OS) | Molto basso (scheduling a carico della JVM) |
| Scalabilità | Limitata | Quasi illimitata |
| Per cosa è adatto | Task CPU-bound, parallelismo limitato | Task I/O-bound, parallelismo massivo |
Integrazione con i server web
I moderni server web (ad esempio Tomcat, Jetty, Undertow) stanno iniziando a supportare i thread virtuali. Questo significa che è possibile gestire ogni richiesta HTTP in un thread virtuale separato, senza il rischio di «annegare» con l’afflusso degli utenti.
Vantaggio: non serve inventare complicati schemi asincroni con callback e CompletableFuture; il codice diventa più semplice — puoi scrivere il consueto codice bloccante, ma l’applicazione scala comunque.
Test di massa e simulazione del carico
I thread virtuali sono perfetti per test che devono «simulare» migliaia di utenti, richieste o operazioni contemporanee. Per esempio, un test che invia 10_000 richieste parallele a un server, ciascuna nel proprio thread virtuale.
Elaborazione parallela di file e connessioni di rete
Se l’applicazione lavora con un gran numero di file o connessioni di rete, puoi gestire ogni connessione in un thread virtuale separato, senza preoccuparti della gestione manuale dei pool.
7. Errori tipici nell'uso degli Executor virtuali
Errore n. 1: hai dimenticato di chiamare shutdown(). Se non chiudi l’Executor, il programma non terminerà — i thread virtuali staranno ancora aspettando nuovi task. Se necessario, aggiungi awaitTermination(...).
Errore n. 2: usare i thread virtuali per calcoli pesanti. I thread virtuali non velocizzano i task che saturano la CPU. Per i carichi CPU-bound è meglio usare un pool fisso (Executors.newFixedThreadPool) e scegliere con cura la dimensione.
Errore n. 3: ignorare le eccezioni all'interno dei task. Se un task lancia un’eccezione, questa non arriverà al thread principale — gestiscila tramite Future (metodo get()) oppure con try/catch dentro la lambda.
Errore n. 4: confondere sintassi/versione della JDK. Verifica di usare una versione JDK corretta (Java 21+) e che l’IDE sia configurata per supportare i thread virtuali. Il metodo specifico è Executors.newVirtualThreadPerTaskExecutor().
Errore n. 5: fare affidamento su ThreadLocal per passare il contesto. I thread virtuali spesso vengono creati e distrutti; ThreadLocal può comportarsi in modo inatteso. Per passare il contesto usa ScopedValue (Scoped Values; maggiori dettagli — nella prossima lezione).
GO TO FULL VERSION