Guida alla parola chiave volatile in Java

1. Panoramica

In assenza delle sincronizzazioni necessarie, il compilatore, il runtime oi processori possono applicare tutti i tipi di ottimizzazioni. Anche se queste ottimizzazioni sono utili per la maggior parte del tempo, a volte possono causare problemi impercettibili.

La memorizzazione nella cache e il riordino sono tra quelle ottimizzazioni che potrebbero sorprenderci in contesti concorrenti. Java e JVM forniscono molti modi per controllare l'ordine della memoria e la parola chiave volatile è uno di questi.

In questo articolo, ci concentreremo su questo concetto fondamentale ma spesso frainteso nel linguaggio Java: la parola chiave volatile . Innanzitutto, inizieremo con un po 'di background su come funziona l'architettura del computer sottostante, quindi acquisiremo familiarità con l'ordine della memoria in Java.

2. Architettura multiprocessore condivisa

I processori sono responsabili dell'esecuzione delle istruzioni del programma. Pertanto, devono recuperare sia le istruzioni del programma che i dati richiesti dalla RAM.

Poiché le CPU sono in grado di eseguire un numero significativo di istruzioni al secondo, il recupero dalla RAM non è l'ideale per loro. Per migliorare questa situazione, i processori utilizzano trucchi come Out of Order Execution, Branch Prediction, Speculative Execution e, ovviamente, Caching.

È qui che entra in gioco la seguente gerarchia di memoria:

Poiché diversi core eseguono più istruzioni e manipolano più dati, riempiono le loro cache con dati e istruzioni più rilevanti. Ciò migliorerà le prestazioni complessive a scapito dell'introduzione di sfide di coerenza della cache .

In parole povere, dovremmo pensarci due volte su cosa succede quando un thread aggiorna un valore memorizzato nella cache.

3. Quando utilizzare volatile

Per espandere maggiormente la coerenza della cache, prendiamo in prestito un esempio dal libro Java Concurrency in Practice:

public class TaskRunner { private static int number; private static boolean ready; private static class Reader extends Thread { @Override public void run() { while (!ready) { Thread.yield(); } System.out.println(number); } } public static void main(String[] args) { new Reader().start(); number = 42; ready = true; } }

La classe TaskRunner mantiene due semplici variabili. Nel suo metodo principale, crea un altro thread che gira sulla variabile ready fintanto che è falsa. Quando la variabile diventa vera, il thread stamperà semplicemente la variabile numerica .

Molti potrebbero aspettarsi che questo programma stampi semplicemente 42 dopo un breve ritardo. Tuttavia, in realtà, il ritardo potrebbe essere molto più lungo. Potrebbe persino rimanere sospeso per sempre o addirittura stampare zero!

La causa di queste anomalie è la mancanza di una corretta visibilità e riordino della memoria . Valutiamoli in modo più dettagliato.

3.1. Visibilità della memoria

In questo semplice esempio, abbiamo due thread dell'applicazione: il thread principale e il thread del lettore. Immaginiamo uno scenario in cui il sistema operativo pianifica quei thread su due diversi core della CPU, dove:

  • Il thread principale ha la sua copia delle variabili ready e number nella sua cache principale
  • Anche il thread del lettore finisce con le sue copie
  • Il thread principale aggiorna i valori memorizzati nella cache

Sulla maggior parte dei processori moderni, le richieste di scrittura non verranno applicate subito dopo essere state emesse. In effetti, i processori tendono a mettere in coda quelle scritture in uno speciale buffer di scrittura . Dopo un po ', applicheranno quelle scritture alla memoria principale tutte in una volta.

Detto questo, quando il thread principale aggiorna il numero e le variabili pronte , non vi è alcuna garanzia su ciò che il thread del lettore potrebbe vedere. In altre parole, il thread del lettore potrebbe vedere il valore aggiornato subito, o con un certo ritardo, o mai del tutto!

Questa visibilità della memoria può causare problemi di vitalità nei programmi che fanno affidamento sulla visibilità.

3.2. Riordino

A peggiorare le cose, il thread del lettore potrebbe vedere quelle scritture in un ordine diverso dall'ordine effettivo del programma . Ad esempio, poiché aggiorniamo per la prima volta la variabile numerica :

public static void main(String[] args) { new Reader().start(); number = 42; ready = true; }

Potremmo aspettarci che il thread del lettore stampi 42. Tuttavia, è effettivamente possibile vedere zero come valore stampato!

Il riordino è una tecnica di ottimizzazione per il miglioramento delle prestazioni. È interessante notare che diversi componenti possono applicare questa ottimizzazione:

  • Il processore può svuotare il suo buffer di scrittura in qualsiasi ordine diverso dall'ordine del programma
  • Il processore può applicare una tecnica di esecuzione fuori servizio
  • Il compilatore JIT può ottimizzare tramite il riordino

3.3. Ordine di memoria volatile

Per garantire che gli aggiornamenti alle variabili si propagino in modo prevedibile ad altri thread, dovremmo applicare il modificatore volatile a tali variabili:

public class TaskRunner { private volatile static int number; private volatile static boolean ready; // same as before }

In questo modo, comunichiamo con runtime e processore per non riordinare alcuna istruzione che coinvolga la variabile volatile . Inoltre, i processori capiscono che dovrebbero scaricare immediatamente eventuali aggiornamenti a queste variabili.

4. volatile e sincronizzazione dei thread

Per le applicazioni multithread, dobbiamo garantire un paio di regole per un comportamento coerente:

  • Esclusione reciproca: solo un thread esegue una sezione critica alla volta
  • Visibilità: le modifiche apportate da un thread ai dati condivisi sono visibili ad altri thread per mantenere la coerenza dei dati

metodi e blocchi sincronizzati forniscono entrambe le proprietà di cui sopra, a scapito delle prestazioni dell'applicazione.

volatile è una parola chiave abbastanza utile perché può aiutare a garantire l'aspetto della visibilità della modifica dei dati senza, ovviamente, fornire l'esclusione reciproca . Pertanto, è utile nei luoghi in cui siamo d'accordo con più thread che eseguono un blocco di codice in parallelo, ma dobbiamo garantire la proprietà di visibilità.

5. Succede prima di ordinare

The memory visibility effects of volatile variables extend beyond the volatile variables themselves.

To make matters more concrete, let's suppose thread A writes to a volatile variable, and then thread B reads the same volatile variable. In such cases, the values that were visible to A before writing the volatile variable will be visible to B after reading the volatile variable:

Technically speaking, any write to a volatile field happens before every subsequent read of the same field. This is the volatile variable rule of the Java Memory Model (JMM).

5.1. Piggybacking

Because of the strength of the happens-before memory ordering, sometimes we can piggyback on the visibility properties of another volatile variable. For instance, in our particular example, we just need to mark the ready variable as volatile:

public class TaskRunner { private static int number; // not volatile private volatile static boolean ready; // same as before }

Anything prior to writing true to the ready variable is visible to anything after reading the ready variable. Therefore, the number variable piggybacks on the memory visibility enforced by the ready variable. Put simply, even though it's not a volatile variable, it is exhibiting a volatile behavior.

Facendo uso di queste semantiche, possiamo definire volatili solo alcune delle variabili della nostra classe e ottimizzare la visibilità garantita.

6. Conclusione

In questo tutorial, abbiamo esplorato di più sulla parola chiave volatile e sulle sue capacità, nonché sui miglioramenti apportati a partire da Java 5.

Come sempre, gli esempi di codice possono essere trovati su GitHub.