CodeGym /Corsi /JAVA 25 SELF /Scalabilità e prestazioni dei Virtual Threads

Scalabilità e prestazioni dei Virtual Threads

JAVA 25 SELF
Livello 57 , Lezione 2
Disponibile

1. Scalabilità

Perché i thread tradizionali scalano male?

Ogni thread classico (Thread) — è un'entità del sistema operativo con il proprio stack (di solito 12 MB) e una struttura di stato. Il tentativo di creare, per esempio, 10 000 thread tradizionali spesso porta all'errore OutOfMemoryError. Per questo, nei server tradizionali si usano pool di thread limitati.

Thread virtuali: la magia della scalabilità

I thread virtuali (Java 21+) sono thread «leggeri» gestiti dalla JVM. Il loro stack è conservato nell'heap e può crescere/ridursi dinamicamente. Quando un thread si blocca sull'I/O, la JVM lo «congela» e prosegue con altri task.

All'interno della JVM funziona un piccolo pool di «carrier» (carrier threads) — thread di piattaforma del SO sui quali, a turno, vengono eseguiti i thread virtuali. Ciò consente di creare 100 000+ task senza incubi di memoria. La JVM decide da sola quali thread virtuali eseguire e quando.

Dimostrazione: 100_000 thread virtuali contro 1_000 di piattaforma

Esempio: creazione di 1000 thread tradizionali

// Tentativo di creare 1000 thread tradizionali
List<Thread> threads = new ArrayList<>();
for (int i = 0; i < 1000; i++) {
    Thread t = new Thread(() -> {
        try {
            Thread.sleep(10_000);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
    });
    threads.add(t);
    t.start();
}
System.out.println("Thread creati: " + threads.size());

Risultato: Nella maggior parte dei sistemi si riuscirà a creare 1 0002 000 thread; oltre questi valori inizieranno problemi di memoria e rallentamenti.

Esempio: creazione di 100 000 thread virtuali

// Creiamo 100_000 thread virtuali
List<Thread> vThreads = new ArrayList<>();
for (int i = 0; i < 100_000; i++) {
    Thread t = Thread.ofVirtual().start(() -> {
        try {
            Thread.sleep(10_000);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
    });
    vThreads.add(t);
}
System.out.println("Thread virtuali creati: " + vThreads.size());

Risultato: Il programma crea senza problemi 100 000 thread virtuali senza crash e senza rallentamenti significativi. La memoria richiesta è molte volte inferiore.

Confronto visivo

Tipo di thread Numero massimo di thread (circa) Uso di memoria Tempo di avvio
Tradizionali (Thread) 1 00010 000 Alto Lungo
Virtuali 100 0001 000 000+ Basso Istantaneo

Fatto: I thread virtuali consentono di scrivere codice «un thread per task» senza pool complessi e senza rischiare di sovraccaricare il sistema.

2. Prestazioni: dove i thread virtuali danno il meglio

Task che sono limitati dall'I/O (I/O-bound)

I thread virtuali sono perfetti per le richieste di rete, l'I/O su file e il lavoro con i database. Quando un'operazione si blocca, il thread virtuale libera il «carrier» e la JVM esegue altri task. Questo aumenta il throughput con un alto numero di attese simultanee.

Esempio: simulazione di 10 000 richieste HTTP contemporanee

import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;

HttpClient client = HttpClient.newHttpClient();
List<Thread> threads = new ArrayList<>();

for (int i = 0; i < 10_000; i++) {
    Thread t = Thread.ofVirtual().start(() -> {
        try {
            HttpRequest request = HttpRequest.newBuilder()
                .uri(URI.create("https://example.com"))
                .build();
            HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
            System.out.println("Risposta: " + response.statusCode());
        } catch (Exception e) {
            System.out.println("Errore: " + e.getMessage());
        }
    });
    threads.add(t);
}

// Attendiamo il completamento di tutti i thread
for (Thread t : threads) {
    t.join();
}

Risultato: Tutte le 10 000 richieste vengono eseguite in parallelo; il programma non va in crash e il codice resta semplice.

CPU-bound: i thread virtuali non accelerano i calcoli

Se un task è gravoso per la CPU, i thread virtuali non aggiungono velocità: il numero di core è fisso. Qui sono opportuni pool fissi pari al numero di core, per non creare competizione inutile.

// Ogni task calcola la somma di un grande intervallo
Runnable cpuTask = () -> {
    long sum = 0;
    for (int i = 0; i < 100_000_000; i++) {
        sum += i;
    }
    System.out.println("Somma: " + sum);
};

// Avviamo 1000 thread virtuali con calcoli
for (int i = 0; i < 1000; i++) {
    Thread.ofVirtual().start(cpuTask);
}

Risultato: I thread competono per la CPU, ma non ci sarà alcuna accelerazione — non è un caso d'uso per Virtual Threads.

3. Limitazioni e particolarità

Sincronizzazione e trappole dei thread virtuali

  • Attenzione ai lock nativi. L'uso di synchronized può «incollare» un thread virtuale al carrier, riducendo i vantaggi. Preferire ReentrantLock, Semaphore e altri primitivi di java.util.concurrent, ottimizzati per i thread virtuali.
  • Librerie datate. Alcuni driver JDBC e librerie native non sono ancora ottimizzati per Virtual Threads. Testate accuratamente le operazioni bloccanti.

Non per task di lunga durata

Virtual Threads sono ideali per unità di lavoro «brevi»: gestione della richiesta, un'operazione e fine. Un milione di task che vivono all'infinito (per esempio calcoli perpetui) non porta benefici — per quelli usate thread di piattaforma.

4. Buone pratiche: dove utilizzare i thread virtuali

  • Task I/O-bound: chiamate di rete, file, DB — tutto ciò in cui il thread attende spesso.
  • Server web: gestite ogni richiesta HTTP in un thread virtuale separato.
  • Test di integrazione: simulate rapidamente migliaia di client.
  • Elaborazione asincrona: scrivete il solito codice «bloccante» — la JVM esegue uno scheduling intelligente.

Da non usare:

  • Per task che saturano costantemente la CPU.
  • Quando è critica la compatibilità con librerie di basso livello (non tutte sono ancora adattate).

Sotto il cofano è comodo usare l'executor: Executors.newVirtualThreadPerTaskExecutor() — «un thread virtuale per task», senza pool fisso.

5. Monitoraggio e misurazioni: come vedere i thread virtuali in azione

JVisualVM e Flight Recorder

JVisualVM mostra i thread attivi, i loro stati e la memoria; da Java 21 i thread virtuali sono visualizzati separatamente. Java Flight Recorder (JFR) registra una dettagliata «scatola nera» dell'esecuzione, inclusa la statistica sui Virtual Threads — utile per trovare i colli di bottiglia.

Come vedere il numero di thread nel codice

Un modo semplice per vedere il numero di thread nella JVM:

System.out.println("Thread totali: " + Thread.activeCount());

Per contare quanti di essi sono virtuali:

long vCount = Thread.getAllStackTraces().keySet().stream()
    .filter(Thread::isVirtual)
    .count();
System.out.println("Thread virtuali: " + vCount);

6. Errori tipici nell'uso dei thread virtuali

Errore n. 1: usare i thread virtuali per calcoli pesanti. Avviare milioni di thread virtuali con task CPU-bound non velocizzerà il processore. I Virtual Threads non sono un «turbo» per i calcoli.

Errore n. 2: copiare ciecamente i vecchi pattern. Non create pool fissi di thread virtuali. Usate Executors.newVirtualThreadPerTaskExecutor() e lasciate che la JVM si scali automaticamente.

Errore n. 3: utilizzare librerie non supportate. Lock nativi e librerie non adattate a Loom possono causare freeze e cali di prestazioni. Verificate la compatibilità in anticipo.

Errore n. 4: ottimizzazione prematura. Se avete pochi thread e un multithreading tradizionale — non affrettatevi a portare tutto sui Virtual Threads. Lo strumento è utile dove c'è molto I/O e attesa.

Errore n. 5: ignorare il monitoraggio. Creare un milione di task è facile, ma senza monitoraggio e gestione delle eccezioni si rischia di ottenere un «benchmark carino» invece di un sistema affidabile. Usate JVisualVM e JFR.

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