Introducere în modelul de memorie Java

Modelul de memorie Java (JMM) descrie comportamentul firelor de execuție în mediul de rulare Java. Modelul de memorie face parte din semantica limbajului Java și descrie la ce se poate și nu ar trebui să se aștepte un programator atunci când dezvoltă software nu pentru o anumită mașină Java, ci pentru Java în ansamblu.

Modelul original de memorie Java (care, în special, se referă la „memoria percolocală”), dezvoltat în 1995, este considerat un eșec: multe optimizări nu pot fi făcute fără a pierde garanția siguranței codului. În special, există mai multe opțiuni pentru a scrie „single” cu mai multe fire:

  • fie fiecare act de accesare a unui singleton (chiar și atunci când obiectul a fost creat cu mult timp în urmă și nimic nu se poate schimba) va provoca o blocare inter-thread;
  • sau într-un anumit set de circumstanțe, sistemul va emite un singuratic neterminat;
  • sau într-un anumit set de circumstanțe, sistemul va crea doi singuratici;
  • sau designul va depinde de comportamentul unei anumite mașini.

Prin urmare, mecanismul de memorie a fost reproiectat. În 2005, odată cu lansarea Java 5, a fost prezentată o nouă abordare, care a fost îmbunătățită în continuare odată cu lansarea Java 14.

Noul model se bazează pe trei reguli:

Regula #1 : Programele cu un singur thread rulează pseudo-secvențial. Aceasta înseamnă: în realitate, procesorul poate efectua mai multe operații pe ceas, schimbându-le în același timp ordinea, totuși, toate dependențele de date rămân, astfel încât comportamentul nu diferă de secvențial.

Regula numărul 2 : nu există valori de nicăieri. Citirea oricărei variabile (cu excepția non-volatile long și double, pentru care această regulă poate să nu fie valabilă) va returna fie valoarea implicită (zero), fie ceva scris acolo de o altă comandă.

Și regula numărul 3 : restul evenimentelor sunt executate în ordine, dacă sunt conectate printr-o relație strictă de ordine parțială „se execută înainte” ( se întâmplă înainte ).

Se întâmplă înainte

Leslie Lamport a venit cu conceptul Happens înainte . Aceasta este o relație strictă de ordine parțială introdusă între comenzile atomice (++ și -- nu sunt atomice) și nu înseamnă „fizic înainte”.

Se spune că a doua echipă va fi „la cunoștință” de modificările făcute de prima.

Se întâmplă înainte

De exemplu, unul este executat înaintea celuilalt pentru astfel de operațiuni:

Sincronizare și monitoare:

  • Capturarea monitorului ( metoda de blocare , pornire sincronizată) și orice se întâmplă pe același fir după acesta.
  • Revenirea monitorului (metoda de deblocare , sfârșitul sincronizarii) și orice se întâmplă pe același fir înainte de acesta.
  • Întoarcerea monitorului și apoi capturarea acestuia de către un alt fir.

Scrierea și citirea:

  • Scrierea în orice variabilă și apoi citirea acesteia în același flux.
  • Totul în același fir înainte de a scrie la variabila volatilă și scrierea în sine. citire volatilă și totul pe același thread după ea.
  • Scrierea unei variabile volatile și apoi citirea din nou. O scriere volatilă interacționează cu memoria în același mod ca o revenire a unui monitor, în timp ce o citire este ca o captură. Se dovedește că, dacă un thread a scris la o variabilă volatilă, iar al doilea a găsit-o, tot ceea ce precede scrierea este executat înainte de tot ce vine după citire; Vezi poza.

Întreținere obiect:

  • Inițializare statică și orice acțiuni cu orice instanțe de obiecte.
  • Scrierea în câmpurile finale din constructor și totul după constructor. Ca o excepție, relația întâmplă-înainte nu se conectează tranzitiv la alte reguli și, prin urmare, poate provoca o cursă între fire.
  • Orice lucru cu obiectul și finalize() .

Serviciu de stream:

  • Pornirea unui thread și orice cod din fir.
  • Punerea la zero a variabilelor legate de fir și de orice cod din fir.
  • Cod în thread și join() ; cod în fir și isAlive() == false .
  • întrerupe() firul și detectează că s-a oprit.

Se întâmplă înainte de nuanțe de lucru

Eliberarea unui monitor se întâmplă înainte de a avea loc înainte de achiziționarea aceluiași monitor. Este demn de remarcat faptul că este lansarea, și nu ieșirea, adică nu trebuie să vă faceți griji cu privire la siguranță atunci când utilizați așteptați.

Să vedem cum aceste cunoștințe ne vor ajuta să ne corectăm exemplul. În acest caz, totul este foarte simplu: doar eliminați verificarea externă și lăsați sincronizarea așa cum este. Acum, cel de-al doilea thread este garantat să vadă toate modificările, deoarece va primi monitorul numai după ce celălalt thread îl eliberează. Și din moment ce nu îl va elibera până când totul nu este inițializat, vom vedea toate modificările simultan și nu separat:

public class Keeper {
    private Data data = null;

    public Data getData() {
        synchronized(this) {
            if(data == null) {
                data = new Data();
            }
        }

        return data;
    }
}

Scrierea într-o variabilă volatilă are loc înainte de a citi din aceeași variabilă. Schimbarea pe care am făcut-o, desigur, remediază eroarea, dar pune oricine a scris codul original înapoi de unde a venit - blocând de fiecare dată. Cuvântul cheie volatil poate salva. De fapt, afirmația în cauză înseamnă că atunci când citim tot ceea ce este declarat volatil, vom obține întotdeauna valoarea reală.

În plus, așa cum am spus mai devreme, pentru câmpurile volatile, scrierea este întotdeauna (inclusiv lungă și dublă) o operație atomică. Un alt punct important: dacă aveți o entitate volatilă care are referințe la alte entități (de exemplu, o matrice, Listă sau altă clasă), atunci doar o referință la entitate în sine va fi întotdeauna „proaspătă”, dar nu la tot ce se află în acesta intră.

Deci, înapoi la berbecii noștri cu blocare dublă. Folosind volatile, puteți remedia situația astfel:

public class Keeper {
    private volatile Data data = null;

    public Data getData() {
        if(data == null) {
            synchronized(this) {
                if(data == null) {
                    data = new Data();
                }
            }
        }
        return data;
    }
}

Aici avem încă o blocare, dar numai dacă data == null. Filtrăm cazurile rămase folosind citirea volatilă. Corectitudinea este asigurată de faptul că stocarea volatilă are loc - înainte de citirea volatilă, iar toate operațiunile care au loc în constructor sunt vizibile oricui citește valoarea câmpului.