Sincronizzazione in Java
Coordina l'accesso allo stato condiviso tra thread in Java con la parola chiave synchronized e i lock intrinseci.
L'introduzione al multithreading metteva in guardia da tre tipi di errori: race condition, bug di visibilità e deadlock. synchronized è la prima risposta di Java ai primi due. Fornisce a un blocco di codice due garanzie contemporaneamente: mutua esclusione (un solo thread alla volta può accedere) e visibilità della memoria (le scritture effettuate all'interno del blocco da un thread sono visibili al thread successivo che vi entra). Queste due garanzie, combinate, sono sufficienti per rendere corretta una vasta quantità di codice multithread.
Questo capitolo è quello concettuale — cosa fa synchronized, cos'è un monitor intrinseco, quali tipi di race condition risolve e quali no. Il capitolo successivo, blocchi synchronized, mostra le forme sintattiche e come scegliere tra di esse.
La race condition che la parola chiave risolve
class Counter {
int n;
void increment() { n++; }
}
Counter c = new Counter();
// Thread A and Thread B both call c.increment() a million times.
// After both finish, what is c.n?n++ è una sola riga sorgente e tre operazioni bytecode: carica n, aggiungi 1, salva n. Se il thread A carica n=42, poi il thread B carica n=42 prima che A salvi, entrambi aggiungono 1 e salvano 43. Un incremento va perso. Eseguendo il programma un milione di volte per ciascun thread, c.n è costantemente inferiore a 2_000_000.
synchronized è la soluzione:
class Counter {
int n;
synchronized void increment() { n++; }
}Ora solo un thread alla volta esegue increment su questo Counter. L'altro attende fuori dalla porta. Risultato: c.n == 2_000_000, ad ogni esecuzione.
Cos'è un monitor
Ogni oggetto Java ha, nascosto nel JVM, un lock associato chiamato monitor intrinseco (o monitor lock). È semplicemente una struttura dati con due stati: un thread proprietario (o null) e una coda di attesa. Un thread che entra in un blocco synchronized:
- Tenta di acquisire il monitor dell'oggetto su cui è dichiarato
synchronized. - Se il monitor non ha proprietario, lo acquisisce (ora
owner == self) e procede. - Se il monitor è di proprietà di un altro thread, questo thread passa allo stato
BLOCKEDe si unisce alla coda di attesa. - Quando il proprietario esce dal blocco, il JVM rilascia il monitor e uno dei thread in attesa lo acquisisce.
Il monitor è per-oggetto. Due istanze di Counter hanno due monitor separati; i thread che operano su Counter diversi non si bloccano a vicenda. È importante — la sincronizzazione è sull'oggetto, non "sul metodo."
synchronized (someObject) {
// critical section: only one thread at a time
// holds someObject's monitor inside this block
}synchronized su un metodo di istanza è equivalente a synchronized (this). Su un metodo statico, equivale a synchronized (Counter.class) — il monitor dell'oggetto Class.
Visibilità, non solo esclusione
La mutua esclusione è la parte ovvia. La parte meno ovvia — e più importante — è la relazione happens-before che il JVM fornisce gratuitamente:
Tutto ciò che un thread fa prima di rilasciare un monitor è garantito essere visibile a qualsiasi thread che successivamente acquisisce lo stesso monitor.
Questa frase è ciò che rende synchronized corretto, non semplicemente "chi arriva prima viene servito prima." Senza di essa, due thread possono usare un blocco synchronized, concordare sulla mutua esclusione, e vedere comunque le scritture dell'altro nell'ordine sbagliato — perché le cache della CPU e il JIT altrimenti sono liberi di riordinare. La coppia rilascio/acquisizione installa una memory barrier che forza la CPU e il JIT a svuotare e ricaricare.
L'implicazione: qualsiasi campo che un programma multithread legge o scrive al di fuori di un blocco synchronized (e non tramite volatile, un atomic, o un'altra primitiva java.util.concurrent) non ha garanzie di visibilità. Un thread può scrivere done = true e un altro thread può vedere done = false per sempre. Torneremo su questo quando tratteremo volatile e il modello di memoria Java.
Cosa synchronized non risolve
Quattro cose che i principianti spesso si aspettano da synchronized ma che non offre:
- Non blocca i dati.
synchronized (list)non impedisce ad altro codice di toccarelist; impedisce a un altro thread di detenere lo stesso monitor. Se qualche altro percorso del codice opera sulistsenza acquisire lo stesso monitor, la protezione scompare. - Non si compone tra oggetti.
synchronized (a); synchronized (b);sono due acquisizioni separate; se un altro thread le acquisisce nell'ordine opposto si ha un deadlock. - Non accelera nulla. I lock sono puro overhead. Usali solo dove la correttezza lo richiede.
- Non risolve tutte le race condition. Le azioni composte come "controlla poi agisci" sono ancora soggette a race condition anche se ogni singola operazione è sincronizzata.
if (map.containsKey(k)) map.put(k, v)è errato anche secontainsKeyeputsono individualmente thread-safe — il gap tra le due chiamate è non protetto. UsaputIfAbsento un singolo blocco sincronizzato attorno a entrambe.
Rientranza
Il monitor intrinseco è rientrante: un thread che già detiene un monitor può entrare in un altro blocco synchronized sullo stesso oggetto senza bloccarsi su sé stesso. Ecco perché questo funziona:
class Account {
synchronized void deposit(int x) { balance += x; }
synchronized void transferTo(Account other, int x) {
deposit(-x); // re-enters same monitor — fine
other.deposit(x); // acquires other's monitor too
}
}Se i monitor non fossero rientranti, la chiamata interna a deposit si bloccherebbe sul monitor già detenuto dalla chiamata esterna — deadlock istantaneo su sé stesso. La rientranza rende sicuro chiamare un altro metodo sincronizzato sullo stesso oggetto.
Il rovescio della medaglia: ogni acquisizione richiede un rilascio corrispondente. Il JVM mantiene un contatore; il monitor viene rilasciato quando il contatore scende a zero.
Su cosa sincronizzarsi
Alcune regole che prevengono la maggior parte dei bug di uso errato dei lock:
- Sincronizza su un oggetto lock privato, non su
this. Il codice esterno può anche faresynchronized (yourInstance); questo consente a un chiamante di detenere il tuo lock per quanto tempo vuole. Unfinal Object lock = new Object();privato è tuo e nessun altro può prenderlo. - Non sincronizzare su letterali
Stringo primitive con boxing. Sono internati/memorizzati nella cache; due blocchisynchronized ("foo")in parti diverse del codice condividono un monitor con chiunque altro abbia usato"foo". - Non sincronizzare su un riferimento che può cambiare.
synchronized (myField)dovemyFieldpuò essere riassegnato corrisponde a due monitor diversi nel tempo. Il compilatore non può rilevarlo; il bug è silenzioso. - Mantieni la sezione critica piccola. Più fai all'interno di un blocco
synchronized, più a lungo tutti gli altri aspettano. Tieni il lock mentre modifichi lo stato condiviso, non mentre esegui le I/O circostanti.
Un esempio pratico: con e senza il lock
Il programma seguente esegue lo stesso workload con contatore condiviso in tre modi: senza sincronizzazione, con metodo synchronized e con blocco synchronized su un oggetto lock dedicato. I numeri mostrano che la prima forma perde aggiornamenti e le altre due no.
Cosa ricavare dall'esecuzione:
- La riga
unsafeha costantemente perso aggiornamenti — il valore finale era inferiore all'atteso1_000_000. Due thread che eseguonon++sono in gara sulla read-modify-write; alcuni incrementi scompaiono. Anche quando il test passa per fortuna in una singola esecuzione, il JIT, lo scheduler del SO o una CPU diversa finiranno per far emergere il problema. La mutazione non sincronizzata di un campo condiviso è errata. - Entrambe le varianti sicure hanno prodotto il conteggio esatto atteso, ogni volta. La mutua esclusione è la parte ovvia di ciò che fa
synchronized; la parte meno visibile è che il valore letto davalue()è l'ultimo scritto daincrement— questa è la garanzia di visibilità. Senza la coppia monitor, la lettura potrebbe legittimamente vedere una copia cached obsoleta. - I tempi di esecuzione per
sync methodesync blockerano entrambi notevolmente superiori aunsafe. I lock non sono gratuiti — ogni entrata/uscita esegue una memory barrier e (sotto contesa) un context switch del thread. Sincronizza dove la correttezza lo richiede; non spruzzare per "sicurezza." - La variante
sync block on private lockè quella usata nel codice di produzione. La formasync methodblocca suthis, che qualsiasi chiamante esterno può acquisire — possono affamarti tenendo il tuo stesso lock. Un oggetto lock privato che non esponi mai è solo tuo. - Il blocco di rientranza è stato eseguito senza deadlock.
outer()deteneva già il monitor dithis;inner()lo ha rientrato senza bloccarsi. Ecco perché un metodo sincronizzato può chiamare liberamente un altro metodo sincronizzato sullo stesso oggetto — senza rientranza, metà della libreria standard andrebbe in deadlock.
Cosa viene dopo
Il capitolo successivo, Blocchi Synchronized in Java, approfondisce le forme sintattiche — metodo, blocco, statico — e le regole per scegliere l'oggetto lock giusto. Per il coordinamento di livello superiore tra thread, vedi la comunicazione inter-thread (wait/notify).