Buone pratiche di multithreading in pratica: edizione Java — convenzioni dell'era dei thread virtuali

· · Multithreading, Java, Applicazioni aziendali, Analisi dei bug, Progettazione

«Vogliamo parallelizzare un job batch aziendale in Java.» «La cache condivisa nella nostra app web Spring si corrompe di tanto in tanto.» «Abbiamo ereditato una vecchia app Swing piena di new Thread.» — Java ha il multithreading integrato nel linguaggio da JDK 1.0, è maturata nella cassetta degli attrezzi java.util.concurrent e, con i thread virtuali in JDK 21, ha riscritto ancora una volta il senso comune della programmazione concorrente. Proprio perché il set di strumenti è così ricco, quale strumento scegliete diventa la qualità del progetto stesso.

Questo articolo è l’edizione Java della serie sul multithreading in pratica. Destinato agli sviluppatori che scrivono sistemi aziendali, job batch e applicazioni server in Java, mappa i principi di progettazione del multithreading — non creare mai i thread direttamente, ridurre lo stato mutabile condiviso, disciplinare i lock, progettare come si ferma prima di ogni altra cosa — sugli strumenti di Java (mirando soprattutto alla release LTS, JDK 21 e successivi), e raccoglie le scelte dell’era dei thread virtuali e le trappole specifiche di Java, fondato su fonti primarie ad agosto 2026. È scritto per essere letto da solo. Gli stessi principi applicati ad altri linguaggi sono coperti negli articoli compagni «edizione .NET», «edizione C++» e «edizione C».

1. Prima di tutto, la conclusione

  • Non scrivete new Thread nel codice aziendale — la stessa regola vale in Java. Consegnate i compiti a ExecutorService e lasciate che la libreria gestisca i cicli di vita dei thread.1
  • Indirizzate i compiti che aspettano soprattutto I/O verso i thread virtuali. I thread virtuali, resi ufficiali in JDK 21, si usano uno per compito e non devono mai finire in un pool. Limitate la concorrenza con un Semaphore, non con la dimensione del pool.23
  • I thread virtuali sono uno strumento di throughput, non uno strumento per rendere più veloce il calcolo. Il parallelismo CPU-bound resta il lavoro dei thread di piattaforma dimensionati all’incirca sul numero di core — un pool fisso o un parallel stream, come prima.3
  • Bloccate su un oggetto lock private final, o su un ReentrantLock dedicato. synchronized(this) e il lock su un oggetto esposto pubblicamente possono scontrarsi con codice esterno. Se vi serve un’acquisizione a tempo (tryLock), usate ReentrantLock.
  • In JDK 21-23 c’era il problema che il blocco dentro un blocco synchronized appuntava i thread virtuali; JDK 24 (JEP 491) l’ha risolto. Distinguete i vecchi avvisi dalla realtà attuale.34
  • volatile garantisce visibilità e ordinamento, non atomicità. Usate AtomicInteger / LongAdder per i contatori, e i lock per lo stato composto.5
  • L’arresto cooperativo tramite interruzione è l’unico modo corretto di fermare una thread. Thread.stop / suspend / resume ora lanciano UnsupportedOperationException. Non ingoiate InterruptedException — o ripristinate lo stato o rilanciate.6
  • Fermate un ExecutorService con il pattern a due stadi: shutdown → awaitTermination → shutdownNow. shutdownNow è best-effort (l’implementazione standard passa per l’interruzione), quindi presuppone che i vostri compiti rispondano all’interruzione.1
  • L’interfaccia di Swing appartiene esclusivamente all’EDT (Event Dispatch Thread). Richiedete gli aggiornamenti da altre thread tramite SwingUtilities.invokeLater.7

2. Perché il multithreading è difficile — race condition, deadlock e modello di memoria

Riducete i problemi che il multithreading introduce, indipendentemente dal linguaggio, e restano due tipi.

Una race condition è un bug in cui il risultato dipende dall’ordine in cui più thread raggiungono un pezzo di codice. L’esempio classico è un contatore condiviso: la singola espressione count++ si spezza in realtà in tre passi — leggi, aggiungi, riscrivi. Se due thread entrano in questi tre passi nello stesso momento, l’incremento di una thread viene sovrascritto e perso quando l’altra riscrive. Il risultato cambia a ogni esecuzione, e non c’è modo di predire quale otterrete.

Thread BVariabile condivisa countThread AThread BVariabile condivisa countThread Acount = 10Sono avvenuti due incrementi,ma count = 11 — l'incremento della Thread A è andato persoLettura (10)Lettura (10)Addizione locale (11)Addizione locale (11)Riscrittura (11)Riscrittura (11)

Figura 1: Una race condition classica in cui un incremento su un contatore condiviso si perde. Se un’altra thread si intercala durante i tre passi di count++, l’ultima risrittura sovrascrive l’altra

Un deadlock è uno stato in cui due thread aspettano ciascuna un lock che l’altra sta tenendo, così nessuna può procedere. La thread A tiene il lock 1 e aspetta il lock 2; la thread B tiene il lock 2 e aspetta il lock 1 — questo da solo basta perché entrambe si fermino per sempre.

aspetta il rilascio del lock 2aspetta il rilascio del lock 1Thread Atiene il lock 1Thread Btiene il lock 2

Figura 2: L’attesa circolare di un deadlock. Nel momento in cui le frecce di attesa formano un anello, ogni thread dentro quell’anello si ferma per sempre

Entrambe dipendono dal timing. Un intercalare che su una macchina di sviluppo compare una volta ogni decine di migliaia di esecuzioni può accadere ogni giorno su un server di produzione con un numero di core e un carico diversi. «Smette di riprodursi quando attacco un debugger» e «è scomparso quando ho aggiunto il logging» sono entrambi perché l’osservazione cambia il timing — comportamento classico di un bug di race. È proprio per questo che ogni principio di questo articolo punta verso ridurre i posti che hanno bisogno di sincronizzazione, prima di preoccuparsi di sincronizzare correttamente.

2.1. Una premessa specifica di Java — il modello di memoria e happens-before

Oltre a ciò, ciò che è specifico di Java è che come si vedono i dati condivisi è definito dalle relazioni happens-before nel Java Memory Model (JMM).

Leggere e scrivere una variabile condivisa senza sincronizzazione non diventa «comportamento indefinito» in stile C++, ma può legittimamente risultare in valori stantii che continuano a essere visti, o scritture che appaiono fuori ordine. Un errore di coerenza della memoria in cui «un ciclo sta guardando un flag boolean, ma il valore che un’altra thread ha cambiato non diventa mai visibile» è un comportamento che il JMM consente, non un bug della JVM.5 Gli strumenti che proteggono da questo sono i meccanismi che creano relazioni happens-before — synchronized, volatile e le classi di java.util.concurrent. Usate correttamente le collezioni concorrenti e la libreria vi garantisce «una relazione happens-before tra un’operazione di aggiornamento e un recupero successivo».8

In altre parole, la guida pratica di Java si può riassumere così: non fate i furbi con una variabile condivisa grezza. Usate gli strumenti di java.util.concurrent per condividere, e lasciate che la libreria crei le relazioni happens-before.

3. Come creare i thread — ExecutorService e thread virtuali

3.1. Separare il compito da come viene eseguito

ExecutorService è ciò che porta il principio «non create i thread da soli» in Java. Separa il lavoro (Runnable / Callable) da come viene eseguito — quanti thread, quale coda — e lascia creazione, riuso e smaltimento dei thread alla libreria.1

Da JDK 21 in poi, scegliere come eseguire è diventata una scelta binaria semplice.23

Soprattutto attesa di I/Ochiamate HTTP, DB, fileCalcolo CPU-boundCompito che volete eseguire in concorrenzaCosa guida il compito?Thread virtualiExecutors.newVirtualThreadPerTaskExecutoruno per compito, mai in poolPool fisso di thread di piattaformaExecutors.newFixedThreadPool - circa tanti quanti i coreo un parallel streamLimitate la concorrenza verso i servizi esternicon Semaphore, non con la dimensione del pool

Figura 3: Scegliere come eseguire il lavoro da JDK 21 in poi. Tirate prima la riga — «cambiate come aspettate l’I/O, parallelizzate per la CPU» — poi consegnate il lavoro I/O-bound ai thread virtuali e quello CPU-bound a un pool convenzionale

C’è una riserva sul lato CPU-bound. Executors.newFixedThreadPool limita il numero di thread, ma la sua coda è illimitata. In un servizio di lunga durata in cui le sottomissioni continuano a superare l’elaborazione, solo i thread sono limitati al numero di core — i compiti che si accumulano in coda, e i loro dati, continuano a mangiare memoria. In quel tipo di allestimento, o usate ThreadPoolExecutor direttamente per configurare una coda limitata più una politica di rifiuto, o mettete un controllo di ammissione come un Semaphore sul lato che sottomette così da poter applicare back-pressure (lo stesso principio della discussione sulle code nella sezione 4).

3.2. Non usate male i thread virtuali

I thread virtuali sono thread leggeri sganciati dai thread OS: durante un’operazione di blocco nel JDK (I/O, lock, sleep e simili nella libreria standard) rilasciano il loro thread OS, ed è per questo che una singola JVM può eseguirne milioni. Detto ciò, non lo rilasciano per ogni tipo di blocco. Se un thread virtuale si blocca mentre esegue codice nativo (JNI) o una funzione esterna, resta appuntato al suo thread carrier. Ciò che JDK 24 (JEP 491, discusso sotto) ha risolto è il pinning causato da synchronized; il pinning al confine nativo resta, quindi caricare un gran numero di thread virtuali con operazioni che si bloccano a lungo tramite un driver JNI o un’API di dispositivo esaurirà i thread carrier. Ma come sottolinea la guida ufficiale, non sono «thread più veloci». La velocità di esecuzione del codice non cambia — ciò che offrono è scala (throughput).3

Ci sono tre discipline per usarli.3

  1. Non metteteli in pool. I thread virtuali sono economici e usa-e-getta; «numero di compiti = numero di thread virtuali» è lo stato corretto. Mettere thread virtuali in un newFixedThreadPool è un errore — usate la forma try (var executor = Executors.newVirtualThreadPerTaskExecutor()).
  2. Limitate la concorrenza con un Semaphore. Esprimete un vincolo come «al massimo 10 connessioni contemporanee a un’API esterna» con un semaforo, non con la dimensione del pool.
  3. Non usateli per lavoro CPU-bound. I thread di piattaforma dimensionati all’incirca sul numero di core restano lo strumento giusto per parallelizzare il calcolo, come prima.

Notate che ciò che gira dentro un thread virtuale è codice sincrono ordinario. Piuttosto che riscrivere il codice come fa l’async/await di .NET, la filosofia di progetto dietro i thread virtuali è farvi eseguire codice lineare «una thread per richiesta», invariato, su scala enorme.2

3.3. Un fraintendimento comune — «non serve il pooling» vale solo per i thread virtuali

Non leggete la disciplina «non metteteli in pool» come se significasse «in Java non esiste un pool di thread (o è inefficiente)». La realtà è l’opposto: i pool di Java sono una parte matura della libreria standard da JDK 5 (2004). Il pool generico finemente configurabile ThreadPoolExecutor (creato tramite le varie factory di Executors), il ForkJoinPool a work-stealing (la cui istanza comune, commonPool(), è il bersaglio di esecuzione predefinito per i parallel stream e CompletableFuture) e ScheduledThreadPoolExecutor per l’esecuzione periodica — questi restano i protagonisti per il lavoro CPU-bound.

Un pool è fondamentalmente un’ottimizzazione fondata sul fatto che «creare e tenere un thread OS è costoso, quindi riutilizzatelo». I thread virtuali eliminano questa premessa rendendo il costo di creazione vicino a zero, quindi non c’è più motivo di riutilizzarli — la comprensione precisa non è che il pooling sia diventato inefficiente, ma che i thread sono diventati abbastanza leggeri da rendere superfluo l’ottimizzazione del pooling. E sotto i thread virtuali, lo scheduler del JDK gira un insieme di thread carrier (thread OS) in numero all’incirca pari ai core, come un ForkJoinPool a work-stealing.2 In altre parole, la forma «gestire un’enorme quantità di lavoro concorrente con un piccolo pool di thread OS» è conservata; solo la gestione di quel pool è passata dalle mani dello sviluppatore alla JVM. È lecito dire che Java arriva alla stessa destinazione dell’async/await di .NET, che restituisce il suo thread al pool nel punto di await, senza cambiare la forma del codice.

4. Ridurre lo stato mutabile condiviso — partizionamento, immutabilità, collezioni concorrenti e code

La contesa nasce solo quando esistono insieme «più thread» e «dati mutabili condivisi». Il numero di thread è determinato dai requisiti, quindi ciò che il progetto può tagliare è la parte condivisa. Ci sono tre famiglie di tecniche — partizionare, rendere immutabile e consegnare i dati — ed ecco come le scrivete in Java.

Partizionatelo. Per un’aggregazione parallela, invece di far scrivere a ogni thread su una variabile di totale condivisa, fate costruire a ogni thread un risultato parziale e uniteli alla fine. reduce / collect dello stream parallelo offrono esattamente questa forma come cornice, e LongAdder, discusso sotto, è altrettanto un’implementazione della strategia di partizionamento — internamente spezza in celle per spargere la contesa, e le somma quando si legge. Ridurre quanto spesso scrivete sullo stato condiviso viene prima di scrivere la sincronizzazione correttamente.

Rendete immutabile. Costruite dati con record e collezioni immutabili (List.copyOf / Map.copyOf) che non vengono mai riscritti dopo la costruzione, e potete condividerli senza sincronizzazione. Per configurazione e dati anagrafici, il pattern standard è: quando dovete sostituirli, costruite un nuovo oggetto e scambiate un riferimento volatile. Tuttavia «sembra in sola lettura» e «è immutabile» sono cose diverse. Gli accessor di un record restituiscono i riferimenti grezzi dei suoi componenti, e la copia di List.copyOf è anch’essa superficiale (non duplica gli oggetti elemento), quindi se gli elementi sono mutabili, chiunque tenga un alias può riscrivere il contenuto, e la contesa resta. È sicuro condividere senza sincronizzazione solo quando l’intero grafo di oggetti — elementi inclusi — è immutabile. Se sono coinvolti elementi mutabili, o passate una copia profonda o spingete anche gli elementi verso record / tipi immutabili.

Usate le operazioni composte sulle collezioni concorrenti. Per il pattern di ConcurrentHashMap «crealo se assente, poi inserisci» usate computeIfAbsent. Questo metodo esegue l’intera chiamata in modo atomico, e se la chiave è assente la funzione di mapping viene chiamata esattamente una volta all’interno di quella singola chiamata.8 La garanzia differisce da ConcurrentDictionary.GetOrAdd di .NET (la cui factory può girare più di una volta sotto contesa) — un punto che chi passa tra i due linguaggi confonde facilmente. Non è, però, «esattamente una volta nella vita della chiave». Se la funzione restituisce null o lancia, non viene registrato alcun mapping, e la funzione gira di nuovo in una chiamata successiva (lo stesso vale se la voce viene rimossa dopo la registrazione). Se la vostra inizializzazione non tollera effetti collaterali duplicati, progettatela così che la funzione abbia successo e restituisca non-null. Come prezzo dell’essere atomica, però, alcuni aggiornamenti di altre thread vengono bloccati mentre gira il calcolo, quindi tenete la funzione di mapping breve, e non aggiornate mai questa stessa mappa dall’interno della funzione (un aggiornamento ricorsivo rilevato può lanciare IllegalStateException).8

// Il pattern standard per un contatore di frequenza: computeIfAbsent + LongAdder
ConcurrentHashMap<String, LongAdder> freqs = new ConcurrentHashMap<>();
freqs.computeIfAbsent(key, k -> new LongAdder()).increment();

Consegnate i dati attraverso una coda. Indirizzate il flusso di dati tra thread attraverso BlockingQueue. Con un ArrayBlockingQueue a cui è stata data una capacità, put si blocca quando è piena, dando back-pressure naturale — la stessa forma del canale limitato dell’edizione .NET. Anche nell’era dei thread virtuali, questo progetto di tracciare un confine chiaro tra produttore e consumatore resta efficace.

5. Disciplina dei lock — synchronized e ReentrantLock

5.1. Su cosa bloccare, e cosa non fare mentre si tiene un lock

Pensate l’unità di locking non come una «sezione di codice» ma come «dati». Mappate un oggetto lock su ciascun insieme di dati mutabili che volete proteggere, e prendete quello stesso lock in ogni posto che tocca quei dati — la realtà di un bug di race è di solito che questa mappatura si è rotta da qualche parte. Evitate synchronized(this) e synchronized(SomeClass.class), perché il codice esterno può bloccare lo stesso oggetto; invece, accoppiate i dati da proteggere uno a uno con un private final Object lock = new Object(); che non viene mai esposto all’esterno.

Altre due discipline sopra a questo. Prima, non fate niente di lento, né niente che tocchi il mondo esterno, mentre tenete un lock. I/O, chiamare listener o eseguire codice sconosciuto mentre tenete ancora il lock allunga quanto lo tenete e rischia che il chiamato tenti di prendere un altro lock, creando l’attesa circolare della figura 2. Seconda, fissate l’ordine di acquisizione per più lock. Dove prendete due o più lock, fate regola che ogni thread li prenda nello stesso ordine, e per i posti in cui non potete garantire l’ordine preparate un percorso «lascia e ritenta se non lo ottieni» con tryLock(timeout), discusso sotto.

synchronized basta per «un’esclusione breve e semplice». Passate a ReentrantLock quando vi serve quanto segue.

  • Acquisizione a tempo tramite tryLock(timeout) (trasformare un hang permanente in un fallimento che potete registrare e gestire)
  • Politiche di equità, più Condition, o quando volete spezzare acquisizione e rilascio di un lock su metodi diversi

Usando ReentrantLock, non rompete mai il pattern di try subito dopo lock() e unlock() in un blocco finally (Java non ha un equivalente del RAII di C++, quindi questo pattern è l’intera disciplina).

5.2. Thread virtuali e pinning — cosa è cambiato in JDK 24

Quando i thread virtuali furono introdotti (JDK 21-23), c’era un vincolo per cui il blocco dentro un blocco synchronized appuntava il thread virtuale al suo thread OS (non poteva rilasciare il thread OS, perdendo il beneficio di scala), e si raccomandava di sostituire i punti di blocco frequenti o lunghi con ReentrantLock.3 Quel vincolo è stato risolto quando JEP 491 di JDK 24 ha riscritto l’implementazione del monitor, e synchronized non appunta più i thread virtuali.4 Se siete su JDK 24 o successivo, sostituire meccanicamente synchronized come contromisura al pinning non è più necessario. Vale la pena controllare se le linee guida più vecchie della vostra organizzazione sono ancora ferme all’avviso dell’era JDK 21.

5.3. Dove stanno gli atomici e volatile

Gli aggiornamenti atomici di una singola variabile li gestiscono AtomicInteger / AtomicLong / AtomicReference (o, per statistiche che vengono solo incrementate ad alta frequenza, il LongAdder resistente alla contesa). volatile garantisce visibilità e ordinamento (happens-before), non l’atomicità delle operazioni composte.5 La stessa conclusione delle edizioni .NET e C++ vale anche in Java: usate gli atomici per flag e valori singoli, i lock per lo stato composto, e non cercate di far fare tutto a volatile da solo.

6. Progettare come si ferma — l’interruzione come lingua comune

6.1. L’etichetta di interrupt

Arresto e cancellazione in Java sono unificati intorno all’interruzione. t.interrupt() imposta lo stato di interruzione della thread bersaglio, e se il bersaglio è bloccato in sleep / wait / join o simili, lancia InterruptedException per svegliarlo subito (a quel punto lo stato di interruzione viene azzerato).6 Thread.stop / suspend / resume, i meccanismi forzati del passato, sono fondamentalmente insicuri, quindi chiamarli ora risulta in UnsupportedOperationException.6

Può finire da solaNon può finire - es. dentro una libreriaIl chiamante la ferma - chiama t.interruptLo stato di interruzione è impostatoThread che calcola:interroga Thread.interrupted in un cicloBloccata in sleep / wait / join:parte InterruptedException e la sveglia subitolo stato viene azzeratoPulisce e finisce da solaCosa fa il catch?Thread.currentThread.interruptripristina lo stato e lascia il segnale

Figura 4: Arresto cooperativo tramite interruzione. Ingoiare InterruptedException fa sparire il segnale di stop — una volta catturata, la scelta è o «finire» o «ripristinare»

C’è una sola disciplina da ricordare in pratica: non scrivete codice che cattura InterruptedException e non fa nulla. Se potete finire nella vostra responsabilità, finite lì; se non potete, ripristinate lo stato con Thread.currentThread().interrupt() e passate il segnale al chiamante (vedi le FAQ).

6.2. Lo shutdown a due stadi di ExecutorService

Le API di shutdown di ExecutorService stanno sopra al modello di interruzione. shutdown() smette di accettare nuovi compiti e lascia correre fino al completamento i compiti già sottomessi; shutdownNow() tenta di fermare i compiti in esecuzione. Come specifica di interfaccia questo è best-effort, ed è documentato esplicitamente che un’implementazione standard (come ThreadPoolExecutor) tipicamente cancella tramite Thread.interrupt() — il che significa che un compito che non risponde all’interruzione non si fermerà nemmeno con shutdownNow, e se usate un’implementazione Executor personalizzata dovete controllarne la documentazione per vedere come cancella (se invia affatto un’interruzione).1 Il pattern di arresto standard mostrato dalla documentazione ufficiale è il seguente pattern a due stadi.1

/** Vero una volta che lo shutdown è completo. Non procedete a rilasciare risorse condivise finché è false. */
boolean shutdownAndAwaitTermination(ExecutorService pool) {
    pool.shutdown();                    // Stadio 1: smettere di accettare nuovi compiti e aspettare il completamento
    try {
        if (!pool.awaitTermination(60, TimeUnit.SECONDS)) {
            // Stadio 2: richiedere la cancellazione. I compiti scartati prima di girare
            // vengono restituiti, quindi marcate i loro Future come cancellati per svegliare chi è bloccato su get()
            pool.shutdownNow().forEach(r -> {
                if (r instanceof Future<?> f) f.cancel(false);
            });
            if (!pool.awaitTermination(60, TimeUnit.SECONDS)) {
                System.err.println("Pool did not terminate");
                return false;           // Lo shutdown non è completo. Segnalatelo in una forma distinguibile dal successo
            }
        }
        return true;
    } catch (InterruptedException ex) {
        pool.shutdownNow().forEach(r -> {
            if (r instanceof Future<?> f) f.cancel(false);
        });
        Thread.currentThread().interrupt();   // Ripristinare anche lo stato di interruzione di questa thread
        return false;                   // Anche questo percorso può lasciare lo shutdown incompleto
    }
}
finisce entro la scadenzascade il tempocompletaancora non finitoshutdownsmette di accettare nuovi compitiawaitTerminationaspetta il completamentoShutdown completoshutdownNowinvia interrupt ai compiti in esecuzionese risponde dipende dal compitoawaitTerminationaspetta di nuovoRegistrarlo come anomaliasospettare un compito che ignora gli interrupt

Figura 5: Lo shutdown a due stadi di ExecutorService. Un progetto a stadi di «aspettare educatamente → richiedere tramite interruzione → se ancora non finisce, osservarlo come anomalia»

C’è un limite nel cancellare i Runnable che shutdownNow() restituisce. Ciò che torna è l’oggetto che stava nella coda di esecuzione — per un submit semplice è proprio il FutureTask consegnato al chiamante, ma per i compiti sottomessi attraverso un wrapper come ExecutorCompletionService è il wrapper dentro la coda, un oggetto diverso dal Future del chiamante. In quella configurazione, la cancellazione sopra non completerà il Future del chiamante, quindi progettatela per tenere la vostra lista di Future al momento della sottomissione e cancellare quelli allo shutdown (o restituire i compiti scartati al proprietario).

close() (AutoCloseable), disponibile da JDK 19 in poi, impacchetta «chiama shutdown e aspetta il completamento» in una forma che potete scrivere con try-with-resources, e try (var executor = ...) combinato con il newVirtualThreadPerTaskExecutor dei thread virtuali è la forma di base moderna.1 Tuttavia close() non è un sostituto del pattern a due stadi sopra. Poiché aspetta il completamento senza timeout, se anche un solo compito non risponde all’interruzione o non finisce mai, la thread che tenta di chiuderlo si blocca per sempre. È uno strumento adatto ad ambiti in cui i compiti sono finiti e garantiti di arrivare al completamento (sottomettetelo lì, aspettatelo lì); per posti come il percorso di shutdown di un’applicazione, dove volete che finisca sempre entro un tempo limitato, usate invece il pattern a due stadi a tempo. Cancellare un compito individuale avviene altrettanto tramite interruzione, con Future.cancel(true).

7. Il thread dell’interfaccia — l’EDT di Swing

Le applicazioni desktop, indipendentemente da linguaggio o framework, seguono la regola che l’interfaccia appartiene esclusivamente al thread che la gestisce. In Swing, quel thread esclusivo è l’Event Dispatch Thread (EDT): i metodi dei componenti Swing, di regola, non sono thread-safe, e toccarli da più thread invita interferenza tra thread ed errori di coerenza della memoria. Richiedete gli aggiornamenti dello schermo da altre thread attraverso SwingUtilities.invokeLater verso l’EDT, e al contrario, poiché eseguire operazioni lunghe sull’EDT congela l’interfaccia, spingete il lavoro pesante su un thread worker tramite SwingWorker o simile.7 JavaFX segue la stessa forma: gli aggiornamenti dell’interfaccia si richiedono sul thread dell’applicazione tramite Platform.runLater.

8. Verifica e debug — i dump dei thread come arma

Non potete aspettarvi che i test trovino i bug di race. I test ordinari contano come successo un’esecuzione in cui la contesa per caso non è avvenuta. Pensate la difesa in tre strati.

La prima linea di difesa è il progetto. In revisione, controllate con una tabella: quali dati mutabili sono condivisi, quale lock protegge ciascun elemento (la mappatura della sezione 5.1), se l’ordine di acquisizione dei lock è unico, se qualche blocco catch sta ingoiando InterruptedException, e se il percorso di stop (shutdown/interruzione) raggiunge ogni compito.

Secondo, sfruttate bene i dump dei thread. Java ha uno strumento standard per catturare «lo stato di ogni thread in questo istante congelato»: jstack (o jcmd <pid> Thread.print) stampa le stack trace, e l’opzione -l aggiunge anche le informazioni sui lock.9 Notate che questo formato di dump tradizionale è per i thread di piattaforma; non include i thread virtuali della vostra applicazione. Quando tracciate una richiesta bloccata in una configurazione che usa thread virtuali (sezione 3), usate jcmd <pid> Thread.dump_to_file -format=json <file>, che può dumpare anche i thread virtuali.2 La procedura di base per indagare un hang è prendere due o tre dump a pochi secondi di distanza e incrociare su quale lock sta aspettando ogni thread ferma, e chi sta tenendo quel lock. Se registrate i timeout di tryLock(timeout) (sezione 5.2), potete persino automatizzare il grilletto per prendere un dump.

Terzo, scuotete le cose sotto carico. Lo stress test — girare a lungo con più parallelismo di quanti sono i core, randomizzare l’ordine di elaborazione, iniettare ritardi artificiali — è un modo pratico di rendere più probabile di pescare il «colpo» di una race condition su una macchina di sviluppo. Eseguite almeno un test con volume di dati e numero di thread di scala produzione prima del rilascio.

9. Dove sta andando la concorrenza Java — Structured Concurrency

Uno sguardo rapido mezzo passo avanti, per chiudere. Costruita sul presupposto dei thread virtuali, Structured Concurrency (StructuredTaskScope) — che tratta più sottocompiti come una singola unità di lavoro e struttura la propagazione dei fallimenti e la cancellazione — è in sviluppo e, ad agosto 2026, è ancora una funzione preview. È stata rivista in una forma API basata su StructuredTaskScope.open() nella quinta preview di JDK 25 (JEP 505), e continua in una sesta preview (JEP 525) nell’attuale JDK 26.1011 Intanto Scoped Values, condivisione di contesto immutabile che risolve i problemi di ThreadLocal, è stata finalizzata in JDK 25.12 I principi di questo articolo — confini di compito chiari, condivisione immutabile, arresto cooperativo — si allineano anche con la direzione in cui stanno andando queste nuove API.

10. Sintesi — la checklist Java

  1. È rimasto qualche new Thread nel codice aziendale (è costruito su ExecutorService / thread virtuali)?
  2. Il lavoro I/O-bound e quello CPU-bound sono indirizzati a meccanismi di esecuzione diversi (il ramo della figura 3)?
  3. I thread virtuali non sono in pool, e la concorrenza è limitata con un Semaphore?
  4. I dati condivisi sono immutabili (record / List.copyOf), o costruiti sugli strumenti di java.util.concurrent?
  5. Non c’è synchronized(this) né lock su un oggetto esposto pubblicamente?
  6. State usando le operazioni composte di ConcurrentHashMap (computeIfAbsent, ecc.) e tenendo breve la funzione di mapping?
  7. Non vi aspettate atomicità da volatile (i contatori usano le classi Atomic / LongAdder)?
  8. Non c’è un solo blocco catch che ingoia InterruptedException?
  9. Fermare un ExecutorService segue il pattern a due stadi a tempo (e i posti che usano close() sono limitati ad ambiti in cui il completamento dei compiti è garantito)?
  10. Gli aggiornamenti dell’interfaccia Swing/JavaFX sono consolidati sull’EDT / thread dell’applicazione?

Java è uno dei linguaggi meglio equipaggiati di strumenti di concorrenza, e l’arrivo dei thread virtuali ha aperto una via per scalare codice sincrono lineare senza riscriverlo. È proprio per questo che cogliere correttamente la divisione del lavoro tra gli strumenti — quale è per il throughput, quale è per l’esclusione, e cosa segnala uno stop — è la sostanza della progettazione del multithreading in Java.

Articoli correlati

Aree di consulenza correlate

KomuraSoft LLC si occupa di revisioni di progetto del multithreading per sistemi aziendali e elaborazione batch in Java, di indagine delle cause di difetti legati alla concorrenza come la corruzione dello stato condiviso e i sintomi «ogni tanto non si ferma / si blocca» (analisi dei dump dei thread), e di consulenza tecnica sull’adozione dei thread virtuali.

Riferimenti

  1. Oracle, ExecutorService (Java SE 21 & JDK 21 API). Sul fatto che shutdown() lasci correre fino al completamento i compiti già sottomessi fermando le nuove sottomissioni; sul fatto che shutdownNow() tenti di fermare i compiti in esecuzione e restituisca un elenco di compiti in attesa di esecuzione, sebbene l’implementazione tipica cancelli tramite Thread.interrupt(), senza garanzia oltre il best-effort, quindi un compito che non risponde all’interruzione non termina; sul fatto di poter aspettare il completamento con awaitTermination; sul fatto che close() (AutoCloseable, da Java 19 in poi) chiami shutdown e aspetti il completamento, usabile con try-with-resources; e sul fatto che lo shutdown a due stadi shutdown → awaitTermination → shutdownNow sia mostrato come esempio d’uso.  2 3 4 5 6

  2. OpenJDK, JEP 444: Virtual Threads. Sul fatto che i thread virtuali siano diventati una funzione ufficiale in JDK 21; sul fatto che siano thread leggeri che riducono drasticamente lo sforzo di scrivere, mantenere e osservare applicazioni concorrenti ad alto throughput; sulla filosofia di progetto di scalare codice sincrono lineare «una richiesta, un thread» invariato; sul fatto che lo scheduler dei thread virtuali del JDK sia un ForkJoinPool a work-stealing operante in modalità FIFO, con un parallelismo predefinito pari al numero di processori disponibili; e sul fatto che sia stato aggiunto un nuovo formato di dump dei thread che include i thread virtuali come jcmd Thread.dump_to_file (in testo semplice e in JSON), mentre i dump dei thread tradizionali non includono i thread virtuali.  2 3 4 5

  3. Oracle Java SE Core Libraries, Virtual Threads. Sul fatto che i thread virtuali siano thread leggeri implementati dal runtime Java che rilasciano il loro thread OS durante l’I/O bloccante; sul fatto che siano una funzione per la scala (throughput) piuttosto che per la velocità (latenza), e inadatti all’elaborazione CPU-intensiva; sul fatto di non mettere mai in pool i thread virtuali e usarne uno per compito (newVirtualThreadPerTaskExecutor); sul fatto di usare Semaphore piuttosto che un pool di thread per limitare la concorrenza; sul fatto che il blocco dentro synchronized allo stato di JDK 21 causasse pinning al thread OS, per cui si consigliava di sostituire i punti frequenti o lunghi con ReentrantLock; e sul fatto di poter rilevare il pinning con -Djdk.tracePinnedThreads.  2 3 4 5 6 7

  4. OpenJDK, JEP 491: Synchronize Virtual Threads without Pinning. Sul fatto che l’implementazione del monitor della JVM sia stata riscritta per JDK 24 per supportare i thread virtuali, così che il blocco dentro un blocco o un metodo synchronized non appunta più un thread virtuale al suo thread carrier; e sul fatto che ciò significhi che la contromisura dell’era JDK 21-23 di «sostituire synchronized con ReentrantLock» non è più, in linea di principio, necessaria.  2

  5. Oracle, The Java Tutorials, Memory Consistency Errors. Sul fatto che gli errori di coerenza della memoria sorgano quando più thread hanno viste incoerenti degli stessi dati; sul fatto che la chiave per evitarli sia la relazione happens-before (una garanzia che una scrittura in memoria di un’istruzione sia visibile a un’altra istruzione); e sul fatto che synchronized, volatile e Thread.start / join, tra gli altri, creino relazioni happens-before.  2 3

  6. Oracle, Thread (Java SE 21 & JDK 21 API). Sul fatto che Thread.stop / suspend / resume siano fondamentalmente insicuri (i lock vengono rilasciati in uno stato incoerente e gli oggetti rotti diventano visibili; suspend può invitare un deadlock), rendendoli deprecati in vista della rimozione, e ora lanciano UnsupportedOperationException quando vengono chiamati; sul fatto che interrupt() imposti lo stato di interruzione e svegli una thread bloccata in sleep / wait / join lanciando InterruptedException (che azzera lo stato di interruzione); e sulla differenza in come interrupted() e isInterrupted() trattano quello stato.  2 3

  7. Oracle, The Java Tutorials, The Event Dispatch Thread. Sul fatto che il codice di gestione degli eventi di Swing giri sull’Event Dispatch Thread (EDT); sul fatto che la maggior parte dei metodi degli oggetti Swing non sia thread-safe, così che chiamarli da più thread invita interferenza tra thread ed errori di coerenza della memoria, il che significa che l’accesso ai componenti Swing dovrebbe, di regola, avvenire sull’EDT; sul fatto di richiedere compiti sull’EDT da altre thread tramite SwingUtilities.invokeLater / invokeAndWait; e sul fatto che i compiti che girano sull’EDT debbano finire in fretta.  2

  8. Oracle, ConcurrentHashMap (Java SE 21 & JDK 21 API). Sul fatto che l’intera chiamata al metodo computeIfAbsent venga eseguita in modo atomico, con la funzione di mapping chiamata esattamente una volta quando la chiave è assente; sul fatto che alcune operazioni di aggiornamento di altre thread vengano bloccate durante il calcolo, quindi vada tenuta breve e semplice; sul fatto che alla funzione di mapping sia vietato modificare questa mappa stessa, con un aggiornamento ricorsivo rilevato che risulta in IllegalStateException; e sul fatto che un’operazione di recupero (get) non si blocchi, con una relazione happens-before che vale tra un aggiornamento per una data chiave e un recupero successivo.  2 3

  9. Oracle, The jstack Command (Java SE 21 Tools Reference). Sul fatto che jstack stampi le stack trace (nome della classe, nome del metodo, numero di riga) di ogni thread in un processo Java specificato; sul fatto che l’opzione -l abiliti una visualizzazione dettagliata che include informazioni aggiuntive sui lock; e sul fatto che venga usato insieme ad altri strumenti diagnostici come jcmd. 

  10. OpenJDK, JEP 505: Structured Concurrency (Fifth Preview). Sul fatto che l’API di structured concurrency tratti un gruppo di sottocompiti correlati come una singola unità di lavoro e strutturi la propagazione degli errori e la cancellazione; sul fatto che StructuredTaskScope sia stato rivisto in una forma aperta tramite un metodo factory statico (open); e sul fatto che sia, allo stato di JDK 25, una quinta preview e non ancora una funzione finalizzata. 

  11. OpenJDK, JEP 525: Structured Concurrency (Sixth Preview). Sul fatto che structured concurrency continui come sesta preview anche in JDK 26 — cioè, ad agosto 2026, sia ancora una funzione preview nel JDK attuale, che richiede di abilitare le funzioni preview per usarla. 

  12. OpenJDK, JEP 506: Scoped Values. Sul fatto che Scoped Values sia stato finalizzato in JDK 25; e sul fatto che sia un meccanismo per condividere dati di contesto immutabili in modo sicuro ed efficiente dentro e tra i thread, fornendo una soluzione ai problemi di ThreadLocal (mutabilità, gestione del ciclo di vita e costo di ereditarietà). 

Articoli recenti con gli stessi tag per approfondire argomenti vicini.

Queste pagine collocano l’argomento in un contesto più ampio di servizi e decisioni.

Domande frequenti

Domande che ricorrono nelle consulenze sull’argomento dell’articolo.

Se ora ci sono i thread virtuali, non serve più un pool di thread (ExecutorService)?
Dipende dal caso d'uso. I thread virtuali sono un meccanismo per eseguire un numero enorme di compiti dominati dall'attesa di I/O; non rendono il codice più veloce, alzano il throughput. Per il lavoro I/O-bound usate un thread virtuale per compito (Executors.newVirtualThreadPerTaskExecutor) e non mettete mai in pool i thread virtuali. Per parallelizzare un calcolo che satura la CPU, invece, un pool di thread di piattaforma limitato all'incirca al numero di core (o un parallel stream) resta lo strumento giusto, come prima. E se volete limitare il numero di connessioni contemporanee a un servizio esterno, la raccomandazione dell'era dei thread virtuali è limitarlo con un Semaphore piuttosto che con la dimensione del pool.
Quale usare, synchronized o ReentrantLock?
Per un'esclusione breve e semplice synchronized basta, e il codice resta conciso. Scegliete ReentrantLock quando vi servono funzioni come l'acquisizione a tempo tramite tryLock, una politica di equità o più Condition. C'è una riserva storica nel combinarlo con i thread virtuali: in JDK 21-23 c'era il problema che il blocco dentro un blocco synchronized appuntava il thread virtuale al suo thread OS, quindi si raccomandava di sostituire i punti di blocco frequenti o lunghi con ReentrantLock. JDK 24 (JEP 491) ha riscritto l'implementazione del monitor e ha risolto questo vincolo. Da JDK 24 in poi non serve sostituire synchronized per motivi di pinning.
Aggiungere volatile rende qualcosa thread-safe?
No. Il volatile di Java crea una relazione happens-before tra una scrittura su quella variabile e una sua lettura, garantendo visibilità (che le altre thread vedano l'ultima scrittura) e ordinamento, ma non garantisce l'atomicità di un'operazione composta come «leggi, calcola, riscrivi». Incrementate un contatore volatile int con ++ da più thread e le addizioni si perdono. Usate AtomicInteger / AtomicLong (o LongAdder per aggregazioni ad alta frequenza) per i contatori, e un lock quando proteggete insieme più variabili. volatile è appropriato quasi solo in situazioni come un semplice flag di stato — dove una thread scrive e le altre solo leggono.
Va bene catturare InterruptedException e ignorarla?
No. L'interruzione è il segnale standard di Java per l'arresto e la cancellazione, e ingoiarla crea una thread che non si ferma. Nel momento in cui InterruptedException viene lanciata lo stato di interruzione è già stato azzerato, quindi se non potete finire il lavoro da soli o ripristinate lo stato con Thread.currentThread().interrupt() per lasciare il segnale al chiamante, o rilanciate l'eccezione così com'è. Un blocco catch vuoto che non fa nulla è una causa classica di bug in cui lo shutdown non ha effetto o shutdownNow viene ignorato.
Non posso fermare una thread con Thread.stop?
No, non potete. Thread.stop è fondamentalmente insicuro (rilascia i lock lasciandoli in uno stato incoerente, esponendo oggetti rotti ad altre thread), quindi è deprecato da tempo, e chiamarlo nel Java attuale lancia UnsupportedOperationException. Lo stesso vale per Thread.suspend / resume. L'unico modo legittimo di fermare una thread è l'arresto cooperativo tramite interruzione. Se usate ExecutorService, shutdown si limita a smettere di accettare nuovi compiti e ad aspettare il completamento — non invia un'interruzione ai compiti in esecuzione. È shutdownNow che tenta di fermare i compiti in esecuzione, ed è best-effort (l'implementazione standard passa per l'interruzione).

Profilo dell’autore

Pagina di presentazione dell’autore dell’articolo.

Go Komura

Rappresentante di KomuraSoft LLC

Specializzato nello sviluppo di software Windows, nella consulenza tecnica e nell’analisi dei malfunzionamenti, soprattutto nei progetti con sistemi esistenti e guasti difficili da riprodurre.

Torna al blog