Sök…


Anmärkningar

Java-minnesmodellen är den del av JLS som anger villkoren under vilken en tråd är garanterad att se effekterna av minneskrivningar gjorda av en annan tråd. Det relevanta avsnittet i de senaste utgåvorna är "JLS 17.4 Memory Model" (i Java 8 , Java 7 , Java 6 )

Det gjordes en större översyn av Java Memory Model i Java 5 som (bland annat) förändrade hur volatile fungerade. Sedan dess har minnesmodellen varit väsentligen oförändrad.

Motivation för minnesmodellen

Tänk på följande exempel:

public class Example {
    public int a, b, c, d;
    
    public void doIt() {
       a = b + 1;
       c = d + 1;
    }
}

Om den här klassen används är en enkeltrådad applikation kommer det observerbara beteendet vara exakt som du kan förvänta dig. Till exempel:

public class SingleThreaded {
    public static void main(String[] args) {
        Example eg = new Example();
        System.out.println(eg.a + ", " + eg.c);
        eg.doIt();
        System.out.println(eg.a + ", " + eg.c);
    }
}

kommer att mata ut:

0, 0
1, 1

Såvitt "huvud" -tråden kan berätta kommer uttalanden i metoden main() och doIt() -metoden att köras i den ordning de skrivs i källkoden. Detta är ett tydligt krav i Java Language Specification (JLS).

Tänk nu på samma klass som används i en flertrådad applikation.

public class MultiThreaded {
    public static void main(String[] args) {
        final Example eg = new Example();
        new Thread(new Runnable() {
            public void run() {
                while (true) {
                    eg.doIt();
                }
            }
        }).start();
        while (true) {
            System.out.println(eg.a + ", " + eg.c);
        }
    }
}

Vad kommer det här att skriva ut?

I själva verket är det enligt JLS inte möjligt att förutsäga att detta kommer att skriva ut:

  • Du kommer förmodligen att se några rader med 0, 0 till att börja med.
  • Då ser du förmodligen rader som N, N eller N, N + 1 .
  • Du kan se rader som N + 1, N
  • I teorin kan du till och med se att 0, 0 linjerna fortsätter för evigt 1 .

1 - I praktiken kan närvaron av de println uttalningarna orsaka viss serendipitös synkronisering och minnescache. Det kommer sannolikt att dölja några av effekterna som skulle orsaka ovanstående beteende.

Så hur kan vi förklara dessa?

Ombeställning av uppdrag

En möjlig förklaring till oväntade resultat är att JIT-kompilatorn har ändrat ordningen på uppdragen i doIt() -metoden. JLS kräver att uttalanden verkar köras i ordning ur den aktuella trådens perspektiv . I det här fallet kan ingenting i koden för doIt() -metoden observera effekten av en (hypotetisk) omordnande av dessa två uttalanden. Detta innebär att JIT-kompilatorn skulle få göra det.

Varför skulle det göra det?

På typisk modern maskinvara utförs maskininstruktioner med hjälp av en instruktionspipeline som gör att en sekvens av instruktioner kan vara i olika steg. Vissa faser av utförande av instruktioner tar längre tid än andra, och minnesoperationer tenderar att ta längre tid. En smart kompilator kan optimera instruktionens genomströmning av rörledningen genom att beställa instruktionerna för att maximera mängden överlappning. Detta kan leda till att delar av uttalanden körs ur funktion. JLS tillåter detta förutsatt att det inte påverkar resultatet av beräkningen ur den aktuella trådens perspektiv .

Effekter av minnescacher

En andra möjlig förklaring är effekten av minnescachen. I en klassisk datorarkitektur har varje processor en liten uppsättning register och en större mängd minne. Tillgång till register är mycket snabbare än åtkomst till huvudminnet. I moderna arkitekturer finns det minnescacher som är långsammare än register, men snabbare än huvudminnet.

En kompilator kommer att utnyttja detta genom att försöka behålla kopior av variabler i register eller i minnescacheminnet. Om en variabel inte behöver spolas till huvudminnet eller inte behöver läsas från minnet finns det stora prestandafördelar med att inte göra detta. I de fall där JLS inte kräver att minnesoperationer är synliga för en annan tråd, kommer Java JIT-kompilatorn sannolikt inte att lägga till "läsbarriären" och "skrivbarriären" som kommer att tvinga huvudminnet att läsa och skriva. Återigen är prestandafördelarna med att göra detta betydande.

Rätt synkronisering

Hittills har vi sett att JLS tillåter JIT-kompilatorn att generera kod som gör enkeltrådig kod snabbare genom att ordna om eller undvika minnesoperationer. Men vad händer när andra trådar kan observera tillståndet för (delade) variabler i huvudminnet?

Svaret är att de andra trådarna kan observera variabla tillstånd som verkar vara omöjliga ... baserat på kodordningen för Java-uttalanden. Lösningen på detta är att använda lämplig synkronisering. De tre huvudsakliga strategierna är:

  • Använda primitiva mutexer och de synchronized konstruktionerna.
  • Med volatile variabler.
  • Med hjälp av högre nivå samtidigt stöd; t.ex. klasser i java.util.concurrent paketen.

Men även med detta är det viktigt att förstå var synkronisering behövs och vilka effekter du kan lita på. Det är här Java Memory Model kommer in.

Minnesmodellen

Java-minnesmodellen är den del av JLS som anger villkoren under vilken en tråd är garanterad att se effekterna av minneskrivningar gjorda av en annan tråd. Minnesmodellen specificeras med en rimlig grad av formell rigoritet , och (som ett resultat) kräver detaljerad och noggrann läsning för att förstå. Men den grundläggande principen är att vissa konstruktioner skapar en "händer-innan" -förhållande mellan skrivning av en variabel med en tråd och en efterföljande avläsning av samma variabel av en annan tråd. Om "händer innan" -relationen existerar, är JIT-kompilatorn skyldig att generera kod som säkerställer att läsoperationen ser värdet skrivet av skrivet.

Beväpnad med detta är det möjligt att resonera om minnes koherens i ett Java-program och bestämma om detta kommer att vara förutsägbart och konsekvent för alla exekveringsplattformar.

Händer före förhållanden

(Följande är en förenklad version av vad Java Language Specification säger. För en djupare förståelse måste du läsa själva specifikationen.)

Händelser-innan-relationer är den del av minnesmodellen som gör att vi kan förstå och resonera om minnessynlighet. Som JLS säger ( JLS 17.4.5 ):

"Två handlingar kan beställas av en händelse-före- relation. Om en åtgärd händer-före en annan, är den första synlig för och ordnad före den andra."

Vad betyder det här?

Handlingar

De åtgärder som ovanstående offert hänvisar till anges i JLS 17.4.2 . Det finns fem typer av handlingar som definieras av specifikationen:

  • Läs: Läsa en icke-flyktig variabel.

  • Skriva: Skriva en icke-flyktig variabel.

  • Synkroniseringsåtgärder:

    • Volatile read: Läser en flyktig variabel.

    • Flyktig skrivning: Att skriva en flyktig variabel.

    • Låsa. Låser en bildskärm

    • Låsa upp. Låsa upp en bildskärm.

    • Den (syntetiska) första och sista handlingen av en tråd.

    • Åtgärder som startar en tråd eller upptäcker att en tråd har avslutats.

  • Externa åtgärder. En åtgärd som har ett resultat som beror på miljön i programmet.

  • Tråddivergensåtgärder. Dessa modellerar beteendet hos vissa typer av oändlig slinga.

Programordning och synkroniseringsordning

Dessa två beställningar ( JLS 17.4.3 och JLS 17.4.4 ) styr verkställandet av uttalanden i en Java

Programordning beskriver ordningen för uttalande av exekvering i en enda tråd.

Synkroniseringsordning beskriver ordningen för uttalande av körning för två uttalanden som är anslutna med en synkronisering:

  • En upplåsning på bildskärmen synkroniseras med alla efterföljande låsåtgärder på skärmen.

  • En skrivning till en flyktig variabel synkroniseras med alla efterföljande läsningar av samma variabel med vilken tråd som helst.

  • En åtgärd som startar en tråd (dvs. samtalet till Thread.start() ) synkroniseras - med den första åtgärden i tråden den startar (dvs. samtalet till trådens run() -metod).

  • Standardinitialiseringen av fält synkroniseras - med den första åtgärden i varje tråd. (Se JLS för en förklaring av detta.)

  • Den sista handlingen i en tråd synkroniseras med varje handling i en annan tråd som upptäcker avslutningen; t.ex. returnera ett samtal join() eller isTerminated() som returnerar true .

  • Om en tråd avbryter en annan tråd synkroniseras avbrottssamtalet i den första tråden - med den punkt där en annan tråd upptäcker att tråden avbröts.

Händer före order

Denna beställning ( JLS 17.4.5 ) är det som avgör om en minnesskrivning garanteras vara synlig för en efterföljande minnesläsning.

Mer specifikt är en läsning av en variabel v garanterad att observera en skrivning till v om och bara om write(v) händer-innan read(v) OCH det inte finns någon ingripande skrivning till v . Om det finns ingripande skrivningar, kan read(v) se resultatet av dem snarare än den tidigare.

Reglerna som definierar händelser innan du beställer är följande:

  • Händer - Innan regel nr 1 - Om x och y är åtgärder av samma tråd och x kommer före y i programordning , sker x -innan y.

  • Händer - Innan regel nr 2 - Det finns en händelse-före-kant från slutet av en konstruktör av ett objekt till början av en finaliserare för det objektet.

  • Händer - Innan regel nr 3 - Om en åtgärd x synkroniseras - med en efterföljande åtgärd y, sker x -före y.

  • Händer - innan regel nr 4 - Om x händer - innan y och y händer - innan z så händer x före z.

Dessutom anges olika klasser i Java-standardbiblioteken för att definiera händelser före relationer. Du kan tolka detta så att det händer på något sätt utan att behöva veta exakt hur garantin kommer att uppfyllas.

Händelser-innan resonemang tillämpas på några exempel

Vi kommer att presentera några exempel för att visa hur man applicerar händer innan man resonerar för att kontrollera att skrivningar är synliga för efterföljande läsningar.

Enkeltrådad kod

Som du kan förvänta dig är skrivningar alltid synliga för efterföljande läsningar i ett enkeltrådat program.

public class SingleThreadExample {
    public int a, b;
    
    public int add() {
       a = 1;         // write(a)
       b = 2;         // write(b)
       return a + b;  // read(a) followed by read(b)
    }
}

Av Happens-Before regel nr 1:

  1. Handlingen write(a) händer-innan handlingen write(b) .
  2. Handlingen write(b) händer-innan den read(a) handlingen.
  3. Den read(a) åtgärden händer - före den read(a) handlingen.

Av Happens-Before regel nr 4:

  1. write(a) händer innan write(b) OCH write(b) händer innan read(a) FÖRESKRIFT write(a) händer innan read(a) .
  2. write(b) händer innan read(a) OCH read(a) händer innan read(b) FÖRESKRIFT write(b) händer innan read(b) .

Summering:

  1. write(a) händer-innan read(a) -relationen innebär att a + b uttalandet garanteras se rätt värde på a .
  2. Förhållandet write(b) händer före read(b) innebär att a + b uttalandet garanteras se rätt värde på b .

Beteende med 'flyktiga' i ett exempel med 2 trådar

Vi kommer att använda följande exempelkod för att utforska några konsekvenser av minnesmodellen för `flyktig.

public class VolatileExample {
    private volatile int a;
    private int b;         // NOT volatile
    
    public void update(int first, int second) {
       b = first;         // write(b)
       a = second;         // write-volatile(a)
    }

    public int observe() {
       return a + b;       // read-volatile(a) followed by read(b)
    }
}

Tänk först på följande sekvens av uttalanden som involverar två trådar:

  1. En enda instans av VolatileExample skapas; kalla det ve ,
  2. ve.update(1, 2) kallas i en tråd, och
  3. ve.observe() kallas i en annan tråd.

Av Happens-Before regel nr 1:

  1. Handlingen write(a) sker före den volatile-write(a) .
  2. Den volatile-read(a) handlingen inträffar innan åtgärden read(b) .

Av Happens-Before regel nr 2:

  1. Åtgärden för volatile-write(a) i den första tråden inträffar - innan den volatile-read(a) i den andra tråden.

Av Happens-Before regel nr 4:

  1. Handlingen write(b) i den första tråden inträffar - innan read(b) -handlingen i den andra tråden.

Med andra ord, för denna specifika sekvens är vi garanterade att den andra tråden kommer att se uppdateringen till den icke-flyktiga variabeln b från den första tråden. Det bör emellertid också vara tydligt att om uppdragen i update var tvärtom, eller observe() -metoden läser variabeln b före a , så skulle händelse-före- kedjan brytas. Kedjan skulle också brytas om volatile-read(a) i den andra tråden inte följde efter den volatile-write(a) i den första tråden.

När kedjan är trasig finns det ingen garanti för att observe() ser rätt värde på b .

Flyktig med tre trådar

Anta att vi lägger till en tredje tråd i föregående exempel:

  1. En enda instans av VolatileExample skapas; kalla det ve ,
  2. Två trådar ringer update :
    • ve.update(1, 2) kallas i en tråd,
    • ve.update(3, 4) kallas i den andra tråden,
  3. ve.observe() kallas därefter i en tredje tråd.

För att analysera detta fullständigt måste vi ta hänsyn till alla möjliga sammanflätningar av uttalandena i tråd ett och tråd två. Istället kommer vi att överväga bara två av dem.

Scenario nr 1 - antag att den update(1, 2) föregår update(3,4) vi denna sekvens:

write(b, 1), write-volatile(a, 2)     // first thread
write(b, 3), write-volatile(a, 4)     // second thread
read-volatile(a), read(b)             // third thread

I det här fallet är det lätt att se att det finns en obruten händelse-före- kedja från write(b, 3) till read(b) . Dessutom finns det ingen ingripande skriv till b . Så för detta scenario garanteras den tredje tråden att b har värdet 3 .

Scenario # 2 - anta att update(1, 2) och update(3,4) överlappar varandra och atjonerna är sammanflätade enligt följande:

write(b, 3)                           // second thread
write(b, 1)                           // first thread
write-volatile(a, 2)                  // first thread
write-volatile(a, 4)                  // second thread
read-volatile(a), read(b)             // third thread

Medan det finns en händelse-före- kedja från att write(b, 3) till att read(b) , finns det en ingripande write(b, 1) utförs av den andra tråden. Det betyder att vi inte kan vara säkra på vilket värde read(b) kommer att se.

(Bortsett från: Detta visar att vi inte kan lita på volatile för att säkerställa synlighet av icke-flyktiga variabler, utom i mycket begränsade situationer.)

Hur man undviker att behöva förstå minnesmodellen

Minnesmodellen är svår att förstå och svår att använda. Det är användbart om du behöver resonera om riktigheten av flera trådade koder, men du vill inte behöva göra detta resonemang för varje multigängad applikation som du skriver.

Om du adopterar följande principer när du skriver samtidig kod i Java, kan du till stor del undvika behovet av att ta till händelser innan du resonerar.

  • Använd immutable datastrukturer där det är möjligt. En korrekt implementerad immutable-klass kommer att vara trådsäker och kommer inte att introducera tråd-säkerhetsproblem när du använder den med andra klasser.

  • Förstå och undvika "osäker publicering".

  • Använd primitiva mutexer eller Lock objekt för att synkronisera åtkomst till tillstånd i muterbara objekt som måste vara tråd-säkra 1 .

  • Använd Executor / ExecutorService eller gaffelkopplingsramen snarare än att försöka skapa hanterings trådar direkt.

  • Använd klassen `java.util.concurrent som tillhandahåller avancerade lås, semaforer, spärrar och spärrar, istället för att använda vänta / meddela / meddela allt direkt.

  • Använd java.util.concurrent av kartor, uppsättningar, listor, köer och deques snarare än extern synkonisering av icke-samtidiga samlingar.

Den allmänna principen är att försöka använda Java: s inbyggda samtidighetsbibliotek snarare än att "rulla din egen" samtidighet. Du kan lita på att de fungerar om du använder dem ordentligt.


1 - Inte alla objekt behöver vara trådsäkra. Till exempel, om ett föremål eller objekt är tråden begränsat (dvs. det är bara tillgängligt för en tråd), är dess gängsäkerhet inte relevant.



Modified text is an extract of the original Stack Overflow Documentation
Licensierat under CC BY-SA 3.0
Inte anslutet till Stack Overflow