Heim >Java >javaLernprogramm >So verwenden Sie das Schlüsselwort volatile in Java
1. Verwandte Konzepte des Speichermodells
Wie wir alle wissen, wird beim Ausführen eines Programms durch einen Computer jede Anweisung in der CPU ausgeführt, und der Prozess der Befehlsausführung erfordert zwangsläufig das Lesen und Schreiben von Daten. Da die temporären Daten während der Ausführung des Programms im Hauptspeicher (physischer Speicher) gespeichert werden, besteht ein Problem: Da die CPU sehr schnell arbeitet, unterscheidet sich der Vorgang des Lesens von Daten aus dem Speicher und des Schreibens von Daten in den Speicher die der CPU. Die Geschwindigkeit der Befehlsausführung ist viel langsamer. Wenn also die Datenoperation zu irgendeinem Zeitpunkt durch Interaktion mit dem Speicher ausgeführt werden muss, wird die Geschwindigkeit der Befehlsausführung erheblich verringert. Es gibt also einen Cache in der CPU.
Das heißt, wenn das Programm ausgeführt wird, werden die für die Operation erforderlichen Daten aus dem Hauptspeicher in den Cache der CPU kopiert. Anschließend kann die CPU Daten direkt aus ihrem Cache lesen und in diesen schreiben, wenn die Operation abgeschlossen ist , werden die Daten im Cache im Hauptspeicher aktualisiert. Nehmen Sie ein einfaches Beispiel, wie den folgenden Code:
1i = i + 1;
Wenn der Thread diese Anweisung ausführt, liest er zuerst den Wert von i aus dem Hauptspeicher und kopiert dann eine Kopie in den Cache. Anschließend führt die CPU die Anweisung aus, um i um 1 zu erhöhen, und schreibt dann die Daten in den Cache schreibt schließlich den Wert in den Cache. Der neueste Wert von i im Cache wird in den Hauptspeicher geschrieben.
Es gibt kein Problem, wenn dieser Code in einem einzelnen Thread ausgeführt wird. Es treten jedoch Probleme auf, wenn er in mehreren Threads ausgeführt wird. In einer Multi-Core-CPU kann jeder Thread in einer anderen CPU ausgeführt werden, sodass jeder Thread beim Ausführen über einen eigenen Cache verfügt (bei Single-Core-CPUs tritt dieses Problem tatsächlich auch auf, wird jedoch vom Thread geplant. Formulare müssen separat ausgeführt werden ). In diesem Artikel nehmen wir als Beispiel eine Multi-Core-CPU.
Beispielsweise führen zwei Threads diesen Code gleichzeitig aus. Wenn der Wert von i anfänglich 0 ist, hoffen wir, dass der Wert von i nach der Ausführung der beiden Threads 2 wird. Aber wird dies der Fall sein?
Es kann die folgende Situation vorliegen: Zunächst lesen die beiden Threads jeweils den Wert von i und speichern ihn im Cache ihrer jeweiligen CPUs. Dann fügt Thread 1 1 hinzu und schreibt dann den neuesten Wert 1 von i in den Speicher. Zu diesem Zeitpunkt ist der Wert von i im Cache von Thread 2 immer noch 0. Nach dem Hinzufügen von 1 ist der Wert von i 1, und dann schreibt Thread 2 den Wert von i in den Speicher.
Der Endwert von i ist 1, nicht 2. Dies ist das berühmte Cache-Konsistenzproblem. Variablen, auf die mehrere Threads zugreifen, werden üblicherweise als gemeinsam genutzte Variablen bezeichnet.
Mit anderen Worten: Wenn eine Variable in mehreren CPUs zwischengespeichert wird (was normalerweise bei der Multithread-Programmierung der Fall ist), liegt möglicherweise ein Problem mit der Cache-Inkonsistenz vor.
Um das Problem der Cache-Inkonsistenz zu lösen, gibt es im Allgemeinen zwei Lösungen:
1) Durch Hinzufügen von LOCK# zum Bus
2) Durch das Cache-Konsistenzprotokoll
Diese beiden Methoden werden auf Hardwareebene bereitgestellt.
In frühen CPUs wurde das Problem der Cache-Inkonsistenz durch das Hinzufügen von LOCK#-Sperren am Bus gelöst. Da die Kommunikation zwischen der CPU und anderen Komponenten über den Bus erfolgt, bedeutet das Hinzufügen von LOCK# zum Bus, dass andere CPUs nicht auf andere Komponenten (z. B. Speicher) zugreifen können, sodass nur eine CPU diese verwenden kann variabler Speicher. Wenn im obigen Beispiel beispielsweise ein Thread i = i +1 ausführt und während der Ausführung dieses Codes das LCOK#-Sperrsignal auf dem Bus gesendet wird, können andere CPUs nur darauf warten, dass dieser Code vollständig ausgeführt wird . Lesen Sie die Variable aus dem Speicher, in dem sich die Variable i befindet, und führen Sie dann die entsprechende Operation aus. Dies löst das Problem der Cache-Inkonsistenz.
Bei der oben genannten Methode gibt es jedoch ein Problem. Während der Bussperre können andere CPUs nicht auf den Speicher zugreifen, was zu einer geringen Effizienz führt.
So erschien das Cache-Kohärenzprotokoll. Das bekannteste ist das MESI-Protokoll von Intel, das sicherstellt, dass die Kopie der in jedem Cache verwendeten gemeinsam genutzten Variablen konsistent ist. Seine Kernidee ist: Wenn die CPU beim Schreiben von Daten feststellt, dass die Betriebsvariable eine gemeinsam genutzte Variable ist, dh eine Kopie der Variablen auch in anderen CPUs vorhanden ist, wird ein Signal gesendet, um andere CPUs zum Festlegen des Caches zu benachrichtigen Wenn daher andere CPUs diese Variable lesen müssen und feststellen, dass die Cache-Zeile, die diese Variable in ihrem eigenen Cache zwischenspeichert, ungültig ist, wird sie erneut aus dem Speicher gelesen.
2. Drei Konzepte in der gleichzeitigen Programmierung
Bei der gleichzeitigen Programmierung stoßen wir normalerweise auf die folgenden drei Probleme: Atomizitätsproblem, Sichtbarkeitsproblem und Ordnungsproblem. Schauen wir uns zunächst diese drei Konzepte genauer an:
1. Atomizität
Atomarität: Das heißt, eine oder mehrere Operationen werden entweder vollständig ausgeführt und der Ausführungsprozess wird nicht durch irgendwelche Faktoren unterbrochen, oder sie werden überhaupt nicht ausgeführt.
Ein ganz klassisches Beispiel ist das Problem der Bankkontoüberweisung:
Beispielsweise muss die Übertragung von 1.000 Yuan von Konto A auf Konto B zwei Vorgänge umfassen: 1.000 Yuan von Konto A abziehen und 1.000 Yuan auf Konto B hinzufügen.
Stellen Sie sich vor, welche Konsequenzen es hätte, wenn diese beiden Operationen nicht atomar wären. Angenommen, der Vorgang wird plötzlich abgebrochen, nachdem 1.000 Yuan von Konto A abgebucht wurden. Dann hob er 500 Yuan von B ab. Nachdem er 500 Yuan abgehoben hatte, fügte er Konto B 1.000 Yuan hinzu. Dies führt dazu, dass Konto A zwar 1.000 Yuan abgezogen wird, Konto B jedoch die überwiesenen 1.000 Yuan nicht erhält.
Daher müssen diese beiden Vorgänge atomar sein, um sicherzustellen, dass keine unerwarteten Probleme auftreten.
Welche Konsequenzen wird die gleiche Reflexion in der gleichzeitigen Programmierung haben?
Um das einfachste Beispiel zu geben: Überlegen Sie, was passieren würde, wenn der Zuweisungsprozess zu einer 32-Bit-Variablen nicht atomar wäre?
1i = 9;
Wenn ein Thread diese Anweisung ausführt, gehe ich vorübergehend davon aus, dass das Zuweisen eines Werts zu einer 32-Bit-Variablen zwei Prozesse umfasst: das Zuweisen eines Werts zu den unteren 16 Bits und das Zuweisen eines Werts zu den oberen 16 Bits.
Dann kann eine Situation auftreten: Wenn der niedrige 16-Bit-Wert geschrieben wird, wird er plötzlich unterbrochen, und zu diesem Zeitpunkt liest ein anderer Thread den Wert von i, dann werden die falschen Daten gelesen.
2. Sichtbarkeit
Sichtbarkeit bedeutet, dass, wenn mehrere Threads auf dieselbe Variable zugreifen und ein Thread den Wert der Variablen ändert, andere Threads den geänderten Wert sofort sehen können.
Ein einfaches Beispiel finden Sie im folgenden Code:
//Code, der von Thread 1 ausgeführt wird
int i = 0;
i = 10;
//Code, der von Thread 2 ausgeführt wird
j = i;
Wenn Thread 1 von CPU1 ausgeführt wird, wird Thread 2 von CPU2 ausgeführt. Aus der obigen Analyse ist ersichtlich, dass Thread 1, wenn er den Satz i = 10 ausführt, zuerst den Anfangswert von i in den Cache von CPU1 lädt und ihn dann 10 und dann den Wert von i im Cache zuweist von CPU1 wird 10, wird aber nicht sofort in den Hauptspeicher geschrieben.
Zu diesem Zeitpunkt führt Thread 2 zuerst den Wert von i aus dem Hauptspeicher aus und lädt ihn in den Cache von CPU2. Beachten Sie, dass der Wert von i im Speicher zu diesem Zeitpunkt noch 0 ist Machen Sie den Wert von j = 0 und nicht 10.
Dies ist ein Sichtbarkeitsproblem. Nachdem Thread 1 die Variable i geändert hat, sieht Thread 2 nicht sofort den von Thread 1 geänderten Wert.
3. Ordnung
Ordnung: Das heißt, die Reihenfolge der Programmausführung entspricht der Reihenfolge des Codes. Ein einfaches Beispiel finden Sie im folgenden Code:
int i = 0;
boolesches Flag = false;
i = 1; //Aussage 1
flag = true; //Aussage 2
Der obige Code definiert eine Variable vom Typ int und eine Variable vom Typ boolean und weist dann den beiden Variablen jeweils Werte zu. Der Codesequenz nach zu urteilen, steht Anweisung 1 vor Anweisung 2. Wenn die JVM diesen Code tatsächlich ausführt, garantiert sie dann, dass Anweisung 1 vor Anweisung 2 ausgeführt wird? Nicht unbedingt, warum? Hier kann es zu einer Neuordnung der Anweisungen kommen.
Lassen Sie uns erklären, was eine Neuordnung von Anweisungen ist. Um die Effizienz des Programmbetriebs zu verbessern, kann der Prozessor nicht garantieren, dass die Ausführungsreihenfolge jeder Anweisung im Programm mit der Reihenfolge im Code übereinstimmt , aber es wird sicherstellen, dass das endgültige Ausführungsergebnis des Programms mit dem Ergebnis der sequentiellen Ausführung des Codes übereinstimmt.
Ob im obigen Code beispielsweise Anweisung 1 oder Anweisung 2 zuerst ausgeführt wird, hat keinen Einfluss auf das endgültige Programmergebnis. Dann ist es möglich, dass während der Ausführung Anweisung 2 zuerst und Anweisung 1 später ausgeführt wird.
Es ist jedoch zu beachten, dass der Prozessor zwar die Anweisungen neu anordnet, aber sicherstellt, dass das Endergebnis des Programms mit dem sequentiellen Ausführungsergebnis des Codes übereinstimmt. Auf welche Garantie verlässt er sich also? Schauen Sie sich das folgende Beispiel an:
int a = 10; //Anweisung 1
int r = 2; //Aussage 2
a = a + 3; //Aussage 3
r = a*a; //Aussage 4
Dieser Code hat 4 Anweisungen, daher ist eine mögliche Ausführungsreihenfolge:
Ist es also möglich, dass dies die Ausführungsreihenfolge ist: Aussage 2 Aussage 1 Aussage 4 Aussage 3
Unmöglich, da der Prozessor bei der Neuordnung die Datenabhängigkeiten zwischen Anweisungen berücksichtigt. Wenn eine Anweisung, Anweisung 2, das Ergebnis von Anweisung 1 verwenden muss, stellt der Prozessor sicher, dass Anweisung 1 vor Anweisung 2 ausgeführt wird.
Obwohl die Neuordnung keinen Einfluss auf die Ergebnisse der Programmausführung innerhalb eines einzelnen Threads hat, wie sieht es mit Multithreading aus? Schauen wir uns ein Beispiel an:
//Thread 1:
context = loadContext(); //Anweisung 1
inited = true; //Aussage 2
//Thread 2:
while(!initited){
schlafen()
}
doSomethingwithconfig(context);
Da im obigen Code Anweisung 1 und Anweisung 2 keine Datenabhängigkeit aufweisen, können sie neu angeordnet werden. Wenn eine Neuordnung auftritt, wird Anweisung 2 zuerst während der Ausführung von Thread 1 ausgeführt, und Thread 2 geht davon aus, dass die Initialisierungsarbeit abgeschlossen ist. Anschließend springt er aus der While-Schleife und führt die Methode doSomethingwithconfig (Kontext) aus, jedoch bei Diesmal ist der Kontext nicht vorhanden. Die Initialisierung führt zu einem Programmfehler.
Wie aus dem Obigen ersichtlich ist, wirkt sich die Neuordnung von Befehlen nicht auf die Ausführung eines einzelnen Threads aus, sondern auf die Korrektheit der gleichzeitigen Ausführung von Threads.
Mit anderen Worten: Damit gleichzeitige Programme korrekt ausgeführt werden können, müssen Atomizität, Sichtbarkeit und Ordnung gewährleistet sein. Solange einer davon nicht gewährleistet ist, kann es zu Fehlfunktionen des Programms kommen.
3. Java-Speichermodell
Zuvor habe ich über einige Probleme gesprochen, die bei Speichermodellen und gleichzeitiger Programmierung auftreten können. Werfen wir einen Blick auf das Java-Speichermodell und untersuchen, welche Garantien uns das Java-Speichermodell bietet und welche Methoden und Mechanismen in Java bereitgestellt werden, um die Korrektheit der Programmausführung bei der Multithread-Programmierung sicherzustellen.
Die Java Virtual Machine Specification versucht, ein Java Memory Model (JMM) zu definieren, um die Speicherzugriffsunterschiede zwischen verschiedenen Hardwareplattformen und Betriebssystemen abzuschirmen, sodass Java-Programme einen konsistenten Speicherzugriff auf verschiedenen Plattformen erzielen können. Was legt das Java-Speichermodell fest? Es definiert die Zugriffsregeln für Variablen im Programm. In größerem Umfang definiert es die Reihenfolge der Programmausführung. Beachten Sie, dass das Java-Speichermodell zur Erzielung einer besseren Ausführungsleistung weder die Ausführungs-Engine daran hindert, die Register oder den Cache des Prozessors zu verwenden, um die Geschwindigkeit der Befehlsausführung zu verbessern, noch den Compiler daran hindert, Anweisungen neu anzuordnen. Mit anderen Worten, im Java-Speichermodell wird es auch Probleme mit der Cache-Konsistenz und der Neuordnung von Befehlen geben.
Das Java-Speichermodell legt fest, dass alle Variablen im Hauptspeicher gespeichert werden (ähnlich dem zuvor erwähnten physischen Speicher) und jeder Thread über einen eigenen Arbeitsspeicher verfügt (ähnlich dem vorherigen Cache). Alle Operationen an Variablen durch Threads müssen im Arbeitsspeicher ausgeführt werden und können nicht direkt im Hauptspeicher ausgeführt werden. Und jeder Thread kann nicht auf den Arbeitsspeicher anderer Threads zugreifen.
Um ein einfaches Beispiel zu geben: Führen Sie in Java die folgende Anweisung aus:
1i = 10;
Der Ausführungsthread muss zunächst die Cache-Zeile, in der sich die Variable i befindet, in seinem eigenen Arbeitsthread zuweisen und sie dann in den Hauptspeicher schreiben. Anstatt den Wert 10 direkt in den Hauptspeicher zu schreiben.
Welche Garantien bietet die Java-Sprache selbst für Atomizität, Sichtbarkeit und Ordnung?
1. Atomizität
In Java sind Lese- und Zuweisungsoperationen zu Variablen grundlegender Datentypen atomare Operationen, das heißt, diese Operationen können nicht unterbrochen werden und werden entweder ausgeführt oder nicht.
Obwohl der obige Satz einfach erscheint, ist er nicht so leicht zu verstehen. Schauen Sie sich das folgende Beispiel an:
Bitte analysieren Sie, welche der folgenden Operationen atomare Operationen sind:
x = 10; //Aussage 1
y = x; //Aussage 2
x++; //Aussage 3
x = x + 1; //Aussage 4
Auf den ersten Blick mögen einige Freunde sagen, dass die Operationen in den obigen vier Anweisungen allesamt atomare Operationen sind. Tatsächlich ist nur Anweisung 1 eine atomare Operation, und die anderen drei Anweisungen sind keine atomaren Operationen.
Anweisung 1 weist x direkt den Wert 10 zu, was bedeutet, dass der Thread, der diese Anweisung ausführt, den Wert 10 direkt in den Arbeitsspeicher schreibt.
Anweisung 2 enthält tatsächlich zwei Operationen. Sie liest zunächst den Wert von x und schreibt dann den Wert von x in den Arbeitsspeicher , aber zusammen sind sie keine atomaren Operationen.
In ähnlicher Weise umfassen x++ und x = x+1 drei Operationen: Lesen des Werts von x, Addieren von 1 und Schreiben des neuen Werts.
Daher ist von den vier obigen Anweisungen nur die Operation von Anweisung 1 atomar.
Mit anderen Worten, nur einfaches Lesen und Zuweisen (und die Zahl muss einer Variablen zugewiesen werden, die gegenseitige Zuweisung zwischen Variablen ist keine atomare Operation) sind atomare Operationen.
Hier ist jedoch eines zu beachten: Auf einer 32-Bit-Plattform sind für das Lesen und Zuweisen von 64-Bit-Daten zwei Vorgänge erforderlich, und ihre Atomizität kann nicht garantiert werden. Es scheint jedoch, dass die JVM im neuesten JDK garantiert hat, dass das Lesen und Zuweisen von 64-Bit-Daten ebenfalls atomare Operationen sind.
Wie aus dem Obigen hervorgeht, garantiert das Java-Speichermodell nur, dass grundlegende Lese- und Zuweisungsoperationen atomare Operationen sind. Wenn Sie Atomizität für einen größeren Bereich von Operationen erreichen möchten, können Sie dies durch synchronisierte und Sperren erreichen. Da synchronisiert und Lock sicherstellen können, dass jeweils nur ein Thread den Codeblock ausführt, gibt es kein Atomizitätsproblem und stellt somit die Atomizität sicher.
2. Sichtbarkeit
Für die Sichtbarkeit stellt Java das Schlüsselwort volatile bereit, um die Sichtbarkeit sicherzustellen.
Wenn eine gemeinsam genutzte Variable flüchtig geändert wird, wird sichergestellt, dass der geänderte Wert sofort im Hauptspeicher aktualisiert wird. Wenn andere Threads ihn lesen müssen, wird der neue Wert aus dem Speicher gelesen.
Gewöhnliche gemeinsam genutzte Variablen können die Sichtbarkeit nicht garantieren, da nach der Änderung einer gewöhnlichen gemeinsam genutzten Variablen ungewiss ist, wann sie in den Hauptspeicher geschrieben wird. Wenn andere Threads sie lesen, befindet sich der ursprüngliche alte Wert zu diesem Zeitpunkt möglicherweise noch im Speicher Die Sichtbarkeit ist nicht gewährleistet.
Darüber hinaus kann die Sichtbarkeit auch durch Synchronized und Lock gewährleistet werden. Synchronized and Lock kann sicherstellen, dass nur ein Thread gleichzeitig die Sperre erhält und den Synchronisationscode ausführt und die Änderungen an den Variablen vor der Freigabe in den Hauptspeicher geleert werden sperren. Die Sicht ist somit gewährleistet.
3. Ordnung
Im Java-Speichermodell dürfen Compiler und Prozessor Anweisungen neu anordnen. Der Neuordnungsprozess wirkt sich jedoch nicht auf die Ausführung von Single-Thread-Programmen aus, sondern auf die Richtigkeit der gleichzeitigen Multi-Thread-Ausführung.
In Java können Sie das Schlüsselwort volatile verwenden, um eine gewisse „Ordnung“ sicherzustellen (das spezifische Prinzip wird im nächsten Abschnitt beschrieben). Darüber hinaus kann die Ordnung durch Synchronisierung und Sperre sichergestellt werden. Offensichtlich stellen Synchronisierung und Sperre sicher, dass zu jedem Zeitpunkt ein Thread den Synchronisierungscode ausführt.
Darüber hinaus verfügt das Java-Speichermodell über eine gewisse angeborene „Ordnung“, d. Wenn die Ausführungsreihenfolge zweier Operationen nicht aus dem Prinzip „Vorher geschieht“ abgeleitet werden kann, ist ihre Reihenfolge nicht garantiert und die virtuelle Maschine kann sie nach Belieben neu anordnen.
Lassen Sie uns das Prinzip „passiert vorher“ im Detail vorstellen:
Regeln für die Programmsequenz: Innerhalb eines Threads erfolgen entsprechend der Codereihenfolge die vorne geschriebenen Operationen vor den hinten geschriebenen Operationen
Sperrregeln: Zuerst wird ein Entsperrvorgang ausgeführt, bevor später derselbe Sperrvorgang ausgeführt wird
Regeln für flüchtige Variablen: Ein Schreibvorgang für eine Variable erfolgt zuerst, bevor ein nachfolgender Lesevorgang für die Variable erfolgt
Übergangsregel: Wenn Operation A vor Operation B und Operation B vor Operation C erfolgt, kann daraus geschlossen werden, dass Operation A vor Operation C stattfindet Thread-Startregeln: Die start()-Methode des Thread-Objekts wird bei jeder Aktion dieses Threads zuerst ausgeführt
Thread-Unterbrechungsregeln: Der Aufruf der Thread-Interrupt()-Methode erfolgt zuerst, wenn der Code des unterbrochenen Threads das Auftreten des Interrupt-Ereignisses erkennt
Thread-Beendigungsregeln: Alle Vorgänge in einem Thread werden zuerst ausgeführt, wenn der Thread beendet wird. Wir können erkennen, dass der Thread beendet wurde, indem wir die Thread.join()-Methode beenden und den Wert von Thread.isAlive() zurückgeben Objektfinalisierungsregel: Die Initialisierung eines Objekts erfolgt zuerst zu Beginn seiner finalize()-Methode
Diese 8 Prinzipien sind Auszüge aus „Vertiefendes Verständnis der Java Virtual Machine“.
Unter diesen 8 Regeln sind die ersten 4 Regeln wichtiger und die letzten 4 Regeln sind offensichtlich.
Lassen Sie uns die ersten vier Regeln unten erklären:
Mein Verständnis von Programmreihenfolgeregeln ist, dass die Ausführung eines Teils des Programmcodes in einem einzelnen Thread ordnungsgemäß zu erfolgen scheint. Beachten Sie, dass in dieser Regel zwar erwähnt wird, dass „vorne geschriebene Vorgänge vor hinten geschriebenen Vorgängen erfolgen“, dies jedoch bedeuten sollte, dass die Reihenfolge, in der das Programm scheinbar ausgeführt wird, der Reihenfolge des Codes entspricht, da die virtuelle Maschine möglicherweise ausgeführt wird Operationen am Programmcode neu angeordnet. Obwohl eine Neuordnung durchgeführt wird, stimmt das endgültige Ausführungsergebnis mit dem Ergebnis der sequentiellen Ausführung des Programms überein. Es werden nur Anweisungen neu angeordnet, die keine Datenabhängigkeiten aufweisen. Daher scheint die Programmausführung in einem einzelnen Thread in der richtigen Reihenfolge ausgeführt zu werden, was verständlich sein sollte. Tatsächlich wird diese Regel verwendet, um die Korrektheit der Ergebnisse der Programmausführung in einem einzelnen Thread sicherzustellen, sie kann jedoch nicht die Korrektheit der Programmausführung in mehreren Threads garantieren.
Die zweite Regel ist auch leichter zu verstehen. Das heißt, wenn sich dieselbe Sperre in einem einzelnen Thread oder in mehreren Threads befindet, muss die Sperre zuerst aufgehoben werden, bevor der Sperrvorgang fortgesetzt werden kann.
Die dritte Regel ist eine wichtigere Regel und wird im folgenden Artikel im Mittelpunkt stehen. Die intuitive Erklärung ist, dass, wenn ein Thread zuerst eine Variable schreibt und dann ein Thread sie liest, der Schreibvorgang definitiv vor dem Lesevorgang erfolgt.
Die vierte Regel spiegelt tatsächlich die transitive Natur des „passiert vorher“-Prinzips wider.
4. Eingehende Analyse des volatilen Keywords
Ich habe bereits über viele Dinge gesprochen, aber sie alle ebnen den Weg für die Diskussion über das Schlüsselwort „volatil“, also kommen wir als nächstes zum Thema.
1.Zwei Semantikebenen des volatilen Schlüsselworts
Sobald eine gemeinsam genutzte Variable (Klassenmitgliedsvariable, statische Klassenmitgliedsvariable) flüchtig geändert wird, weist sie zwei Semantikebenen auf:
1) Stellt die Sichtbarkeit sicher, wenn verschiedene Threads diese Variable bearbeiten, d. h. wenn ein Thread den Wert einer Variablen ändert, ist der neue Wert sofort für andere Threads sichtbar.
2) Das Nachbestellen von Anweisungen ist verboten.
Schauen wir uns zunächst einen Codeabschnitt an, wenn Thread 1 zuerst und Thread 2 später ausgeführt wird:
//Thread 1
boolean stop = false;
while(!stop){
doSomething();
}
//Thread 2
stop = true;
Dieser Code ist ein sehr typischer Code, und viele Leute verwenden diese Markierungsmethode möglicherweise, wenn sie einen Thread unterbrechen. Aber wird dieser Code tatsächlich völlig korrekt ausgeführt? Das heißt, wird der Thread unterbrochen? Nicht unbedingt, vielleicht in den meisten Fällen, kann dieser Code den Thread unterbrechen, aber er kann auch dazu führen, dass der Thread nicht unterbrochen werden kann (obwohl diese Möglichkeit sehr gering ist, aber sobald dies geschieht, wird eine Endlosschleife verursacht).
Lassen Sie uns erklären, warum dieser Code dazu führen kann, dass der Thread nicht unterbrochen werden kann. Wie bereits erläutert, verfügt jeder Thread während der Ausführung über einen eigenen Arbeitsspeicher. Wenn also Thread 1 ausgeführt wird, kopiert er den Wert der Stoppvariablen und legt ihn in seinem eigenen Arbeitsspeicher ab.
Wenn Thread 2 dann den Wert der Stoppvariablen ändert, aber bevor er Zeit hat, ihn in den Hauptspeicher zu schreiben, wendet sich Thread 2 anderen Dingen zu. Dann kennt Thread 1 die Änderung der Stoppvariablen durch Thread 2 nicht es wird weiterhin eine Schleife durchlaufen.
Aber nach der Verwendung einer flüchtigen Modifikation wird es anders:
Erstens: Die Verwendung des Schlüsselworts volatile erzwingt, dass der geänderte Wert sofort in den Hauptspeicher geschrieben wird
Zweitens: Wenn das Schlüsselwort volatile verwendet wird und Thread 2 eine Änderung vornimmt, wird die Cache-Zeile des Cache-Variablenstopps im Arbeitsspeicher von Thread 1 ungültig (wenn sie auf der Hardwareebene widergespiegelt wird, handelt es sich um den entsprechenden Cache). Zeile im L1- oder L2-Cache der CPU) Ungültig);
Drittens: Da die Cache-Zeile des Cache-Variablenstopps im Arbeitsspeicher von Thread 1 ungültig ist, wechselt Thread 1 in den Hauptspeicher, um den Wert des Variablenstopps erneut zu lesen.
Wenn Thread 2 dann den Stoppwert ändert (dazu gehören natürlich zwei Vorgänge: Ändern des Werts im Arbeitsspeicher von Thread 2 und anschließendes Schreiben des geänderten Werts in den Speicher), wird die Cache-Zeile der Variablen angehalten im Arbeitsspeicher von Thread 1 zwischengespeichert werden. Wenn Thread 1 liest, stellt er fest, dass seine Cache-Zeile ungültig ist. Er wartet auf die Aktualisierung der Hauptspeicheradresse, die der Cache-Zeile entspricht, und liest dann den neuesten Wert aus dem entsprechenden Hauptspeicher.
Was Thread 1 dann liest, ist der letzte korrekte Wert.
2. Garantiert Volatilität die Atomizität?
Aus dem oben Gesagten wissen wir, dass das Schlüsselwort volatile die Sichtbarkeit von Operationen garantiert. Garantiert volatile jedoch, dass Operationen an Variablen atomar sind?
Schauen wir uns ein Beispiel an:
öffentlicher Klassentest {
public volatile int inc = 0;
Erhöhung der öffentlichen Lücke() {
inc++;
}
public static void main(String[] args) {
letzter Testtest = neuer Test();
for(int i=0;i<10;i++){
neuer Thread(){
public void run() {
for(int j=0;j<1000;j++)
test.increase();
};
}.start();
}
while(Thread.activeCount()>1) //Stellen Sie sicher, dass alle vorherigen Threads ausgeführt wurden
Thread.yield();
System.out.println(test.inc);
}
}
Denken Sie darüber nach, was ist die Ausgabe dieses Programms? Vielleicht denken einige Freunde, dass es 10.000 sind. Wenn Sie es jedoch ausführen, werden Sie feststellen, dass die Ergebnisse jedes Mal inkonsistent sind und immer eine Zahl unter 10.000 sind.
Einige Freunde haben vielleicht Fragen, nein, das Obige ist eine automatische Inkrementierungsoperation für die Variable inc. Da volatile die Sichtbarkeit gewährleistet, kann der geänderte Wert nach dem Inkrementieren in jedem Thread angezeigt werden, sodass 10 Threads ausgeführt werden Operationen, dann sollte der Endwert von inc 1000*10=10000 sein.
Hier liegt ein Missverständnis vor. Das Schlüsselwort volatile kann sicherstellen, dass die Sichtbarkeit korrekt ist. Der Fehler im obigen Programm besteht jedoch darin, dass es keine Atomizität garantiert. Die Sichtbarkeit kann nur sicherstellen, dass jedes Mal der neueste Wert gelesen wird, Volatilität kann jedoch nicht die Atomizität von Operationen an Variablen garantieren.
Wie bereits erwähnt, ist die automatische Inkrementierung nicht atomar. Sie umfasst das Lesen des ursprünglichen Werts der Variablen, das Hinzufügen von 1 und das Schreiben in den Arbeitsspeicher. Das bedeutet, dass die drei Unteroperationen der Auto-Inkrement-Operation möglicherweise separat ausgeführt werden, was zu der folgenden Situation führen kann:
Wenn der Wert der Variablen inc zu einem bestimmten Zeitpunkt 10 beträgt,
Thread 1 führt eine automatische Inkrementierungsoperation für die Variable aus. Thread 1 liest zuerst den ursprünglichen Wert der Variablen inc, und dann wird Thread 1 blockiert Dann führt Thread 2 eine automatische Inkrementierungsoperation für die Variable aus, und Thread 2 liest auch den ursprünglichen Wert der Variablen inc. Da Thread 1 nur die Variable inc liest und die Variable nicht ändert, verursacht dies keine Arbeit von Thread 2 . Die Cache-Zeile der Cache-Variablen Inc im Speicher ist ungültig, daher geht Thread 2 direkt zum Hauptspeicher, um den Wert von Inc zu lesen. Er stellt fest, dass der Wert von Inc 10 ist, fügt dann 1 hinzu und schreibt 11 Arbeitsspeicher und schreibt es schließlich in den Hauptspeicher.
Anschließend fügt Thread 1 1 hinzu. Da der Wert von inc gelesen wurde, ist zu beachten, dass der Wert von inc im Arbeitsspeicher von Thread 1 zu diesem Zeitpunkt noch 10 beträgt. Daher beträgt der Wert von inc, nachdem Thread 1 1 zu inc hinzugefügt hat, 11 ., dann schreibe 11 in den Arbeitsspeicher und schließlich in den Hauptspeicher.
Nachdem die beiden Threads dann jeweils eine automatische Inkrementierungsoperation durchgeführt hatten, wurde inc nur um 1 erhöht.
Nachdem dies erklärt wurde, haben einige Freunde möglicherweise Fragen. Nein, ist nicht garantiert, dass die Cache-Zeile ungültig ist, wenn eine Variable eine flüchtige Variable ändert? Dann lesen andere Threads den neuen Wert, ja, das ist richtig. Dies ist die Regel für flüchtige Variablen in der Regel „Vorhergehend vor“ oben. Es ist jedoch zu beachten, dass der Inc-Wert nicht geändert wird, nachdem Thread 1 die Variable gelesen und blockiert hat. Obwohl flüchtig sicherstellen kann, dass Thread 2 den Wert der Variablen inc aus dem Speicher liest, ändert Thread 1 ihn nicht, sodass Thread 2 den geänderten Wert überhaupt nicht sieht.
Die Grundursache liegt hier. Die automatische Inkrementierungsoperation ist keine atomare Operation, und volatile kann nicht garantieren, dass eine Operation an einer Variablen atomar ist.
Ändern Sie den obigen Code in einen der folgenden Codes, um den Effekt zu erzielen:
Synchronisiert verwenden:
öffentlicher Klassentest {
public int inc = 0;
öffentliche synchronisierte Lückenerhöhung() {
inc++;
}
public static void main(String[] args) {
letzter Testtest = neuer Test();
for(int i=0;i<10;i++){
neuer Thread(){
public void run() {
for(int j=0;j<1000;j++)
test.increase();
};
}.start();
}
while(Thread.activeCount()>1) //Stellen Sie sicher, dass alle vorherigen Threads ausgeführt wurden
Thread.yield();
System.out.println(test.inc);
}
}
Code anzeigen
采用Sperre:
öffentlicher Klassentest {
public int inc = 0;
Lock lock = new ReentrantLock();
Erhöhung der öffentlichen Lücke() {
lock.lock();
versuche es mit {
inc++;
} endlich{
lock.unlock();
}
}
public static void main(String[] args) {
letzter Testtest = neuer Test();
for(int i=0;i<10;i++){
neuer Thread(){
public void run() {
for(int j=0;j<1000;j++)
test.increase();
};
}.start();
}
while(Thread.activeCount()>1) //保证前面的线程都执行完
Thread.yield();
System.out.println(test.inc);
}
}
Code anzeigen
采用AtomicInteger:
öffentlicher Klassentest {
public AtomicInteger inc = new AtomicInteger();
Erhöhung der öffentlichen Lücke() {
inc.getAndIncrement();
}
public static void main(String[] args) {
letzter Testtest = neuer Test();
for(int i=0;i<10;i++){
neuer Thread(){
public void run() {
for(int j=0;j<1000;j++)
test.increase();
};
}.start();
}
while(Thread.activeCount()>1) //保证前面的线程都执行完
Thread.yield();
System.out.println(test.inc);
}
}
Code anzeigen
Java 1.5, java.util.concurrent.atomic减1操作)、以及加法操作(加一个数)减法操作(减一个数)进行了封装,保证这些操作是原子性操作.atomic是利用CAS来实现原子性操作的(Compare And Swap), CAS实际上是利用处理器提供的CMPXCHG指令实现的,而处理器执行CMPXCHG指令是一个原子性操作.
3.volatile能保证有序性吗?
在前面提到volatile关键字能禁止指令重排序,所以volatile能在一定程度上保证有序性.
flüchtig 1)当程序执行到volatile变量的读操作或者写操作时, 在其前面的操作的更改肯定全部已经进行, 且结果已经对后面的操作可见;在其后面的操作肯定还没有进行;
2)语句放到其前面执行.
可能上面说的比较绕,举个简单的例子:
//x、y为非volatile变量
//flag为volatile变量
x = 2; //语句1
y = 0; //语句2
Flag = wahr; //语句3
x = 4; //语句4
y = -1; //语句5
由于flag变量为volatile变量,那么在进行指令重排序的过程的时候,不会将语句3放到语句1、语句2前面也不会讲语句3放到语句4、语句5后面.但是要注意语句1和语句2的顺序、语句4和语句5的顺序是不作任何保证的。
并且volatile关键字能保证, 执行到语句3时, 语句1和语句2必定是执行完毕了的, 且语句1和语句2的执行结果对语句3、语句4、语句5是可见的。
那么我们回到前面举的一个例子:
//线程1:
context = LoadContext(); //语句1
initiert = wahr; //语句2
//线程2:
while(!inited ){
schlafen()
}
doSomethingwithconfig(context);
前面举这个例子的时候, 提到有可能语句2会在语句1之前执行, 那么久可能导致context还没被初始化,而线程2中就使用未初始化的context去进行操作,导致程序出错。
这里如果用volatile关键字对initiiert变量进行修饰,就不会出现这种问题了,因为当执行到语句2时,必定能保证context已经初始化完毕.
4.volatile的原理和实现机制
前面讲述了源于volatile关键字的些使用,下面我们来探讨一下volatile到底如何保证可见性和禁止指令重排序的.
下面这段话摘自《深入理解Java虚拟机》:
„观察加入volatile关键字和没有加入volatile关键字时所生成的汇编代码发现,加入volatile关键字时,会多出一个lock前缀指令“
lock前缀指令实际上相当于一个内存屏障(也成内存栅栏),内存屏障会提供3个功能:
1)排到内存屏障的后面;即在执行到内存屏障这句指令时,在它前面的操作已经全部完成;
2)它会强制将对缓存的修改操作立即写入主存;
3). 五.使用volatile关键字的场景
Das synchronisierte Schlüsselwort verhindert, dass mehrere Threads gleichzeitig einen Code ausführen, was sich stark auf die Effizienz der Programmausführung auswirkt. Das flüchtige Schlüsselwort weist in einigen Fällen eine bessere Leistung auf als das synchronisierte Schlüsselwort. Es ist jedoch zu beachten, dass das flüchtige Schlüsselwort das synchronisierte nicht ersetzen kann Schlüsselwort, da das Schlüsselwort volatile die Atomizität der Operation nicht garantieren kann. Im Allgemeinen müssen die folgenden zwei Bedingungen erfüllt sein, um flüchtig zu verwenden:
1) Schreiboperationen in Variablen hängen nicht vom aktuellen Wert ab
2) Die Variable ist nicht in einer Invariante mit anderen Variablen enthalten
Tatsächlich zeigen diese Bedingungen an, dass die gültigen Werte, die in flüchtige Variablen geschrieben werden können, unabhängig von jedem Programmstatus sind, einschließlich des aktuellen Status der Variablen.
Tatsächlich verstehe ich, dass die beiden oben genannten Bedingungen sicherstellen müssen, dass die Operation eine atomare Operation ist, um sicherzustellen, dass Programme, die das Schlüsselwort volatile verwenden, während der Parallelität korrekt ausgeführt werden können.
Hier sind einige Szenarien, in denen Volatile in Java verwendet wird.
1. Betrag der Statusmarkierung
volatile boolean flag = false;
while(!flag){
doSomething();
}
public void setFlag() {
flag = true;
}
volatile boolean inited = false;
//Thread 1:
context = loadContext();
inited = true;
//Thread 2:
while(!initited){
schlafen()
}
doSomethingwithconfig(context);
2. Überprüfen Sie noch einmal
Klasse Singleton{
private flüchtige statische Singleton-Instanz = null;
privater Singleton() {
}
öffentliches statisches Singleton getInstance() {
if(instance==null) {
synchronisiert (Singleton.class) {
if(instance==null)
Instanz = new Singleton();
}
}
Rückgabeinstanz;
}
}
Das obige ist der detaillierte Inhalt vonSo verwenden Sie das Schlüsselwort volatile in Java. Für weitere Informationen folgen Sie bitte anderen verwandten Artikeln auf der PHP chinesischen Website!