Java Language
Java-geheugenmodel
Zoeken…
Opmerkingen
Het Java-geheugenmodel is het gedeelte van de JLS dat de voorwaarden specificeert waaronder een thread gegarandeerd de effecten van geheugenschrijvingen door een andere thread ziet. De relevante sectie in recente edities is "JLS 17.4 Geheugenmodel" (in Java 8 , Java 7 , Java 6 )
Er was een ingrijpende revisie van het Java-geheugenmodel in Java 5 die (onder andere) de manier veranderde waarop volatile
werkte. Sindsdien is het geheugenmodel in wezen ongewijzigd gebleven.
Motivatie voor het geheugenmodel
Overweeg het volgende voorbeeld:
public class Example {
public int a, b, c, d;
public void doIt() {
a = b + 1;
c = d + 1;
}
}
Als deze klasse wordt gebruikt als een applicatie met één thread, is het waarneembare gedrag precies zoals u zou verwachten. Bijvoorbeeld:
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);
}
}
zal uitvoeren:
0, 0
1, 1
Voor zover de " doIt()
" kan vertellen , worden de instructies in de methode main()
en de methode doIt()
uitgevoerd in de volgorde waarin ze in de broncode zijn geschreven. Dit is een duidelijke vereiste van de Java Language Specification (JLS).
Beschouw nu dezelfde klasse die wordt gebruikt in een toepassing met meerdere threads.
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);
}
}
}
Wat gaat deze afdrukken?
Volgens de JLS is het zelfs niet mogelijk om te voorspellen dat dit zal worden afgedrukt:
- U zult waarschijnlijk een paar regels van
0, 0
om mee te beginnen. - Dan zie je waarschijnlijk lijnen als
N, N
ofN, N + 1
. - Mogelijk ziet u lijnen zoals
N + 1, N
- In theorie zou je zelfs kunnen zien dat de
0, 0
lijnen voor altijd doorgaan 1 .
1 - In de praktijk kan de aanwezigheid van de println
instructies enige serendipitaire synchronisatie en geheugencache-flushing veroorzaken. Dat zal waarschijnlijk enkele van de effecten verbergen die het bovengenoemde gedrag zouden veroorzaken.
Dus hoe kunnen we deze verklaren?
Herordenen van opdrachten
Een mogelijke verklaring voor onverwachte resultaten is dat de JIT-compiler de volgorde van de toewijzingen in de methode doIt()
heeft gewijzigd. De JLS vereist dat instructies worden uitgevoerd in volgorde vanuit het perspectief van de huidige thread. In dit geval kan niets in de code van de methode doIt()
het effect van een (hypothetische) herschikking van die twee beweringen waarnemen. Dit betekent dat de JIT-compiler dat zou mogen doen.
Waarom zou het dat doen?
Op typische moderne hardware worden machine-instructies uitgevoerd met behulp van een instructiepijplijn waarmee een reeks instructies in verschillende fasen kan worden uitgevoerd. Sommige fasen van het uitvoeren van instructies duren langer dan andere, en geheugenbewerkingen duren meestal langer. Een slimme compiler kan de instructiedoorvoer van de pijplijn optimaliseren door de instructies te bestellen om de hoeveelheid overlapping te maximaliseren. Dit kan ertoe leiden dat delen van overzichten buiten de orde worden uitgevoerd. De JLS staat dit toe op voorwaarde dat dit het resultaat van de berekening niet beïnvloedt vanuit het perspectief van de huidige thread .
Effecten van geheugencaches
Een tweede mogelijke verklaring is het effect van geheugencaching. In een klassieke computerarchitectuur heeft elke processor een kleine set registers en een grotere hoeveelheid geheugen. Toegang tot registers is veel sneller dan toegang tot hoofdgeheugen. In moderne architecturen zijn er geheugencaches die langzamer zijn dan registers, maar sneller dan het hoofdgeheugen.
Een compiler zal dit benutten door te proberen kopieën van variabelen in registers of in de geheugencaches te bewaren. Als een variabele niet naar het hoofdgeheugen hoeft te worden gespoeld of niet uit het geheugen hoeft te worden gelezen, zijn er aanzienlijke prestatievoordelen als u dit niet doet. In gevallen waarin de JLS niet vereist dat geheugenbewerkingen zichtbaar zijn voor een andere thread, voegt de Java JIT-compiler waarschijnlijk de instructies "leesbarrière" en "schrijfbarrière" niet toe die het lezen en schrijven van het hoofdgeheugen zullen forceren. Nogmaals, de prestatievoordelen hiervan zijn aanzienlijk.
Juiste synchronisatie
Tot nu toe hebben we gezien dat de JLS de JIT-compiler toestaat om code te genereren die single-threaded code sneller maakt door het opnieuw ordenen of vermijden van geheugenbewerkingen. Maar wat gebeurt er als andere threads de status van de (gedeelde) variabelen in het hoofdgeheugen kunnen observeren?
Het antwoord is dat de andere threads variabele toestanden kunnen observeren die onmogelijk lijken te zijn ... op basis van de codevolgorde van de Java-instructies. De oplossing hiervoor is om de juiste synchronisatie te gebruiken. De drie belangrijkste benaderingen zijn:
- Primitieve mutexen en de
synchronized
constructies gebruiken. -
volatile
variabelen gebruiken. - Ondersteuning van gelijktijdigheid op hoger niveau; bijv. klassen in de
java.util.concurrent
pakketten.
Maar zelfs hiermee is het belangrijk om te begrijpen waar synchronisatie nodig is en op welke effecten u kunt vertrouwen. Hier komt het Java-geheugenmodel binnen.
Het geheugenmodel
Het Java-geheugenmodel is het gedeelte van de JLS dat de voorwaarden specificeert waaronder een thread gegarandeerd de effecten van geheugenschrijvingen door een andere thread ziet. Het geheugenmodel wordt gespecificeerd met een behoorlijke mate van formele nauwkeurigheid en vereist (als gevolg) een gedetailleerde en zorgvuldige lezing om het te begrijpen. Maar het basisprincipe is dat bepaalde constructen een "gebeurt-vóór" -relatie creëren tussen schrijven van een variabele door een thread, en een daaropvolgende read van dezelfde variabele door een andere thread. Als de relatie "gebeurt vóór" bestaat, is de JIT-compiler verplicht code te genereren die ervoor zorgt dat de leesbewerking de waarde ziet die door de schrijfactie is geschreven.
Gewapend hiermee is het mogelijk om te redeneren over geheugencoherentie in een Java-programma en te beslissen of dit voorspelbaar en consistent zal zijn voor alle uitvoeringsplatforms.
Gebeurt vóór relaties
(Het volgende is een vereenvoudigde versie van wat de Java Language Specification zegt. Voor een beter begrip moet u de specificatie zelf lezen.)
Gebeurtenisrelaties zijn het onderdeel van het geheugenmodel waarmee we inzicht in en inzicht in geheugen kunnen krijgen. Zoals de JLS zegt ( JLS 17.4.5 ):
"Twee acties kunnen worden geordend door een gebeurt-voor- relatie. Als de ene actie gebeurt-vóór de andere, dan is de eerste zichtbaar voor en geordend voor de tweede."
Wat betekent dit?
acties
De acties waarnaar het bovenstaande citaat verwijst, zijn gespecificeerd in JLS 17.4.2 . Er zijn 5 soorten acties vermeld door de specificatie:
Lezen: een niet-vluchtige variabele lezen.
Schrijven: een niet-vluchtige variabele schrijven.
Synchronisatie acties:
Vluchtig lezen: een vluchtige variabele lezen.
Vluchtig schrijven: een vluchtige variabele schrijven.
Slot. Monitor vergrendelen
Ontgrendelen. Monitor ontgrendelen.
De (synthetische) eerste en laatste acties van een thread.
Acties die een thread starten of detecteren dat een thread is beëindigd.
Externe acties. Een actie die een resultaat heeft dat afhankelijk is van de omgeving waarin het programma zich bevindt.
Discussie divergentie acties. Deze modelleren het gedrag van bepaalde soorten oneindige lus.
Programma volgorde en synchronisatie volgorde
Deze twee ordeningen ( JLS 17.4.3 en JLS 17.4.4 ) regelen de uitvoering van instructies in een Java
Programma volgorde beschrijft de volgorde van uitvoering van instructies binnen een enkele thread.
Synchronisatie volgorde beschrijft de volgorde van uitvoering van instructies voor twee instructies verbonden door een synchronisatie:
Een ontgrendelingsactie op de monitor wordt gesynchroniseerd met alle volgende vergrendelingsacties op die monitor.
Een schrijven naar een vluchtige variabele wordt gesynchroniseerd met alle volgende lezingen van dezelfde variabele door elke thread.
Een actie die een thread start (dwz de aanroep van
Thread.start()
) wordt gesynchroniseerd met de eerste actie in de thread die wordt gestart (dwz de aanroep van de methoderun()
van de thread).De standaardinitialisatie van velden wordt gesynchroniseerd met de eerste actie in elke thread. (Zie de JLS voor een uitleg hiervan.)
De laatste actie in een thread wordt gesynchroniseerd met elke actie in een andere thread die de beëindiging detecteert; bijv. de terugkeer van een
join()
isTerminated()
ofisTerminated()
dietrue
retourneert.Als een thread een andere thread onderbreekt, wordt de interrupt-oproep in de eerste thread gesynchroniseerd met het punt waar een andere thread detecteert dat de thread is onderbroken.
Gebeurt vóór bestelling
Deze volgorde ( JLS 17.4.5 ) bepaalt wat gegarandeerd wordt dat een geheugenschrift zichtbaar is voor een volgende gelezen geheugen.
Meer in het bijzonder zal een lezen van een variabele v
gegarandeerd een schrijven naar v
waarnemen als en alleen als write(v)
gebeurt - vóór read(v)
EN er is geen tussenliggend schrijven naar v
. Als er tussenliggende schrijfopdrachten zijn, kan de read(v)
de resultaten hiervan zien in plaats van de eerdere.
De regels die bepalen wat er gebeurt - vóór bestellen zijn als volgt:
Gebeurt-Voor Regel # 1 - Als x en y acties van dezelfde thread zijn en x voor y komt in de volgorde van het programma , dan gebeurt x voor y.
Happens-Before Regel # 2 - Er is een happen-before edge vanaf het einde van een constructor van een object tot het begin van een finalizer voor dat object.
Gebeurt-Voor Regel # 3 - Als een actie x synchroniseert met een volgende actie y, gebeurt x vóór Y.
Gebeurt-Voor Regel # 4 - Als x gebeurt-vóór y en y gebeurt-vóór z dan gebeurt x -vóór- z.
Bovendien zijn verschillende klassen in de standaard Java-bibliotheken gespecificeerd als definitie van happen-before- relaties. Je kunt dit zo interpreteren dat het op de een of andere manier gebeurt, zonder precies te moeten weten hoe de garantie zal worden nagekomen.
Gebeurt vóór redenering toegepast op enkele voorbeelden
We zullen enkele voorbeelden geven om te laten zien hoe het gebeurt - voordat we redeneren om te controleren of het schrijven zichtbaar is voor latere lezingen.
Code met één draad
Zoals je zou verwachten, zijn schrijfacties altijd zichtbaar voor latere leesbewerkingen in een programma met één thread.
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)
}
}
Door Happens-Before Regel # 1:
- De actie
write(a)
vindt plaats vóór de actiewrite(b)
. - De actie
write(b)
vindt plaats vóór de actieread(a)
. - De actie
read(a)
vindt plaats vóór de actieread(a)
.
Door Happens-Before Regel # 4:
-
write(a)
gebeurt - vóórwrite(b)
ENwrite(b)
gebeurt - vóórread(a)
IMPLIESwrite(a)
gebeurt - vóórread(a)
. -
write(b)
gebeurt - vóórread(a)
ENread(a)
gebeurt - vóórread(b)
IMPLIESwrite(b)
gebeurt - vóórread(b)
.
Opsommen:
- De relatie
write(a)
happen-beforeread(a)
betekent dat de instructiea + b
gegarandeerd de juiste waarde vana
. - De relatie
write(b)
happen-beforeread(b)
betekent dat de instructiea + b
gegarandeerd de juiste waarde vanb
.
Gedrag van 'vluchtig' in een voorbeeld met 2 threads
We zullen de volgende voorbeeldcode gebruiken om enkele implicaties van het Geheugenmodel voor `vluchtig 'te onderzoeken.
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)
}
}
Overweeg eerst de volgende reeks uitspraken met 2 threads:
- Er wordt één exemplaar van
VolatileExample
gemaakt; noem hetve
, -
ve.update(1, 2)
wordt in één thread genoemd, en -
ve.observe()
wordt een andere thread genoemd.
Door Happens-Before Regel # 1:
- De actie
write(a)
vindt plaats - vóór de actievolatile-write(a)
. - De
volatile-read(a)
actie gebeurt vóór deread(b)
actie.
Door Happens-Before Regel # 2:
- De actie
volatile-write(a)
in de eerste thread gebeurt vóór de actievolatile-read(a)
in de tweede thread.
Door Happens-Before Regel # 4:
- De actie
write(b)
in de eerste thread gebeurt vóór de actieread(b)
in de tweede thread.
Met andere woorden, voor deze specifieke reeks is ons gegarandeerd dat de 2e thread de update van de niet-vluchtige variabele b
van de eerste thread te zien krijgt. Het moet echter ook duidelijk zijn dat als de toewijzingen in de update
andersom waren, of de methode observe()
de variabele b
vóór a
leest, de ketting die gebeurt vóór dan wordt verbroken. De ketting zou ook worden verbroken als volatile-read(a)
in de tweede thread niet volgde op de volatile-write(a)
in de eerste thread.
Wanneer de ketting is gebroken, is er geen garantie dat observe()
de juiste waarde van b
zal zien.
Vluchtig met drie draden
Stel dat we een derde thread toevoegen aan het vorige voorbeeld:
- Er wordt één exemplaar van
VolatileExample
gemaakt; noem hetve
, -
update
twee threads:-
ve.update(1, 2)
wordt in één thread genoemd, -
ve.update(3, 4)
wordt in de tweede thread genoemd,
-
-
ve.observe()
wordt vervolgens een derde thread genoemd.
Om dit volledig te analyseren, moeten we rekening houden met alle mogelijke interleavings van de verklaringen in thread één en thread twee. In plaats daarvan zullen we er slechts twee overwegen.
Scenario # 1 - stel dat update(1, 2)
aan update(3,4)
voorafgaat, krijgen we deze volgorde:
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
In dit geval is het gemakkelijk om te zien dat er een ononderbroken gebeurt - voordat de ketting van write(b, 3)
naar read(b)
. Verder is er geen schrijven naar b
. Dus voor dit scenario ziet de derde thread gegarandeerd b
als waarde 3
.
Scenario # 2 - stel dat update(1, 2)
en update(3,4)
elkaar overlappen en de aties als volgt zijn verweven:
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
Nu, terwijl er een ketting gebeurt van write(b, 3)
naar read(b)
, is er een tussenliggende write(b, 1)
die wordt uitgevoerd door de andere thread. Dit betekent dat we niet zeker kunnen zijn welke waarde read(b)
zal zien.
(Terzijde: dit toont aan dat we niet kunnen vertrouwen op volatile
voor het waarborgen van de zichtbaarheid van niet-vluchtige variabelen, behalve in zeer beperkte situaties.)
Hoe te voorkomen dat u het geheugenmodel moet begrijpen
Het geheugenmodel is moeilijk te begrijpen en moeilijk toe te passen. Het is handig als u moet redeneren over de juistheid van multi-threaded code, maar u wilt deze redenering niet hoeven te doen voor elke multi-threaded toepassing die u schrijft.
Als u de volgende principes toepast bij het schrijven van gelijktijdige code in Java, kunt u grotendeels de noodzaak vermijden om toevlucht te nemen tot gebeurt-vóór redeneren.
Gebruik waar mogelijk onveranderlijke datastructuren. Een correct geïmplementeerde onveranderlijke klasse is thread-safe en introduceert geen thread-safety-problemen wanneer u deze met andere klassen gebruikt.
"Onveilige publicatie" begrijpen en vermijden.
Gebruik primitieve mutexen of
Lock
objecten om de toegang tot status te synchroniseren in veranderlijke objecten die thread-safe moeten zijn 1 .Gebruik
Executor
/ExecutorService
of het vork join-framework in plaats van te proberen direct beheerde threads te maken.Gebruik de klassen 'java.util.concurrent' die geavanceerde vergrendelingen, semaforen, vergrendelingen en barrières bieden, in plaats van direct gebruik te maken van wachten / melden / melden.
Gebruik de
java.util.concurrent
versies van kaarten, sets, lijsten, wachtrijen en deques in plaats van externe synchronisatie van niet-gelijktijdige collecties.
Het algemene principe is om te proberen de ingebouwde gelijktijdigheidsbibliotheken van Java te gebruiken in plaats van "je eigen" gelijktijdigheid. U kunt erop vertrouwen dat ze werken, als u ze correct gebruikt.
1 - Niet alle objecten hoeven draadveilig te zijn. Als een of meer objecten bijvoorbeeld thread-begrensd zijn (dat wil zeggen dat het slechts voor één thread toegankelijk is), dan is de thread-safety niet relevant.