Java 101: comprensione dei thread Java, parte 2: sincronizzazione dei thread

Il mese scorso vi ho mostrato quanto sia facile creare oggetti thread, avviare thread che si associano a quegli oggetti chiamando Threadil start()metodo di ed eseguire semplici operazioni sui thread chiamando altri Threadmetodi come i tre join()metodi di overload . Questo mese, tuttavia, ci occuperemo di programmi Java multithread, che sono più complessi.

Capire i thread Java: leggi l'intera serie

  • Parte 1: presentazione di thread ed eseguibili
  • Parte 2: sincronizzazione dei thread
  • Parte 3: pianificazione dei thread, attesa / notifica e interruzione del thread
  • Parte 4: gruppi di thread, volatilità, variabili locali del thread, timer e morte del thread

I programmi multithread spesso funzionano in modo irregolare o producono valori errati a causa della mancanza di sincronizzazione dei thread . La sincronizzazione è l'atto di serializzare (o ordinare uno alla volta) l'accesso ai thread a quelle sequenze di codice che consentono a più thread di manipolare variabili di campo di classe e istanza e altre risorse condivise. Io chiamo queste sequenze di codice sezioni di codice critiche. . La colonna di questo mese riguarda l'uso della sincronizzazione per serializzare l'accesso dei thread alle sezioni di codice critiche nei programmi.

Inizio con un esempio che illustra il motivo per cui alcuni programmi multithread devono utilizzare la sincronizzazione. Successivamente esplorerò il meccanismo di sincronizzazione di Java in termini di monitor e blocchi e la synchronizedparola chiave. Poiché l'utilizzo errato del meccanismo di sincronizzazione ne nega i vantaggi, concludo esaminando due problemi che derivano da tale uso improprio.

Suggerimento: a differenza delle variabili di campo di classe e istanza, i thread non possono condividere variabili e parametri locali. Il motivo: variabili e parametri locali vengono allocati sullo stack di chiamate al metodo di un thread. Di conseguenza, ogni thread riceve la propria copia di quelle variabili. Al contrario, i thread possono condividere campi di classe e campi di istanza perché tali variabili non vengono allocate nello stack di chiamate al metodo di un thread. Al contrario, si allocano nella memoria heap condivisa, come parte di classi (campi classe) o oggetti (campi istanza).

La necessità di sincronizzazione

Perché abbiamo bisogno della sincronizzazione? Per una risposta, considera questo esempio: scrivi un programma Java che utilizza una coppia di thread per simulare il prelievo / deposito di transazioni finanziarie. In quel programma, un thread esegue i depositi mentre l'altro esegue i prelievi. Ogni thread manipola una coppia di variabili condivise, variabili di campo di classe e istanza, che identifica il nome e l'importo della transazione finanziaria. Per una transazione finanziaria corretta, ogni thread deve terminare l'assegnazione dei valori alle variabili namee amount(e stampare quei valori, per simulare il salvataggio della transazione) prima che l'altro thread inizi ad assegnare i valori a namee amount(e anche a stampare quei valori). Dopo un po 'di lavoro, ti ritroverai con un codice sorgente che assomiglia al listato 1:

Listato 1. NeedForSynchronizationDemo.java

// NeedForSynchronizationDemo.java class NeedForSynchronizationDemo { public static void main (String [] args) { FinTrans ft = new FinTrans (); TransThread tt1 = new TransThread (ft, "Deposit Thread"); TransThread tt2 = new TransThread (ft, "Withdrawal Thread"); tt1.start (); tt2.start (); } } class FinTrans { public static String transName; public static double amount; } class TransThread extends Thread { private FinTrans ft; TransThread (FinTrans ft, String name) { super (name); // Save thread's name this.ft = ft; // Save reference to financial transaction object } public void run () { for (int i = 0; i < 100; i++) { if (getName ().equals ("Deposit Thread")) { // Start of deposit thread's critical code section ft.transName = "Deposit"; try { Thread.sleep ((int) (Math.random () * 1000)); } catch (InterruptedException e) { } ft.amount = 2000.0; System.out.println (ft.transName + " " + ft.amount); // End of deposit thread's critical code section } else { // Start of withdrawal thread's critical code section ft.transName = "Withdrawal"; try { Thread.sleep ((int) (Math.random () * 1000)); } catch (InterruptedException e) { } ft.amount = 250.0; System.out.println (ft.transName + " " + ft.amount); // End of withdrawal thread's critical code section } } } }

NeedForSynchronizationDemoIl codice sorgente di ha due sezioni di codice critiche: una accessibile al thread di deposito e l'altra accessibile al thread di ritiro. All'interno della sezione del codice critico del thread di deposito, quel thread assegna il DepositStringriferimento dell'oggetto alla variabile condivisa transNamee assegna 2000.0alla variabile condivisa amount. Allo stesso modo, all'interno della sezione del codice critico del thread di ritiro, quel thread assegna il WithdrawalStringriferimento all'oggetto transNamee lo assegna 250.0a amount. Dopo le assegnazioni di ogni thread, il contenuto di quelle variabili viene stampato. Quando esegui NeedForSynchronizationDemo, potresti aspettarti un output simile a un elenco di righe Withdrawal 250.0e intervallate Deposit 2000.0. Invece, ricevi un output simile al seguente:

Withdrawal 250.0 Withdrawal 2000.0 Deposit 2000.0 Deposit 2000.0 Deposit 250.0

Il programma ha sicuramente un problema. Il thread di prelievo non dovrebbe simulare prelievi di $ 2000 e il thread di deposito non dovrebbe simulare depositi di $ 250. Ogni thread produce un output incoerente. Cosa causa queste incongruenze? Considera quanto segue:

  • Su una macchina a processore singolo, i thread condividono il processore. Di conseguenza, un thread può essere eseguito solo per un determinato periodo di tempo. In quel momento, la JVM / il sistema operativo sospende l'esecuzione di quel thread e consente l'esecuzione di un altro thread: una manifestazione della pianificazione dei thread, un argomento di cui discuto nella Parte 3. Su una macchina multiprocessore, a seconda del numero di thread e processori, ogni thread può avere il proprio processore.
  • Su una macchina a processore singolo, il periodo di esecuzione di un thread potrebbe non durare abbastanza a lungo da consentire a tale thread di terminare l'esecuzione della sezione di codice critico prima che un altro thread inizi a eseguire la propria sezione di codice critico. Su una macchina multiprocessore, i thread possono eseguire simultaneamente codice nelle rispettive sezioni di codice critiche. Tuttavia, potrebbero immettere le sezioni di codice critiche in momenti diversi.
  • Su macchine a processore singolo o multiprocessore, può verificarsi il seguente scenario: Il thread A assegna un valore alla variabile condivisa X nella sua sezione di codice critico e decide di eseguire un'operazione di input / output che richiede 100 millisecondi. Il thread B quindi entra nella sua sezione di codice critico, assegna un valore diverso a X, esegue un'operazione di input / output di 50 millisecondi e assegna valori alle variabili condivise Y e Z. L'operazione di input / output del thread A viene completata e quel thread assegna la propria valori su Y e Z. Poiché X contiene un valore assegnato da B, mentre Y e Z contengono valori assegnati da A, ne risulta un'incoerenza.

Come nasce un'incoerenza NeedForSynchronizationDemo? Supponiamo che il thread di deposito venga eseguito ft.transName = "Deposit";e quindi chiamato Thread.sleep(). A quel punto, il thread di deposito cede il controllo del processore per il periodo di tempo in cui deve dormire e il thread di ritiro viene eseguito. Supponiamo che il thread di deposito dorma per 500 millisecondi (un valore selezionato casualmente, grazie a Math.random(), compreso tra 0 e 999 millisecondi; esplorerò Mathe il suo random()metodo in un articolo futuro). Durante il tempo di sospensione del thread di deposito, il thread di ritiro viene eseguito ft.transName = "Withdrawal";, resta inattivo per 50 millisecondi (il valore di sospensione selezionato casualmente del thread di ritiro), si sveglia, esegue ft.amount = 250.0;ed esegue, System.out.println (ft.transName + " " + ft.amount);tutto prima che il thread di deposito si risvegli. Di conseguenza, il thread di ritiro viene stampatoWithdrawal 250.0, che è corretto. Quando il thread di deposito si attiva, viene eseguito ft.amount = 2000.0;, seguito da System.out.println (ft.transName + " " + ft.amount);. Questa volta Withdrawal 2000.0stampa, il che non è corretto. Sebbene il thread di deposito abbia precedentemente assegnato il "Deposit"riferimento a transName, tale riferimento è successivamente scomparso quando il thread di prelievo ha assegnato il "Withdrawal"riferimento a quella variabile condivisa. Quando il thread di deposito si è risvegliato, non è riuscito a ripristinare il riferimento corretto a transName, ma ha continuato la sua esecuzione assegnando 2000.0a amount. Sebbene nessuna delle due variabili abbia un valore non valido, i valori combinati di entrambe le variabili rappresentano un'incongruenza. In questo caso, i loro valori rappresentano un tentativo di ritirare, 000.

Molto tempo fa, gli informatici hanno inventato un termine per descrivere i comportamenti combinati di più thread che portano a incongruenze. Questo termine è una condizione di competizione: l'azione di ogni thread che corre per completare la sua sezione di codice critico prima che qualche altro thread entri nella stessa sezione di codice critico. ComeNeedForSynchronizationDemodimostra che gli ordini di esecuzione dei thread sono imprevedibili. Non vi è alcuna garanzia che un thread possa completare la sua sezione di codice critico prima che un altro thread entri in quella sezione. Quindi, abbiamo una condizione di gara, che causa incongruenze. Per evitare condizioni di competizione, ogni thread deve completare la propria sezione di codice critico prima che un altro thread entri nella stessa sezione di codice critico o in un'altra sezione di codice critico correlato che manipola le stesse variabili o risorse condivise. Senza mezzi per serializzare l'accesso, ovvero consentire l'accesso a un solo thread alla volta, a una sezione di codice critica, non è possibile prevenire le condizioni di competizione o le incongruenze. Fortunatamente, Java fornisce un modo per serializzare l'accesso ai thread: attraverso il suo meccanismo di sincronizzazione.

Nota : dei tipi Java, solo le variabili a virgola mobile a precisione doppia e intera lunga sono soggette a incongruenze. Perché? Una JVM a 32 bit accede in genere a una variabile intera lunga a 64 bit oa una variabile a virgola mobile a doppia precisione a 64 bit in due passaggi adiacenti di 32 bit. Un thread potrebbe completare il primo passaggio e quindi attendere mentre un altro thread esegue entrambi i passaggi. Quindi, il primo thread potrebbe svegliarsi e completare il secondo passaggio, producendo una variabile con un valore diverso dal valore del primo o del secondo thread. Di conseguenza, se almeno un thread può modificare una variabile intera lunga o una variabile a virgola mobile a precisione doppia, tutti i thread che leggono e / o modificano quella variabile devono utilizzare la sincronizzazione per serializzare l'accesso alla variabile.

Meccanismo di sincronizzazione di Java

Java fornisce un meccanismo di sincronizzazione per impedire a più di un thread di eseguire codice in una o più sezioni di codice critiche in qualsiasi momento. Questo meccanismo si basa sui concetti di monitor e serrature. Pensa a un monitor come a un involucro protettivo attorno a una sezione di codice critica e a un lucchettocome entità software che un monitor utilizza per impedire a più thread di entrare nel monitor. L'idea è questa: quando un thread desidera entrare in una sezione di codice critico controllata dal monitor, quel thread deve acquisire il blocco associato a un oggetto che si associa al monitor. (Ogni oggetto ha il proprio blocco.) Se qualche altro thread mantiene quel blocco, la JVM forza il thread richiedente ad attendere in un'area di attesa associata al monitor / blocco. Quando il thread nel monitor rilascia il blocco, la JVM rimuove il thread in attesa dall'area di attesa del monitor e consente a quel thread di acquisire il blocco e procedere alla sezione del codice critico del monitor.

Per lavorare con monitor / blocchi, la JVM fornisce le istruzioni monitorentere monitorexit. Fortunatamente, non è necessario lavorare a un livello così basso. Invece, è possibile utilizzare la synchronizedparola chiave Java nel contesto synchronizeddell'istruzione e dei metodi sincronizzati.

L'istruzione sincronizzata

Alcune sezioni di codice critiche occupano piccole porzioni dei loro metodi di inclusione. Per proteggere l'accesso di più thread a tali sezioni di codice critiche, utilizzare l' synchronizedistruzione. Quella dichiarazione ha la seguente sintassi:

'synchronized' '(' objectidentifier ')' '{' // Critical code section '}'

L' synchronizedistruzione inizia con la parola chiave synchronizede continua con un identificatore di oggetto, che appare tra una coppia di parentesi tonde. L' identificatore di oggetto fa riferimento a un oggetto il cui blocco si associa al monitor synchronizedrappresentato dall'istruzione. Infine, la sezione di codice critico delle istruzioni Java appare tra una coppia di caratteri di parentesi graffa. Come interpreti l' synchronizedaffermazione? Considera il seguente frammento di codice:

synchronized ("sync object") { // Access shared variables and other shared resources }