La parola chiave volatile in Java
Usa volatile in Java per garantire visibilità e ordinamento delle letture e scritture di un campo tra i thread, senza acquisire un lock.
synchronized risolve due problemi contemporaneamente: esclusione reciproca e visibilità della memoria. A volte hai bisogno solo del secondo. Un semplice flag booleano che un thread imposta e un altro thread controlla non ha bisogno di accesso esclusivo — c'è solo un writer e uno o più reader, e il lavoro in sé non è composito. Usare un lock è eccessivo; non usarlo è sbagliato. volatile è la parola chiave che fornisce visibilità senza esclusione.
È uno strumento preciso. Usalo nel caso d'uso corretto ed è la primitiva thread-safe più veloce che Java offre. Usalo in modo errato — ad esempio per aggiornamenti composti come ++ — e avrai gli stessi bug che synchronized avrebbe dovuto correggere.
Il problema che volatile risolve
Senza alcuna sincronizzazione:
class Worker implements Runnable {
boolean stop = false; // plain field
public void run() {
while (!stop) { // hot loop
doWork();
}
}
}
Worker w = new Worker();
new Thread(w).start();
Thread.sleep(1000);
w.stop = true; // ask it to stopQuesto programma potrebbe non fermarsi mai. Il motivo: nulla nella JVM è obbligato a propagare stop dalla cache CPU di un thread alla memoria principale, e nulla è obbligato a invalidare la copia memorizzata del worker quando il thread principale scrive. Anche al JIT è permesso sollevare !stop fuori dal ciclo del tutto — il compilatore vede che il corpo non modifica stop, quindi memorizza il valore in un registro. Per sempre.
Questo è il bug di visibilità. La soluzione:
volatile boolean stop = false;Ora ogni scrittura su stop viene propagata alla memoria principale e ogni lettura proviene dalla memoria principale. Il JIT non può sollevare la lettura; il ciclo vede il nuovo valore entro microsecondi dall'aggiornamento del writer.
Cosa garantisce volatile
Quattro cose:
- Visibilità. Una scrittura su un campo
volatileè garantita essere visibile alle letture successive in qualsiasi altro thread. - Atomicità di lettura e scrittura — ma solo per il campo stesso. Una lettura/scrittura
volatile longevolatile doubleè atomica; senzavolatile, una lettura/scrittura a 64 bit può essere "strappata" su una JVM a 32 bit. - Ordinamento (happens-before). Tutto ciò che è accaduto prima di una scrittura
volatilenel thread che scrive è visibile a tutto ciò che accade dopo la corrispondente letturavolatilenel thread che legge. Questa è la parte che non si vede nella sintassi ma è la garanzia più potente. - Nessun riordinamento attorno all'accesso. Il compilatore e la CPU non possono spostare letture/scritture ordinarie oltre un accesso
volatilein nessuna direzione.
Il terzo punto — happens-before tramite una coppia di scrittura/lettura volatile — è talvolta chiamato la primitiva di pubblicazione più economica. Se scrivi un campo, poi volatile boolean ready = true, un altro thread che vede ready == true è garantito di vedere anche la scrittura precedente. Questo è il modo in cui molta inizializzazione thread-safe viene eseguita senza un lock.
Cosa volatile non garantisce
L'errore più comune:
volatile int counter = 0;
void increment() { counter++; } // STILL BROKENvolatile rende la lettura atomica e la scrittura atomica — ma counter++ è read-modify-write, ovvero tre operazioni. Due thread leggono entrambi 42, calcolano entrambi 43, scrivono entrambi 43. Un incremento è ancora perduto.
Per aggiornamenti composti, volatile non è sufficiente. Usa synchronized, un Lock, o — quasi sempre la risposta giusta — un AtomicInteger.
L'altra cosa che volatile non fa:
- Non fornisce esclusione reciproca. Due thread possono scrivere su un campo
volatilecontemporaneamente; il risultato è "uno di loro vince, l'altro è perso", che è la risposta giusta per uno stato simile a un flag e la risposta sbagliata per "unire questi due valori." - Non sincronizza più campi atomicamente insieme. Se il tuo invariante è "se A è true allora B deve essere 42", scrivere ciascuno su un campo
volatileseparato può lasciare un altro thread vedere il nuovo A e il vecchio B.
Il pattern di pubblicazione
L'applicazione più utile di volatile al di fuori del caso del flag di stop:
class LazyResource {
private Resource cached; // not volatile
private volatile boolean ready = false; // the publication flag
public Resource get() {
if (!ready) {
synchronized (this) {
if (!ready) {
cached = buildResource(); // expensive init
ready = true; // publishes cached
}
}
}
return cached;
}
}La scrittura volatile di ready = true pubblica la scrittura precedente su cached. Qualsiasi thread che successivamente legge ready == true è garantito di vedere cached completamente inizializzato. Il lock è conteso solo alla prima chiamata; le chiamate successive leggono semplicemente ready e saltano la sincronizzazione. Questo è l'idioma classico del double-checked locking, reso corretto da volatile.
Senza volatile su ready, l'ottimizzazione è rotta — il secondo thread può vedere ready == true e poi leggere cached come null. Con volatile, non può.
(L'alternativa moderna è semplicemente usare private final Resource cached = buildResource(); se l'inizializzazione anticipata va bene, o Supplier<Resource> lazy = Suppliers.memoize(...) dalla libreria di tua scelta. Il double-checked locking fatto a mano è raro nel codice moderno; il pattern vale la pena conoscere perché lo incontrerai.)
Quando volatile è esattamente giusto
Una piccola manciata di casi d'uso in cui volatile è la risposta migliore:
- Un flag di stato con un singolo writer letto da molti thread. Segnali di stop, flag "ready", numeri di versione della configurazione.
- Un riferimento a un valore immutabile che viene scambiato atomicamente. Cache che il writer ricostruisce e pubblica; i reader vedono il vecchio o il nuovo oggetto intero, mai uno a metà costruzione.
- Pubblicare il risultato di un'inizializzazione una tantum tramite il pattern double-checked-locking.
- Un timestamp o un numero di sequenza che un thread imposta e altri leggono — dove perdere l'aggiornamento più recente è accettabile ma il valore deve essere sempre autoconsistente.
Al di fuori di questi casi, di solito vuoi uno strumento di livello superiore. Non essere furbo con volatile — la sua semantica è troppo sottile per supportare lo stato generale.
volatile long e volatile double su JVM a 32 bit
Una particolarità storica. Su una JVM a 32 bit, una lettura/scrittura long o double non-volatile può essere suddivisa in due accessi a 32 bit. Due thread possono produrre un valore "strappato" — i 32 bit superiori di una scrittura e i 32 bit inferiori di un'altra. volatile long e volatile double sono garantiti atomici indipendentemente dalla dimensione della parola.
Le JVM moderne girano quasi sempre a 64 bit, e le JVM a 64 bit rendono comunque tutti i primitivi atomici, ma la regola è ancora nella specifica. Se hai un long/double condiviso tra thread, marcalo volatile (o usa AtomicLong/AtomicDouble).
Un esempio pratico: il bug del flag di stop
Il programma seguente esegue il worker prima con un boolean semplice (che di solito non si ferma mai), poi con un boolean volatile (che si ferma prontamente). Un watchdog interrompe il caso rotto dopo 2 secondi in modo che la demo termini.
Cosa ricavare dall'esecuzione:
- Il worker
PLAINspesso non si fermava entro la finestra del watchdog. Il thread principale ha scrittostop = true; il thread worker, con la sua copia distopmemorizzata nel registro, non se ne è mai accorto. Aggiungivolatilee il bug scompare. Questo è il problema di visibilità — ogni programma multithread ne ha una versione. - Il worker
VOLATILEsi è fermato entro un microsecondo o due dalla scrittura. Questo è il costo della barriera di memoria che la JVM emette per una coppia di scrittura/letturavolatile— singole cifre di microsecondi nel caso peggiore, e spesso meno. Economico per il caso d'uso giusto. - Il contatore
VOLATILE++era inferiore a200.000.volatilenon trasforman++in un'operazione atomica — è ancora read-modify-write, e due thread possono leggere entrambi42e memorizzare entrambi43. Questo è il caso d'uso errato più comune divolatile. Per aggiornamenti composti, usaAtomicInteger(prossimo capitolo). - Il costo di
volatileè piccolo ma reale — ogni lettura e ogni scrittura tocca la memoria principale ed emette una barriera di memoria. Per un flag che viene letto una volta per iterazione del ciclo, è nulla. Per un campo letto in un ciclo interno stretto, il costo della barriera si accumula. Se trovi unvolatilecaldo in un profilo, considera se la lettura può essere sollevata a una variabile locale e il corpo del ciclo eseguito su quella snapshot. volatileè anche il meccanismo di pubblicazione per un riferimento — scrivi i dati, poi scrivi il riferimento convolatile. Il thread che legge che vede il nuovo riferimento è garantito di vedere tutti i dati che il writer ha preparato. Questo è il mattone fondamentale del double-checked-locking e dei pattern di scambio di valori immutabili.
Cosa c'è dopo
Il prossimo capitolo, Java Atomic Variables, introduce AtomicInteger, AtomicLong, AtomicReference e il resto della famiglia java.util.concurrent.atomic — lo strumento giusto per "incrementare un contatore da molti thread" e qualsiasi altro aggiornamento composto lock-free.