W3docs

Introduzione al Multithreading in Java

Cosa sono i thread, perché usarli in Java e i compromessi della programmazione concorrente.

Ogni programma Java scritto finora aveva un solo thread di esecuzione — un cursore che avanzava nel bytecode, una variabile sullo stack, una chiamata a metodo alla volta. Questo è il thread "main" che la JVM avvia per te. Il multithreading consiste nell'avere la JVM che esegue più cursori contemporaneamente, condividendo lo stesso heap. Due thread possono trovarsi contemporaneamente all'interno di due metodi diversi su due oggetti diversi — ed è questa sia la potenza che il pericolo.

Le CPU moderne hanno molti core. Un programma a thread singolo lascia tutti i core tranne uno inattivi. Un server web che gestisce una richiesta alla volta non riesce a sfruttare una macchina a 16 core più di quanto potrebbe sfruttarne una a 1 core. Il motivo principale per cui esiste il multithreading è mettere al lavoro quei core e mantenere il programma reattivo quando una sua parte è in attesa (del disco, della rete, dell'utente).

Cosa è effettivamente un thread

Un thread Java è la combinazione di due cose:

  • Un thread a livello di OS che il sistema operativo pianifica su un core della CPU. Ha un program counter, un insieme di registri e uno stack nativo. Il sistema operativo lo alterna nel tempo con tutti gli altri thread eseguibili sulla macchina.
  • Un oggetto Java di tipo java.lang.Thread. Contiene un nome, una priorità, un flag daemon e — soprattutto — un riferimento al Runnable il cui metodo run() eseguirà.

Quando chiami thread.start(), la JVM chiede al sistema operativo di creare un nuovo thread nativo che, alla prima pianificazione, chiamerà il tuo metodo run(). Il thread originale continua immediatamente; ora i due vengono eseguiti in modo concorrente.

public static void main(String[] args) {
  System.out.println("main: hello from " + Thread.currentThread().getName());

  Thread t = new Thread(() -> {
    System.out.println("worker: hello from " + Thread.currentThread().getName());
  }, "worker-1");

  t.start();                                         // worker runs concurrently with main
  System.out.println("main: continuing");
}

L'interleaving dell'output non è deterministico — il sistema operativo decide quale thread viene eseguito per primo, e quella decisione cambia tra un'esecuzione e l'altra. Questo non determinismo è il fatto centrale della programmazione concorrente.

Perché usare i thread

Due motivazioni distinte, spesso confuse:

  • Throughput. Hai lavoro CPU-bound — ridimensionamento di immagini, parsing, compressione. Un thread usa un core; otto thread usano otto core e terminano circa otto volte più velocemente. Questo è il parallelismo.
  • Reattività. Hai un thread che altrimenti si bloccherebbe — in attesa di una risposta di rete, di un database, di un clic dell'utente. Eseguire quel lavoro su un thread separato consente al resto del programma di continuare a fare cose utili mentre aspetta. Questa è la concorrenza.

La maggior parte dei programmi reali ha bisogno di entrambe. Un server web usa molti thread in modo che una richiesta lenta non blocchi le altre (concorrenza) e in modo che molte richieste veloci possano essere gestite in parallelo tra i core (parallelismo).

Perché i thread sono difficili

I thread condividono la memoria. La stessa HashMap, ArrayList o int counter++ può essere toccata da due thread nello stesso istante — e la JVM, le cache della CPU e il compilatore sono tutti autorizzati a riordinare le operazioni in modi sorprendenti. I tre problemi che il codice multithread incontra continuamente:

  • Race condition. Due thread leggono-modificano-scrivono la stessa variabile; uno degli aggiornamenti viene perso. counter++ non è atomico — è leggi counter, aggiungi uno, scrivi counter. Due thread possono leggere entrambi lo stesso valore e scrivere entrambi value + 1, perdendo così un incremento. La soluzione è la sincronizzazione — forzare la lettura-modifica-scrittura ad avvenire come un unico passo indivisibile.
  • Visibilità. Un thread scrive un campo; un altro thread lo legge e vede il vecchio valore, perché ciascun thread ha la propria cache della CPU e non esiste alcuna regola che forzi la propagazione della scrittura senza una memory barrier. Ecco perché esistono volatile, synchronized e java.util.concurrent.
  • Deadlock. Il thread A detiene il lock X e attende il lock Y; il thread B detiene il lock Y e attende il lock X. Nessuno dei due procede mai. Il programma si blocca senza eccezioni e senza righe di log. Il capitolo sul deadlock mostra come rilevarlo ed evitarlo.

Il resto di questa parte del libro riguarda in gran parte la prevenzione di questi tre tipi di errori mantenendo il guadagno in termini di throughput.

Il vocabolario che incontrerai

Alcuni termini che compaiono ovunque e che i capitoli successivi danno per scontati:

TermineSignificato
ConcorrenzaPiù task che fanno progressi nello stesso periodo di tempo. Possono o non possono essere eseguiti letteralmente nello stesso istante.
ParallelismoPiù task che vengono eseguiti letteralmente nello stesso istante su core diversi. Un sottoinsieme della concorrenza.
Mutua esclusioneSolo un thread è autorizzato all'interno di una sezione critica alla volta. Lock e synchronized la forniscono.
Memory modelLe regole che stabiliscono quando un thread ha la garanzia di vedere la scrittura di un altro thread. Definito dalla JLS, raffinato da JSR-133.
AtomicoUn'operazione che non può essere osservata a metà. O è avvenuta o non è avvenuta — nessuno stato intermedio visibile agli altri thread.
Thread-safeUna classe la cui API pubblica può essere chiamata da più thread senza sincronizzazione esterna e continua a comportarsi correttamente.

Thread daemon e la regola di uscita della JVM

Una cosa che sorprende i principianti: la JVM esce quando termina l'ultimo thread non-daemon. Il thread main è non-daemon. I thread creati con new Thread(...) sono non-daemon per default — quindi creare un thread worker mantiene la JVM in esecuzione finché quel worker non ritorna.

Puoi contrassegnare un thread come daemon con t.setDaemon(true) prima di start(). I thread daemon non mantengono la JVM in vita; quando tutti i thread non-daemon terminano, la JVM li interrompe bruscamente. Usa i daemon per lavori in background che dovrebbero terminare con il programma (un timer che interroga, un flusher di metriche) — mai per lavori il cui completamento ti serve effettivamente (scritture su file, commit di transazioni).

Thread vs. thread virtuali

Java 21 ha introdotto i thread virtuali, che appaiono identici a livello di API ma vengono pianificati dalla JVM su un piccolo pool di thread OS. Il modello mentale di questo capitolo — un Thread Java equivale a un thread OS — descrive i "platform thread", che è ciò che si ottiene con il costruttore new Thread(...) senza modifiche. I platform thread sono costosi: ognuno occupa circa 1 MB di stack nativo e il sistema operativo limita quanti ne può avere un processo, quindi vanno creati con cura. I thread virtuali sono economici — milioni vanno bene — e rendono nuovamente gratuito l'I/O bloccante. Li affrontiamo in Java Virtual Threads; fino ad allora, "thread" significa "platform thread."

Un esempio pratico: seriale vs. parallelo

Il programma seguente somma una porzione di lavoro CPU-intensivo in due modi — una volta sequenzialmente sul thread main, una volta diviso tra quattro thread — e stampa il tempo di clock reale per ciascuno. I numeri variano a seconda della macchina, ma la forma è la stessa ovunque: più thread, meno tempo di clock, finché non si esauriscono i core.

java— editable, runs on the server

Cosa osservare dall'esecuzione:

  • L'esecuzione seriale ha usato un core; quella parallela ne ha usati quattro. L'accelerazione è sub-lineare (più vicina a 3x che a 4x) perché il sistema operativo, il GC e altri thread JVM vogliono anch'essi tempo CPU. La legge di Amdahl in azione — una piccola frazione seriale (il loop finale di somma delle partizioni, l'avvio del loop) limita l'accelerazione.
  • Ogni worker ha scritto nel proprio slot di partials[]. Nessun thread ha mai toccato lo stesso indice, quindi non è stata necessaria alcuna sincronizzazione. Questa è la forma più semplice di parallelismo — partizionare i dati e lasciare che ogni thread possieda la propria partizione.
  • t.join() è il modo in cui main attende che worker-3 finisca. Senza i join, il loop leggerebbe partials prima che i worker avessero scritto, e parallelSum sarebbe sbagliato. join è l'unico pezzo di coordinamento tra thread che questo programma usa; i capitoli successivi ne introdurranno molti altri.
  • Il thread daemon in fondo non ha mantenuto la JVM in vita. Stava per dormire per 60 secondi, ma main è ritornato e la JVM è uscita, terminando il daemon a metà del suo sleep senza eseguire la sua istruzione di stampa. Questo è il contratto del daemon.
  • Thread.currentThread().getName() e il nome esplicito passato al costruttore Thread sono il modo per distinguere i thread nei log, nei profiler e nei thread dump. Dai sempre un nome ai tuoi thread — Thread-3 è inutile quando stai cercando di capire quale è bloccato.

Cosa viene dopo

Il prossimo capitolo, Java Thread Class, si concentra sull'oggetto Thread stesso — i suoi costruttori, la differenza tra estendere Thread e passargli un Runnable, e l'API per nome, priorità, stato daemon e interruzione.

Pratica

Pratica
Due thread eseguono ciascuno `counter++` 100.000 volte su un `int counter` condiviso che inizia a 0. Dopo entrambi i `join()`, qual è il valore più probabile di `counter`?
Due thread eseguono ciascuno `counter++` 100.000 volte su un `int counter` condiviso che inizia a 0. Dopo entrambi i `join()`, qual è il valore più probabile di `counter`?
Was this page helpful?