W3docs

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 stop

Questo 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:

  1. Visibilità. Una scrittura su un campo volatile è garantita essere visibile alle letture successive in qualsiasi altro thread.
  2. Atomicità di lettura e scrittura — ma solo per il campo stesso. Una lettura/scrittura volatile long e volatile double è atomica; senza volatile, una lettura/scrittura a 64 bit può essere "strappata" su una JVM a 32 bit.
  3. Ordinamento (happens-before). Tutto ciò che è accaduto prima di una scrittura volatile nel thread che scrive è visibile a tutto ciò che accade dopo la corrispondente lettura volatile nel thread che legge. Questa è la parte che non si vede nella sintassi ma è la garanzia più potente.
  4. Nessun riordinamento attorno all'accesso. Il compilatore e la CPU non possono spostare letture/scritture ordinarie oltre un accesso volatile in 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 BROKEN

volatile 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 volatile contemporaneamente; 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 volatile separato 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.

java— editable, runs on the server

Cosa ricavare dall'esecuzione:

  • Il worker PLAIN spesso non si fermava entro la finestra del watchdog. Il thread principale ha scritto stop = true; il thread worker, con la sua copia di stop memorizzata nel registro, non se ne è mai accorto. Aggiungi volatile e il bug scompare. Questo è il problema di visibilità — ogni programma multithread ne ha una versione.
  • Il worker VOLATILE si è fermato entro un microsecondo o due dalla scrittura. Questo è il costo della barriera di memoria che la JVM emette per una coppia di scrittura/lettura volatile — singole cifre di microsecondi nel caso peggiore, e spesso meno. Economico per il caso d'uso giusto.
  • Il contatore VOLATILE++ era inferiore a 200.000. volatile non trasforma n++ in un'operazione atomica — è ancora read-modify-write, e due thread possono leggere entrambi 42 e memorizzare entrambi 43. Questo è il caso d'uso errato più comune di volatile. Per aggiornamenti composti, usa AtomicInteger (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 un volatile caldo 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 con volatile. 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.

Esercitati

Pratica
Dichiari `private volatile int counter = 0;` e chiami `counter++` da più thread. Dopo che 4 thread eseguono ciascuno 100.000 incrementi, quale valore contiene tipicamente `counter`?
Dichiari `private volatile int counter = 0;` e chiami `counter++` da più thread. Dopo che 4 thread eseguono ciascuno 100.000 incrementi, quale valore contiene tipicamente `counter`?
Was this page helpful?