Detaillierte Erläuterung des flüchtigen Schlüsselworts in Java Concurrency

Was ist das flüchtige Schlüsselwort

Das flüchtige Schlüsselwort wird zum Ändern von Variablen verwendet. Mit diesem Schlüsselwort geänderte Variablen können Sichtbarkeit und Reihenfolge sicherstellen.
Aber es kann keine Atomizität erreichen.
Sie können sich das als geschwächtes, leichtes synchronisiertes Schlüsselwort vorstellen.
Wird zur Synchronisation verwendet.

Beginnen wir mit der Beschreibung der drei oben genannten Merkmale.

Drei Eigenschaften

Sichtbarkeit, Atomizität und Ordnung sind die Basis der gesamten Java-Parallelität.

  • Sichtbarkeit: Das heißt, wenn ein Thread den Wert einer gemeinsam genutzten Variablen ändert, lesen nach dieser Operation andere Threads die Variable und lesen die geänderten neuen Daten anstelle der alten Daten.
  • Atomarität: Eine Operation ist unteilbar und kann nicht unterbrochen werden. Sie wird entweder ausgeführt oder nicht ausgeführt. Es ist unmöglich zu sagen, dass ich dort nach der Hälfte der Ausführung anhalten werde.
  • Ordnungsmäßigkeit: Zum Beispiel wird unser Code in relativer Reihenfolge ausgeführt. Der Code in der vorherigen Zeile wird zuerst ausgeführt, und der Code in der nächsten Zeile wird später ausgeführt. Warum sagst du das? In einer Single-Thread-Umgebung kann dies zwar als sequentiell angesehen werden, jedoch nicht unbedingt aus einer Multithread-Perspektive. Der Compiler und die CPU ordnen den Code oder die Anweisungen unter der Voraussetzung neu, dass das richtige Single-Thread-Ergebnis für eine effiziente Ausführung sichergestellt wird. Bei Single-Threads wirkt sich dies nicht aus, bei Multithreads ist dies jedoch ein Problem.

Man kann sagen, dass diese drei Merkmale die Probleme sind, die wir in der Java-Parallelität lösen möchten.
Als Reaktion darauf basiert JMM (Java Memory Model) auf diesen drei Merkmalen.

Als nächstes werden wir JMM vorstellen

Java-Speichermodell

Am Ende unserer Hardware muss die CPU mit dem Hauptspeicher (Speicher) interagieren.
Wir wissen jedoch, dass die Register in der CPU schnell sind, aber die Geschwindigkeit des Hauptspeichers ist im Vergleich zu den Registern zu langsam.
Wenn die CPU direkt mit dem Speicher interagiert, wäre dies Zeitverschwendung. Die Registerkapazität ist gering und die Kosten zu hoch.
Die unterste Schicht verwendet also einen Cache, um das Register (CPU) mit dem Hauptspeicher zu verbinden. Da eine Geschwindigkeit zwischen den beiden liegt, ist auch der Preis akzeptabel. Als Cache für beide.
Jetzt übernimmt die grundlegende untere Schicht dieses Modell. Unterschiedliche CPUs haben jedoch unterschiedliche Modelle. Wenn Sie sie direkt Programmierern zuordnen, ist dies zu problematisch. Es gibt zu viele Szenarien, als dass Programmierer sie berücksichtigen könnten.
Daher erstellt Java ein eigenes Speichermodell, um diese Modelle zu kapseln, und passt eine unveränderliche Standardlogik an, um Programmierer bereitzustellen. Dies ist das Java Memory Model (JMM).

Das heißt, wir verstehen die zugrunde liegende Speichersituation, wir müssen nur das Java-Speichermodell berücksichtigen, wir müssen nicht berücksichtigen, welche Art von CPU oder Chaos.
Fügen Sie hier eine Bildbeschreibung ein
Das schematische Diagramm von JMM ist oben gezeigt.
Das Modell hier ist ein logisches Konzept, das nicht unbedingt real ist. Als Programmierer müssen wir nicht überlegen, ob es wirklich existiert.

Jeder Thread hat einen privaten Arbeitsthread, und der Arbeitsthread ist mit dem Hauptspeicher verbunden.
Der Hauptspeicher wird von allen Threads gemeinsam genutzt, und es werden gemeinsam genutzte Variablen gespeichert (fast alle Instanzvariablen, statischen Variablen, Klassenobjekte usw.).

  • Thread-Lesevorgang: Leeren Sie zuerst die gemeinsam genutzten Variablen im Hauptspeicher in den eigenen lokalen Speicher und lesen Sie dann aus dem lokalen Speicher
  • Thread-Schreibvorgang: Schreiben Sie zuerst die Daten in den lokalen Speicher und leeren Sie sie dann in den Hauptspeicher

Es sollte hier angemerkt werden, dass es, egal ob es sich um Lesen oder Schreiben handelt, nicht atomar ist, sondern die Zufallsoperation von zwei getrennten Operationen ist.
Das Lesen und Schreiben aller Daten des Threads muss im lokalen Speicher erfolgen, und der Hauptspeicher kann nicht direkt bearbeitet werden.

JMM-Problem

Dieses Speichermodell realisiert den Cache, so dass der Durchsatz der CPU so groß wie möglich ist und nicht auf den langsamen Speicher gewartet werden muss. Es hat Vor- und Nachteile.
Das Trennen von Lese- und Schreibvorgängen führt zu Problemen mit der Thread-Sicherheit.
Zum Beispiel das folgende Beispiel:

	static int i =0;
	两个线程同时执行以下代码,结果会怎样呢?
	线程A执行将i=2;
	线程B读取i值;

Thread A wird zuerst ausgeführt und Thread B wird später ausgeführt. Wird das i von Thread B 2 gelesen?
Nicht unbedingt muss es 0 sein. Wenn
Thread A ausgeführt wird und die Variable i = 2 in den lokalen Speicher legt, spült Thread B das i im Hauptspeicher in seinen eigenen lokalen Speicher. Zu diesem Zeitpunkt ist das i im Hauptspeicher 0, und dann spült der lokale Speicher von Thread A i in den Hauptspeicher.
Das Endergebnis ist also, dass i im Hauptspeicher 2 ist und das von Thread B gelesene Ergebnis 0 ist. Obwohl gemäß Logik A zuerst und B später ausgeführt wird, sollte das Ergebnis 2 sein. Dies ist jedoch nicht der Fall, sodass dies ein durch JMM verursachtes Problem ist. (Der Wert von i im Cache von Thread AB ist hier unterschiedlich, daher wird das Problem der Cache-Konsistenz entworfen.)

Neuordnung von Anweisungen

Zusätzlich zu den obigen Problemen ist es auch mit dem Problem der Neuordnung von Befehlen konfrontiert (Code, der auch als Bytecode-Befehle verstanden werden kann, die fast gleich sind).

Zunächst einmal, warum ist die Nachbestellung?
Für JVM und Kompilierungszeit ist die aktuelle Codereihenfolge nicht unbedingt effizienter. Um die Effizienz zu erreichen, muss die Reihenfolge unterbrochen werden.
Insbesondere in einer gleichzeitigen Umgebung wird die Neuordnung wichtiger.

Nehmen Sie ein solches Beispiel:
Moderne CPUs verwenden normalerweise Pipeline-Technologie.
Da mehrere Befehle ausgeführt werden müssen, kann jeder Befehl auch in verschiedene Schritte zerlegt werden. Die in jedem Schritt verwendeten Register (nicht als Ressource) sind unterschiedlich. Wenn außer den Ressourcen nur ein Teil eines Befehls gleichzeitig ausgeführt wird es besetzt, andere Ressourcen werden verschwendet.
Daher verwenden wir Pipeline-Technologie, wie das Ausführen von Teil a von Befehl 1 im ersten Moment, das gleichzeitige Ausführen von Teil b von Befehl 2 und das gleichzeitige Ausführen von Teil d von Befehl 3. Gleichzeitig werden mehrere Anweisungen gleichzeitig ausgeführt, was wesentlich effizienter ist.
Zur gleichen Zeit, wenn für eine Anweisung, wenn die Reihenfolge von zwei der Schritte umgekehrt werden kann, dann müssen wir nicht auf Schritt 3 in der Reihenfolge von Schritt 2 warten (dies wird blockieren), kann ich wählen, um auszuführen Schritt 2 oder Schritt zuerst nach den besten. 3. Auf diese Weise wird es durch Nachbestellanweisungen effizienter.

Ähnlich wie in diesem Beispiel ist unsere Code-Neuordnung hier aus Effizienzgründen dieselbe.

Z.B:

int i = 0;//1
int j = 1;//2
int a = i+j;//3

Müssen wir es im folgenden Beispiel beispielsweise in der Reihenfolge 123 ausführen?
Nicht unbedingt, wenn es schneller ist, können wir die 213-Reihenfolge verwenden, und das Ergebnis wird sich zu diesem Zeitpunkt nicht ändern.

Dann müssen Sie sich vielleicht fragen, warum 312 hier nicht?
Sehr einfach, da 3 von 12 abhängt, muss 12 vor 3 ausgeführt werden, und die relative Reihenfolge von 12 spielt keine Rolle.
Wir können leicht sehen, aber wie man die JVM bestimmt?

Bestimmt durch das Definierte geschieht vor Regeln.

Passiert vor Regeln

Dies ist eine vordefinierte Regel und kann nicht verletzt werden, wenn die JVM optimiert (neu angeordnet) wird.

  1. Prinzip der Programmreihenfolge: In einem Thread erfolgt gemäß der Programmcodesequenz die vorne geschriebene Operation vor der hinten geschriebenen Operation.
  2. Flüchtige Regeln: Das Schreiben flüchtiger Variablen erfolgt vor dem Lesen, wodurch die Sichtbarkeit flüchtiger Variablen sichergestellt wird.
  3. Sperrregeln: Das Entsperren (Entsperren) muss vor dem anschließenden Sperren (Sperren) erfolgen.
  4. Transitivität: A steht vor B und B steht vor C, daher muss A vor C stehen.
  5. Die Startmethode des Threads geht jeder Aktion voraus, die er ausführt.
  6. Alle Operationen des Threads gehen der Beendigung des Threads voraus.
  7. Die Unterbrechung des Threads (Interrupt ()) geht dem unterbrochenen Code voraus.
  8. Der Konstruktor des Objekts endet vor der finalize-Methode.

Die erste Regel Die Programmreihenfolge-Regel besagt, dass in einem Thread alle Operationen in Ordnung sind, aber in JMM ist eine Neuordnung zulässig, solange das Ausführungsergebnis gleich ist Die Thread-Ausführung ergibt sich, es gibt jedoch keine Garantie dafür, dass dies auch für Multithreading gilt. Die zweite Regelüberwachungsregel ist eigentlich leicht zu verstehen, dh stellen Sie vor dem Sperren sicher, dass die Sperre aufgehoben wurde, bevor Sie mit dem Sperren fortfahren können. Die dritte Regel gilt für das besprochene flüchtige Element. Wenn ein Thread zuerst eine Variable schreibt und ein anderer Thread sie liest, muss die Schreiboperation der Leseoperation vorausgehen. Die vierte Regel ist die Transitivität von "Vorher passiert". Die nächsten Artikel werden nicht einzeln wiederholt.

In einem einzelnen Thread spielt die Neuordnung keine Rolle, da das tatsächliche Ergebnis gleich bleibt. Bei mehreren Threads ist das Problem jedoch groß.

Zum Beispiel die folgende Frage:

int a = 0;
bool flag = false;

public void write() {
    
    
   a = 2;              //1
   flag = true;        //2
}

public void multiply() {
    
    
   if (flag) {
    
             //3
       int ret = a * a;//4
   }
}

Thread A führt zuerst die Writer-Methode aus und Thread B führt anschließend die Multiplikationsmethode aus.
Ist das Ergebnis 4 nicht unbedingt, wenn eine Nachbestellung durchgeführt wird, wie unten gezeigt

	线程A		线程B
	2			
				3
				4
	1

Hier ist 1, 2 die Programmablaufregel, die neu angeordnet werden kann.
3 und 4 sind voneinander abhängig, so dass 3 vor 4 passiert, was nicht neu angeordnet werden kann, und das Problem wird wieder ausgeschlossen.
Im obigen Fall ist das Ergebnis von ret 0. Es ist nicht dasselbe wie wir erwartet hatten.
Es gibt hier also ein Problem, wir hoffen eindeutig, dass das Ergebnis 4 im Code ist.

Das flüchtige Schlüsselwort wird ausgegeben

Um das obige Problem zu lösen, erscheint der Protagonist.

Zunächst zur ersten Frage:
Setzen Sie die statische Variable i auf flüchtig

	static volatile int i =0;
	两个线程同时执行以下代码,结果会怎样呢?
	线程A执行将i=2;
	线程B读取i值;

Zu diesem Zeitpunkt sind die Lese- / Schreibvorgänge alle atomar, daher schreibt Thread A zuerst (steht kurz vor dem Schreiben in den Arbeitsspeicher, und die beiden Schritte zum Aktualisieren des Arbeitsspeichers in den Hauptspeicher werden zu einem zusammengefasst und sofort aktualisiert nach dem Schreiben in den Arbeitsspeicher.)
Dann liest der B-Thread, dasselbe gilt, wenn er erhalten wird, er wird auch direkt nach dem Aktualisieren erhalten.
Das Endergebnis ist 2.


Zur zweiten Frage:

int a = 0;
volatile bool flag = false;

public void write() {
    
    
   a = 2;              //1
   flag = true;        //2
}

public void multiply() {
    
    
   if (flag) {
    
             //3
       int ret = a * a;//4
   }
}

Nur das Flag wird hier als flüchtig definiert.
Schauen Sie sich die hb-Sequenz zu diesem Zeitpunkt an.
In der Schreibmethode gibt es hier eine Erklärung: Das
flüchtige Schlüsselwort verbietet die Neuordnung . Das sogenannte Verbot besteht darin, dass die gewöhnliche Variablenoperation vor dem Code vor sich selbst erfolgen muss und nicht hinter sich selbst neu angeordnet werden kann. Ebenso kann letzteres nicht zum gehen Vorderseite. Das flüchtige Schlüsselwort hier entspricht einer Barriere, die die oberen und unteren Regionen Ihrer eigenen trennt. Es ist nicht meine Aufgabe, Ihre eigenen Regionen neu anzuordnen, aber Sie können nicht über Regionen hinweg herumspielen. (Tatsächlich werden auch Speicherbarrieren verwendet. Beispielsweise werden alle Schreibvorgänge vor dieser Barriere in den Hauptspeicher geleert.)
Daher wird die Reihenfolge von 1-2 begrenzt.
Gleichzeitig wird, da flüchtiges Schreib-hb gelesen wird, 2 -3
gleichzeitig 3 und 4 Es gibt Abhängigkeiten, also 3-4

Daher wurde die Reihenfolge von 1-2-3-4 endlich realisiert, wie wir es uns gewünscht hatten.

Hier ist die Verwirklichung von Sichtbarkeit und Ordnung.


Was ist mit Atomizität?
Hat der zuvor erwähnte Flüchtige die Atomizität des Lesens / Schreibens nicht erkannt, warum ist er dann nicht atomar?

Bei der Atomizität handelt es sich um i ++, bei dem ein Wert basierend auf dem ursprünglichen Wert geändert wird.
Dies ist kein einfaches Lesen oder Schreiben.
Die Logik lautet:

	先读
	修改
	写

Es ist eine zusammengesetzte Operation. Flüchtig kann die Atomizität dieser Verbindungsoperation nicht erreichen, daher gibt es keinen Weg.

Stellen Sie sich vor, dass für i = 0
Thread A i ++ ausführt, Thread B auch i ++ ausführt,
was passieren könnte

	线程A		线程B
	读			
				读
	修改	
				修改
	写			
				写

Gemäß der obigen Sequenz wird i nicht 2, sondern 1 sein. Wenn Thread A i = 0 erhält, liest Thread B auch 0. Wenn Thread A das Schreiben beendet hat, hat Thread B das Lesen bereits beendet, so ist es auch 1 wenn es geschrieben ist. Es unterscheidet sich also von dem, was wir erwartet hatten.
Volatile kann dieses Problem nicht lösen.
(Ps: hier kann in Betracht gezogen werden, durch CAS oder Sperre zu lösen)

um zusammenzufassen

flüchtig erreicht

  • Sichtbarkeit: Die beiden Schritte Schreiben in den Arbeitsspeicher und Aktualisieren des Arbeitsspeichers in den Hauptspeicher werden zu einem zusammengefasst. Der Hauptspeicher wird in den Arbeitsspeicher aktualisiert, und der von der CPU aus dem Arbeitsspeicher erhaltene Wert wird ebenfalls kombiniert Eins, damit die flüchtige Variable gemacht wird. Das Lesen / Schreiben ist atomar, so dass garantiert werden kann, dass es sichtbar ist.
    Die Operation zum Realisieren der Zwei-in-Eins-Operation ist das Sperrpräfix in der Assembly, sodass der Cache der aktuellen CPU in den Speicher geleert wird und gleichzeitig andere Caches ungültig werden, sodass andere CPUs erneut abgerufen werden müssen der Cache. (Mit anderen Worten, aktualisieren Sie sofort nach dem Schreiben und aktualisieren und lesen Sie sofort beim Lesen)
  • Ordnungsmäßigkeit: Das flüchtige Schlüsselwort verhindert die Neuordnung von Anweisungen, und es werden Speicherbarrieren verwendet. Es spielt keine Rolle, wie Ihr Code vor und nach der Barriere neu angeordnet wird, aber die Vorderseite kann nicht befolgt werden, und die letzte kann nicht verwendet werden. Semantisch müssen Schreibvorgänge vor der Speicherbarriere in den Speicher geleert werden, damit die Lesevorgänge nach der Speicherbarriere die Ergebnisse der vorherigen Schreibvorgänge erhalten können. (Die Speicherbarriere verringert also die neue Leistung, was dazu führt, dass der Code nicht optimiert werden kann.)

Nicht implementiert:

  • Atomizität: Es wird nur die Atomizität einer einzelnen Operation realisiert. Beispielsweise liest i ++ zuerst, ändert später und schreibt zuletzt. Es handelt sich um eine zusammengesetzte Operation, sodass die Atomizität nicht garantiert ist

Referenz

Gleichzeitige Programmierung Java-Speichermodell + flüchtiges Schlüsselwort + HappenVor der
Sicherheit des Regelthreads (ein) - Verstehen Sie das flüchtige Schlüsselwort Java Interviewer als bevorzugtes flüchtiges Schlüsselwort gründlich

Ich denke du magst

Origin blog.csdn.net/qq_34687559/article/details/114329619
Empfohlen
Rangfolge