Ghid pentru cuvântul cheie volatil în Java

1. Prezentare generală

În absența sincronizărilor necesare, compilatorul, runtime-ul sau procesoarele pot aplica tot felul de optimizări. Chiar dacă aceste optimizări sunt benefice de cele mai multe ori, uneori pot cauza probleme subtile.

Memorarea în cache și reordonarea se numără printre acele optimizări care ne pot surprinde în contexte concurente. Java și JVM oferă multe modalități de a controla ordinea memoriei, iar cuvântul cheie volatil este unul dintre ele.

În acest articol, ne vom concentra asupra acestui concept fundamental, dar adesea neînțeles, în limbajul Java - cuvântul cheie volatil . În primul rând, vom începe cu un pic de fundal despre modul în care funcționează arhitectura computerului de bază și apoi ne vom familiariza cu ordinea memoriei în Java.

2. Arhitectură multiprocesor partajat

Procesorii sunt responsabili pentru executarea instrucțiunilor programului. Prin urmare, trebuie să recupereze atât instrucțiunile programului, cât și datele necesare din RAM.

Deoarece procesoarele sunt capabile să efectueze un număr semnificativ de instrucțiuni pe secundă, preluarea din RAM nu este ideală pentru ele. Pentru a îmbunătăți această situație, procesoarele folosesc trucuri precum Executarea în afara ordinii, Predicția ramurilor, Execuția speculativă și, desigur, Caching-ul.

Aici intră în joc următoarea ierarhie a memoriei:

Pe măsură ce diferite nuclee execută mai multe instrucțiuni și manipulează mai multe date, acestea își completează cache-urile cu date și instrucțiuni mai relevante. Acest lucru va îmbunătăți performanța generală în detrimentul introducerii provocărilor de coerență a cache-ului .

Pur și simplu, ar trebui să ne gândim de două ori la ceea ce se întâmplă atunci când un fir actualizează o valoare în cache.

3. Când se utilizează volatile

Pentru a extinde mai mult asupra coerenței cache-ului, să împrumutăm un exemplu din cartea Java simultaneitate în practică:

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; } }

TaskRunner Clasa susține două variabile simple. În metoda sa principală, creează un alt fir care se rotește pe variabila gata , atâta timp cât este falsă. Când variabila devine adevărată, firul va imprima pur și simplu variabila numerică .

Mulți se pot aștepta ca acest program să imprime pur și simplu 42 după o scurtă întârziere. Cu toate acestea, în realitate, întârzierea poate fi mult mai lungă. Poate chiar să atârne pentru totdeauna sau chiar să imprime zero!

Cauza acestor anomalii este lipsa unei vizibilități și reordonări adecvate a memoriei . Să le evaluăm mai detaliat.

3.1. Vizibilitatea memoriei

În acest exemplu simplu, avem două fire de aplicație: firul principal și firul cititorului. Să ne imaginăm un scenariu în care sistemul de operare programează acele fire pe două nuclee CPU diferite, unde:

  • Firul principal are copia variabilelor gata și numerice în memoria cache principală
  • Firul cititorului se termină și cu copiile sale
  • Firul principal actualizează valorile memorate în cache

Pe majoritatea procesoarelor moderne, cererile de scriere nu vor fi aplicate imediat după ce sunt emise. De fapt, procesoarele tind să coadă acele scrieri într-un tampon special de scriere . După un timp, aceștia vor aplica toate aceste scrisori în memoria principală dintr-o dată.

Cu toate acestea, atunci când firul principal actualizează numărul și variabilele gata , nu există nicio garanție cu privire la ceea ce poate vedea firul cititorului. Cu alte cuvinte, firul cititorului poate vedea valoarea actualizată imediat, sau cu o anumită întârziere, sau niciodată!

Această vizibilitate a memoriei poate cauza probleme de viață în programele care se bazează pe vizibilitate.

3.2. Reordonare

Pentru a înrăutăți lucrurile, firul cititorului poate vedea aceste scrieri în orice altă ordine decât ordinea programului real . De exemplu, deoarece actualizăm prima dată variabila numerică :

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

Ne putem aștepta ca firul cititorului să imprime 42. Cu toate acestea, este de fapt posibil să vedeți zero ca valoare imprimată!

Reordonarea este o tehnică de optimizare pentru îmbunătățirea performanței. În mod interesant, diferite componente pot aplica această optimizare:

  • Procesorul își poate spăla bufferul de scriere în orice altă ordine decât cea a programului
  • Procesorul poate aplica o tehnică de execuție în afara comenzii
  • Compilatorul JIT se poate optimiza prin reordonare

3.3. ordinea de memorie volatilă

Pentru a ne asigura că actualizările variabilelor se propagă previzibil către alte fire, ar trebui să aplicăm modificatorul volatil acelor variabile:

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

În acest fel, comunicăm cu runtime și procesor pentru a nu reordona nicio instrucțiune care implică variabila volatilă . De asemenea, procesoarele înțeleg că ar trebui să elimine imediat orice actualizări ale acestor variabile.

4. volatil și sincronizare Thread

Pentru aplicațiile cu mai multe fire, trebuie să asigurăm câteva reguli pentru un comportament consecvent:

  • Excludere reciprocă - doar un singur fir execută o secțiune critică la un moment dat
  • Vizibilitate - modificările făcute de un fir la datele partajate sunt vizibile pentru alte fire pentru a menține coerența datelor

metodele și blocurile sincronizate oferă ambele proprietăți de mai sus, cu prețul performanței aplicației.

volatile este un cuvânt cheie destul de util, deoarece poate contribui la asigurarea aspectului vizibilității schimbării datelor, fără a asigura, desigur, excluderea reciprocă . Astfel, este util în locurile în care suntem în regulă cu mai multe fire care execută un bloc de cod în paralel, dar trebuie să asigurăm proprietatea de vizibilitate.

5. Se întâmplă - Înainte de a comanda

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.

Folosind această semantică, putem defini doar câteva dintre variabilele din clasa noastră ca fiind volatile și putem optimiza garanția vizibilității.

6. Concluzie

În acest tutorial, am explorat mai multe despre cuvântul cheie volatil și capacitățile acestuia, precum și îmbunătățirile aduse acestuia începând cu Java 5.

Ca întotdeauna, exemplele de cod pot fi găsite pe GitHub.