Heim  >  Artikel  >  Java  >  Eingehende Analyse des Java-Speichermodells: grundlegender Teil

Eingehende Analyse des Java-Speichermodells: grundlegender Teil

黄舟
黄舟Original
2016-12-29 13:02:281388Durchsuche

Klassifizierung gleichzeitiger Programmiermodelle

Bei der gleichzeitigen Programmierung müssen wir uns mit zwei Hauptproblemen befassen: wie man zwischen Threads kommuniziert und wie man zwischen Threads synchronisiert (Threads beziehen sich hier auf aktive Entitäten, die gleichzeitig ausgeführt werden). Kommunikation bezieht sich auf den Mechanismus, durch den Threads Informationen austauschen. Bei der imperativen Programmierung gibt es zwei Kommunikationsmechanismen zwischen Threads: Shared Memory und Message Passing.

Im Shared-Memory-Parallelitätsmodell wird der gemeinsame Status des Programms von Threads gemeinsam genutzt, und Threads kommunizieren implizit, indem sie den gemeinsamen Status im Speicher schreiben und lesen. Im Parallelitätsmodell für die Nachrichtenübermittlung gibt es keinen öffentlichen Status zwischen Threads, und Threads müssen explizit kommunizieren, indem sie Nachrichten explizit senden.

Synchronisierung bezieht sich auf den Mechanismus, den Programme verwenden, um die relative Reihenfolge zu steuern, in der Vorgänge zwischen verschiedenen Threads ausgeführt werden. Im Shared-Memory-Parallelitätsmodell wird die Synchronisierung explizit durchgeführt. Programmierer müssen explizit angeben, dass eine Methode oder ein Codeabschnitt ausschließlich zwischen Threads ausgeführt werden muss. Im Message-Passing-Parallelitätsmodell wird die Synchronisierung implizit durchgeführt, da das Senden einer Nachricht dem Empfangen einer Nachricht vorausgehen muss.

Die Parallelität von Java verwendet ein Shared-Memory-Modell. Die Kommunikation zwischen Java-Threads erfolgt immer implizit und der gesamte Kommunikationsprozess ist für Programmierer völlig transparent. Wenn ein Java-Programmierer, der ein Multithread-Programm schreibt, nicht versteht, wie die implizite Inter-Thread-Kommunikation funktioniert, wird er wahrscheinlich auf alle möglichen seltsamen Probleme mit der Speichersichtbarkeit stoßen.

Abstraktion des Java-Speichermodells

In Java werden alle Instanzfelder, statischen Felder und Array-Elemente im Heap-Speicher gespeichert und der Heap-Speicher wird von Threads gemeinsam genutzt (in diesem Artikel werden „gemeinsam genutzte Variablen“ verwendet). „Dieser Begriff bezieht sich auf Instanzfelder, statische Felder und Array-Elemente. Lokale Variablen, Methodendefinitionsparameter (in der Java-Sprachspezifikation als formale Methodenparameter bezeichnet) und Ausnahmehandlerparameter werden nicht von Threads gemeinsam genutzt, sie haben keine Probleme mit der Speichersichtbarkeit und sind nicht vom Speichermodell betroffen.

Die Kommunikation zwischen Java-Threads wird durch das Java-Speichermodell (in diesem Artikel als JMM bezeichnet) gesteuert, das bestimmt, wann ein Schreibvorgang in eine gemeinsam genutzte Variable durch einen Thread für einen anderen Thread sichtbar ist. Aus abstrakter Sicht definiert JMM die abstrakte Beziehung zwischen Threads und dem Hauptspeicher: Gemeinsam genutzte Variablen zwischen Threads werden im Hauptspeicher (Hauptspeicher) gespeichert, und jeder Thread verfügt über einen privaten lokalen Speicher (lokaler Speicher), eine Kopie des gemeinsam genutzten Die Variable, die der Thread liest/schreibt, wird im lokalen Speicher gespeichert. Lokaler Speicher ist ein abstraktes Konzept von JMM und existiert nicht wirklich. Es umfasst Caches, Schreibpuffer, Register und andere Hardware- und Compiler-Optimierungen. Das abstrakte schematische Diagramm des Java-Speichermodells lautet wie folgt:

Eingehende Analyse des Java-Speichermodells: grundlegender Teil

Wenn Thread A und Thread B aus der obigen Abbildung kommunizieren möchten, müssen sie die folgenden beiden durchlaufen Schritte:
Zuerst aktualisiert Thread A die aktualisierten gemeinsam genutzten Variablen im lokalen Speicher A im Hauptspeicher.
Dann geht Thread B in den Hauptspeicher, um die gemeinsam genutzten Variablen zu lesen, die Thread A zuvor aktualisiert hat.

Das Folgende ist ein schematisches Diagramm, um diese beiden Schritte zu veranschaulichen:

Eingehende Analyse des Java-Speichermodells: grundlegender Teil

Wie in der Abbildung oben gezeigt, verfügen der lokale Speicher A und B über Kopien des gemeinsam genutzten Speichers Variable x im Hauptspeicher. Angenommen, die x-Werte in diesen drei Speichern sind zunächst alle 0. Wenn Thread A ausgeführt wird, speichert er vorübergehend den aktualisierten x-Wert (vorausgesetzt, der Wert ist 1) in seinem eigenen lokalen Speicher A. Wenn Thread A und Thread B kommunizieren müssen, aktualisiert Thread A zunächst den geänderten x-Wert in seinem lokalen Speicher im Hauptspeicher. Zu diesem Zeitpunkt wird der x-Wert im Hauptspeicher 1. Anschließend geht Thread B zum Hauptspeicher, um den aktualisierten x-Wert von Thread A zu lesen. Zu diesem Zeitpunkt wird auch der x-Wert des lokalen Speichers von Thread B zu 1.

Insgesamt betrachtet handelt es sich bei diesen beiden Schritten im Wesentlichen darum, dass Thread A eine Nachricht an Thread B sendet, und dieser Kommunikationsprozess muss über den Hauptspeicher erfolgen. JMM bietet Java-Programmierern Garantien für die Speichersichtbarkeit, indem es die Interaktion zwischen dem Hauptspeicher und dem lokalen Speicher jedes Threads steuert.

Neuordnung

Um die Leistung bei der Ausführung eines Programms zu verbessern, ordnen Compiler und Prozessoren häufig Anweisungen neu. Es gibt drei Arten der Neuordnung:
Compiler-optimierte Neuordnung. Der Compiler kann die Ausführungsreihenfolge von Anweisungen neu anordnen, ohne die Semantik eines Single-Thread-Programms zu ändern.
Parallele Neuordnung auf Befehlsebene. Moderne Prozessoren nutzen Parallelität auf Befehlsebene (Instruction Level Parallelism, ILP), um mehrere Anweisungen überlappend auszuführen. Wenn keine Datenabhängigkeiten bestehen, kann der Prozessor die Reihenfolge ändern, in der Anweisungen den Maschinenanweisungen entsprechen.
Neuordnung des Speichersystems. Da der Prozessor Cache- und Lese-/Schreibpuffer verwendet, kann dies den Eindruck erwecken, dass Lade- und Speichervorgänge nicht in der richtigen Reihenfolge ausgeführt werden.

Vom Java-Quellcode bis zur endgültigen tatsächlich ausgeführten Befehlssequenz werden die folgenden drei Neuordnungen vorgenommen:

Eingehende Analyse des Java-Speichermodells: grundlegender Teil

Die obige 1 gehört zur Compiler-Neuordnung und 2 und 3 gehören zur Prozessor-Neuordnung. Diese Neuordnungen können in Multithread-Programmen zu Problemen mit der Speichersichtbarkeit führen. Für Compiler verbieten die Compiler-Neuordnungsregeln von JMM bestimmte Arten der Compiler-Neuordnung (nicht alle Compiler-Neuordnungen sind verboten). Für die Neuordnung von Prozessoren verlangen die Prozessor-Neuordnungsregeln von JMM, dass der Java-Compiler beim Generieren von Befehlssequenzen bestimmte Arten von Anweisungen für Speicherbarrieren (Intel nennt es „Memory Fence“) einfügt und Speicherbarrierenanweisungen verwendet, um bestimmte Arten von Anweisungen für Prozessoren (nicht für alle Prozessoren) zu verhindern Nachbestellung muss deaktiviert sein).

JMM ist ein Speichermodell auf Sprachebene, das einen konsistenten Speicher für Programmierer gewährleistet, indem es bestimmte Arten der Compiler-Neuordnung und Prozessor-Neuordnung auf verschiedenen Compilern und verschiedenen Prozessorplattformen verhindert.

Anweisungen zur Neuordnung des Prozessors und zur Speicherbarriere

Moderne Prozessoren verwenden Schreibpuffer, um in den Speicher geschriebene Daten vorübergehend zu speichern. Der Schreibpuffer hält die Befehlspipeline am Laufen und vermeidet Verzögerungen, die dadurch entstehen, dass der Prozessor beim Warten auf das Schreiben von Daten in den Speicher ins Stocken gerät. Gleichzeitig kann durch die Aktualisierung des Schreibpuffers in einem Batch-Prozess und die Zusammenführung mehrerer Schreibvorgänge an derselben Speicheradresse im Schreibpuffer die Speicherbusnutzung reduziert werden. Obwohl Schreibpuffer so viele Vorteile haben, ist der Schreibpuffer auf jedem Prozessor nur für den Prozessor sichtbar, auf dem er sich befindet. Diese Funktion wird einen wichtigen Einfluss auf die Ausführungsreihenfolge von Speicheroperationen haben: Die Reihenfolge, in der der Prozessor Speicheroperationen liest/schreibt, stimmt nicht unbedingt mit der Reihenfolge überein, in der der Speicher tatsächlich Lese-/Schreiboperationen durchführt! Für eine konkrete Erklärung schauen Sie sich bitte das folgende Beispiel an:
Prozessor A
Prozessor B
a = 1; //A1

x = b; //A2
b = 2 ; / /B1

y = a; //B2
Anfangszustand: a = b = 0

Der Prozessor ermöglicht die Ausführung des Ergebnisses: x = y = 0
Angenommen, Prozessor A und Prozessor B führen parallel Speicherzugriffe in der Reihenfolge des Programms durch, erhalten jedoch möglicherweise das Ergebnis x = y = 0. Die spezifischen Gründe sind in der folgenden Abbildung dargestellt:

Eingehende Analyse des Java-Speichermodells: grundlegender Teil

Hier können Prozessor A und Prozessor B gleichzeitig die gemeinsam genutzte Variable in ihre eigenen Schreibpuffer (A1, B1) schreiben, dann eine andere gemeinsam genutzte Variable (A2, B2) aus dem Speicher lesen und sich schließlich selbst schreiben Die schmutzigen Daten Die im Cache-Bereich gespeicherten Daten werden in den Speicher geleert (A3, B3). Wenn das Programm in diesem Timing ausgeführt wird, kann es das Ergebnis x = y = 0 erhalten.

Der tatsächlichen Abfolge der Speichervorgänge nach zu urteilen, wird der Schreibvorgang A1 erst tatsächlich ausgeführt, wenn Prozessor A A3 ausführt, um seinen eigenen Schreibcache zu aktualisieren. Obwohl Prozessor A Speicheroperationen in der Reihenfolge A1->A2 ausführt, ist die Reihenfolge, in der die Speicheroperationen tatsächlich stattfinden, A2->A1. Zu diesem Zeitpunkt wird die Speicheroperationsreihenfolge von Prozessor A neu geordnet (die Situation von Prozessor B ist dieselbe wie die von Prozessor A, daher werde ich hier nicht auf Details eingehen).

Der Schlüssel hier ist, dass, da der Schreibpuffer nur für seinen eigenen Prozessor sichtbar ist, die Reihenfolge, in der der Prozessor Speicheroperationen ausführt, nicht mit der tatsächlichen Reihenfolge übereinstimmt, in der die Speicheroperationen ausgeführt werden. Da moderne Prozessoren Schreibpuffer verwenden, ermöglichen moderne Prozessoren die Neuordnung von Schreib-Lese-Vorgängen.

Das Folgende ist eine Liste der Neuordnungstypen, die von gängigen Prozessoren zugelassen werden:
Load-Load
Load-Store
Store-Store
Store-Load
Data Dependencies
sparc-TSO
N
N
N
Y
N
x86
N
N
N
Y
N
ia64
J
J
J
J
N
PowerPC
J
J
J
J
N
Ein Ein „N“ in einer Tabellenzelle zeigt an, dass der Prozessor keine Neuordnung der beiden Vorgänge zulässt, und ein „Y“ gibt an, dass eine Neuordnung zulässig ist.

Aus der obigen Tabelle können wir ersehen, dass gemeinsame Prozessoren die Neuordnung von Store-Load-Vorgängen nicht zulassen. sparc-TSO und x86 verfügen über relativ starke Prozessorspeichermodelle, die nur eine Neuordnung von Schreib-Lese-Vorgängen ermöglichen (da beide Schreibpuffer verwenden).

※Hinweis 1: Sparc-TSO bezieht sich auf die Eigenschaften des Sparc-Prozessors bei der Ausführung im TSO-Speichermodell (Total Store Order).

※Hinweis 2: x86 in der obigen Tabelle umfasst x64 und AMD64.

※Hinweis 3: Da das Speichermodell des ARM-Prozessors dem des PowerPC-Prozessors sehr ähnlich ist, wird es in diesem Artikel ignoriert.

※Hinweis 4: Die Datenabhängigkeit wird später genauer erläutert.

Um die Sichtbarkeit des Speichers sicherzustellen, fügt der Java-Compiler an geeigneten Stellen in der generierten Befehlssequenz Speicherbarrierenanweisungen ein, um bestimmte Arten der Neuordnung des Prozessors zu verhindern. JMM unterteilt Speicherbarrierenanweisungen in die folgenden vier Kategorien:
Barrieretyp
Anweisungsbeispiel
Erklärung
LoadLoad-Barrieren
Load1; LoadLoad; Load2
Stellen Sie sicher, dass Load1-Daten vor Load2 geladen werden und das Laden aller nachfolgenden Ladeanweisungen.
StoreStore Barriers
Store1; StoreStore; Store2
Stellt sicher, dass Store1-Daten für andere Prozessoren sichtbar sind (in den Speicher geleert), vor dem Store in Store2 und allen nachfolgenden Store-Anweisungen.
LoadStore Barriers
Load1; LoadStore2
Stellt sicher, dass Load1-Daten vor Store2 geladen werden und alle nachfolgenden Store-Anweisungen in den Speicher geleert werden.
StoreLoad-Barrieren
Store1; StoreLoad; Load2
Stellt sicher, dass Store1-Daten für andere Prozessoren sichtbar werden (bezogen auf das Leeren in den Speicher), bevor sie von Load2 und allen nachfolgenden Ladeanweisungen geladen werden. StoreLoad-Barrieren bewirken, dass alle Speicherzugriffsanweisungen (Speicher- und Ladeanweisungen) vor der Barriere abgeschlossen werden, bevor die Speicherzugriffsanweisungen nach der Barriere ausgeführt werden.
StoreLoad Barriers ist eine „Allzweck“-Barriere, die gleichzeitig die Wirkung der anderen drei Barrieren hat. Die meisten modernen Multiprozessoren unterstützen diese Barriere (andere Arten von Barrieren werden möglicherweise nicht von allen Prozessoren unterstützt). Die Umsetzung dieser Barriere kann teuer sein, da aktuelle Prozessoren normalerweise alle Daten im Schreibpuffer in den Speicher leeren müssen (Puffer vollständig leeren).

passiert-bevor

Ab JDK5 verwendet Java das neue JSR-133-Speichermodell (sofern nicht anders angegeben, konzentriert sich dieser Artikel auf das JSR-133-Speichermodell). JSR-133 schlägt das Konzept des Vorhergehenden vor, mit dem die Speichersichtbarkeit zwischen Vorgängen erläutert wird. Wenn die Ergebnisse einer Operation für eine andere Operation sichtbar sein müssen, muss zwischen den beiden Operationen eine „Vorhergehend“-Beziehung bestehen. Die beiden hier genannten Vorgänge können innerhalb eines Threads oder zwischen verschiedenen Threads erfolgen. Die „Vorher“-Regeln, die eng mit Programmierern verknüpft sind, lauten wie folgt:
Programmsequenzregeln: Jede Operation in einem Thread findet statt, bevor jede nachfolgende Operation in diesem Thread ausgeführt wird.
Monitorsperrregel: Das Entsperren einer Monitorsperre erfolgt vor der anschließenden Sperrung der Monitorsperre.
Regeln für flüchtige Variablen: Das Schreiben in ein flüchtiges Feld erfolgt vor jedem nachfolgenden Lesen dieses flüchtigen Felds.
Transitivität: Wenn A passiert – vor B, und B passiert – vor C, dann passiert A – vor C.

Beachten Sie, dass zwischen zwei Vorgängen eine „Passiert-vorher“-Beziehung besteht, was nicht bedeutet, dass der erstere Vorgang vor dem letzteren Vorgang ausgeführt werden muss! „casces-before“ erfordert lediglich, dass die vorherige Operation (Ergebnis der Ausführung) für die nächste Operation sichtbar ist und dass die erste Operation für die zweite Operation sichtbar und vor ihr angeordnet ist. Die Definition von „cashes-before“ ist sehr subtil. Im folgenden Artikel wird genau erläutert, warum „cashes-before“ auf diese Weise definiert wird.

Die Beziehung zwischen Happening-Before und JMM ist in der folgenden Abbildung dargestellt:

Eingehende Analyse des Java-Speichermodells: grundlegender Teil

Wie in der Abbildung oben gezeigt, entspricht eine Vorhergehensregel normalerweise mehreren Compiler-Neuordnungsregeln und Prozessor-Neuordnungsregeln. Für Java-Programmierer ist die „Passiert vor“-Regel einfach und leicht zu verstehen. Sie verhindert, dass Programmierer komplexe Neuordnungsregeln und die spezifische Implementierung dieser Regeln erlernen, um die von JMM bereitgestellten Garantien für die Speichersichtbarkeit zu verstehen.

Das Obige ist eine ausführliche Analyse des Java-Speichermodells: der grundlegende Teil. Weitere verwandte Inhalte finden Sie auf der chinesischen PHP-Website (www.php.cn)!


Stellungnahme:
Der Inhalt dieses Artikels wird freiwillig von Internetnutzern beigesteuert und das Urheberrecht liegt beim ursprünglichen Autor. Diese Website übernimmt keine entsprechende rechtliche Verantwortung. Wenn Sie Inhalte finden, bei denen der Verdacht eines Plagiats oder einer Rechtsverletzung besteht, wenden Sie sich bitte an admin@php.cn