CodeGym /Corsi /JAVA 25 SELF /Utilizzo di Executor con thread virtuali

Utilizzo di Executor con thread virtuali

JAVA 25 SELF
Livello 57 , Lezione 3
Disponibile

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).

Commenti
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION