W3docs

Java Virtual Threads in Profondità

Un'analisi approfondita dei virtual thread Java: pinning, scheduling e come migrare il codice dai platform thread.

I platform thread — l'unico tipo disponibile in Java fino a JDK 21 — mappano uno a uno sui thread del sistema operativo. Sono costosi: ognuno riserva circa un megabyte di stack e il pianificatore OS può gestirne solo alcune migliaia prima che il context-switching consumi la CPU. I virtual thread, introdotti dal Project Loom, abbattono questo limite. Sono thread leggeri gestiti dalla JVM, non dall'OS, quindi un singolo programma può eseguirne milioni. Questo capitolo va oltre l'introduzione: come vengono pianificati, cos'è il pinning, come la structured concurrency collega i loro cicli di vita e dove conviene usarli (e dove no).

Se sei nuovo all'argomento, leggi prima l'introduzione ai virtual thread; questo capitolo presuppone che tu sappia già come avviarne uno. Sarà utile anche una conoscenza di base del multithreading in Java e dell'executor framework.

Platform thread vs. virtual thread

Un virtual thread è comunque un java.lang.Thread — la stessa API, la stessa Runnable. La differenza sta in ciò che lo supporta. Un platform thread è un thread OS per tutta la sua vita. Un virtual thread viene eseguito su un piccolo pool di platform thread detti carrier: quando si blocca su I/O, la JVM lo smonta dal suo carrier, libera quel carrier per un altro virtual thread, e rimonta il virtual thread quando l'I/O si completa. Bloccare un virtual thread è economico; bloccare un platform thread spreca una risorsa scarsa.

AspettoPlatform threadVirtual thread
Supportato daUn thread OSUn carrier thread del pool
Costo in memoria~1 MB di stack fissoPoche centinaia di byte, cresce su richiesta
Numero praticoMigliaiaMilioni
Ideale perLavoro CPU-boundLavoro I/O-bound, alta concorrenza
Quando si bloccaSpreca il thread OSSi smonta; il carrier viene riutilizzato
Ciclo di vitaPooling e riusoUno per task, usa e getta

Il modello mentale si ribalta. Con i platform thread si dimensiona con cura un pool e si riutilizzano i thread. Con i virtual thread se ne crea uno per task e lo si lascia terminare — sono abbastanza economici da essere usa e getta.

Creare virtual thread

Esistono tre modi idiomatici. Per un singolo task si usa il builder Thread.ofVirtual() o la scorciatoia Thread.startVirtualThread; per molti task si usa un executor virtual-thread-per-task.

// One-off, started immediately.
Thread t = Thread.startVirtualThread(() ->
    System.out.println("hi from " + Thread.currentThread()));
t.join();

// Builder: configure before starting.
Thread named = Thread.ofVirtual().name("worker-", 0).unstarted(() -> doWork());
named.start();

// Many tasks: the executor creates a fresh virtual thread per submitted task.
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
    for (int i = 0; i < 1_000; i++) {
        executor.submit(() -> handleRequest());
    }
} // close() waits for every task to finish

Non mettere mai in pool i virtual thread. Un tradizionale fixed thread pool limita la concorrenza intenzionalmente; avvolgere i virtual thread in Executors.newFixedThreadPool(...) vanifica completamente il loro vantaggio. Lo strumento giusto è newVirtualThreadPerTaskExecutor(), che non impone alcun limite di dimensione.

Scheduling, carrier e pinning

I virtual thread sono pianificati da un ForkJoinPool dedicato il cui numero di worker corrisponde per impostazione predefinita al numero di core CPU. Questi worker sono i carrier thread. Quando un virtual thread raggiunge una chiamata bloccante nel JDK — Thread.sleep, letture socket, BlockingQueue.take — il runtime lo smonta in modo che il carrier possa eseguire qualcos'altro.

A volte un virtual thread non può essere smontato e rimane agganciato al suo carrier. Questo si chiama pinning e vanifica lo scopo: un virtual thread bloccato ma appuntato tiene in ostaggio un carrier. Due situazioni lo causano:

Causa del pinningPerché accadeSoluzione
All'interno di un blocco/metodo synchronizedIl monitor è legato al carrierSostituire con ReentrantLock
All'interno di una chiamata nativa (JNI)Il runtime non può catturare lo stack nativoEvitare blocchi nel codice nativo
// Pins the carrier while sleeping — bad.
synchronized (lock) {
    Thread.sleep(1000); // the virtual thread cannot unmount here
}

// Does not pin — good.
lock.lock();
try {
    Thread.sleep(1000); // the virtual thread unmounts freely
} finally {
    lock.unlock();
}

Puoi diagnosticare il pinning eseguendo con -Djdk.tracePinnedThreads=full, che stampa uno stack trace ogni volta che un virtual thread appunta il suo carrier.

Structured concurrency

Creare thread in modo casuale comporta fughe: se un subtask fallisce, i suoi fratelli continuano ad essere eseguiti e bisogna ricordarsi di cancellarli. La structured concurrency (StructuredTaskScope, un'API in anteprima) fa sì che un gruppo di subtask si comporti come una singola unità di lavoro — vengono avviati insieme, attesi insieme e cancellati insieme. Quando lo scope padre termina, tutti i figli sono garantiti come completati.

import java.util.concurrent.StructuredTaskScope;

Response handle() throws Exception {
    try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
        var user  = scope.fork(() -> fetchUser());     // subtask 1
        var order = scope.fork(() -> fetchOrder());    // subtask 2

        scope.join();            // wait for both
        scope.throwIfFailed();   // propagate the first failure, cancel the rest

        return new Response(user.get(), order.get());
    } // both subtasks are guaranteed finished or cancelled here
}

ShutdownOnFailure cancella i subtask rimanenti nel momento in cui uno lancia un'eccezione; ShutdownOnSuccess ritorna non appena il primo subtask ha successo (utile per mettere in gara chiamate ridondanti). In entrambi i casi non ci sono thread orfani. Per l'API completa e altri pattern, consulta il capitolo sulla structured concurrency.

Esempio pratico: diecimila task concorrenti

Il programma seguente invia 10.000 task I/O-bound — ognuno dorme semplicemente 50 ms per simulare una chiamata di rete — a un executor virtual-thread-per-task. Conta quanti carrier thread distinti hanno effettivamente eseguito il lavoro e confronta il tempo di esecuzione reale con quello che si otterrebbe eseguendo gli stessi task uno dopo l'altro.

java— editable, runs on the server

Cosa ricavare dall'esecuzione:

  • 10.000 task vengono completati, eppure l'intera esecuzione termina in meno di un secondo — ben lontano dai ~500.000 ms che gli stessi sleep richiederebbero in esecuzione sequenziale, perché tutta l'attesa si sovrappone.
  • Il numero di carrier thread è uguale al numero di core CPU (Carrier threads corrisponde ad Available cores): migliaia di virtual thread sono multiplexati su quella piccola manciata di platform thread.
  • Thread.sleep smonta il virtual thread dal suo carrier, ed è esattamente per questo che così pochi carrier riescono a servire così tanti task contemporaneamente — il carrier non rimane mai inattivo ad aspettare.
  • La chiusura di newVirtualThreadPerTaskExecutor() in un blocco try-with-resources blocca finché ogni task inviato non è terminato, quindi il conteggio completati raggiunge sempre 10.000 prima che venga stampata la misurazione del tempo.
  • isVirtual() restituisce true e isDaemon() restituisce true — i virtual thread sono sempre thread daemon, quindi non tengono mai la JVM in vita da soli.

Quando usare i virtual thread (e quando no)

I virtual thread rappresentano un vantaggio quando i tuoi task trascorrono la maggior parte del tempo in attesa — sulla rete, un database, un file o un servizio downstream. Questa è la forma comune del lavoro lato server, quindi il consiglio tipico è semplice: usa un virtual thread per richiesta e scrivi codice bloccante normale.

Non rappresentano un'accelerazione per il lavoro CPU-bound. Un task che elabora numeri non si blocca mai, quindi non si smonta mai; eseguirne un milione aggiunge solo overhead di scheduling. Per il calcolo puro, dimensiona un pool in base al numero di core. Altre due cose da tenere a mente:

  • Controlla i percorsi critici alla ricerca di blocchi synchronized che avvolgono chiamate bloccanti e migrarli a ReentrantLock per evitare il pinning.
  • Non mettere in cache o in pool i virtual thread, e non fare affidamento sullo stato thread-local per limitare la concorrenza — usa un semaforo o un altro limitatore esplicito se devi controllare la velocità.

Esercitazione

Pratica
Avvolgi i virtual thread in Executors.newFixedThreadPool(200) per eseguire 10.000 task I/O-bound. Perché è un errore?
Avvolgi i virtual thread in Executors.newFixedThreadPool(200) per eseguire 10.000 task I/O-bound. Perché è un errore?
Was this page helpful?