Viele Freunde haben vielleicht von dem Keyword Volatile gehört und haben es möglicherweise verwendet. Vor Java 5 war es ein kontroverses Schlüsselwort, da es häufig zu unerwarteten Ergebnissen führte. Erst nachdem Java 5 das volatile Schlüsselwort zurückgeführt hat, hat seine Vitalität wiedererlangt.
Obwohl das volatile Schlüsselwort buchstäblich einfach zu verstehen ist, ist es nicht einfach, es gut zu verwenden. Da das volatile Schlüsselwort mit dem Speichermodell von Java zusammenhängt, bevor wir den volatilen Schlüssel mitgeteilt haben, verstehen wir zunächst die Konzepte und Kenntnisse im Zusammenhang mit dem Speichermodell, analysieren wir dann das Implementierungsprinzip des volatilen Schlüsselworts und geben schließlich mehrere Szenarien für die Verwendung des volatilen Keywords.
Hier ist der Verzeichnisriss dieses Artikels:
1. verwandte Konzepte von Gedächtnismodellen
Wie wir alle wissen, wird jeder Anweisungen in der CPU, wenn ein Computer ein Programm ausführt, und während der Ausführung der Anweisung unweigerlich das Lesen und Schreiben von Daten beinhaltet. Da die temporären Daten während des Programmvorgangs im Hauptspeicher (physischer Speicher) gespeichert sind, gibt es zu diesem Zeitpunkt ein Problem. Da die CPU -Ausführungsgeschwindigkeit sehr schnell ist, ist der Prozess des Lesens von Daten aus dem Speicher und des Schreibens von Daten in den Speicher viel langsamer als die Ausführung von Anweisungen durch die CPU. Wenn die Datenoperation jederzeit durch Interaktion mit dem Speicher durchgeführt werden muss, wird die Ausführung der Befehlsausführung stark reduziert. Daher gibt es in der CPU einen Cache.
Das heißt, wenn das Programm ausgeführt wird, kopiert es die Daten, die der Vorgang vom Hauptspeicher zum CPU -Cache benötigt. Wenn die CPU dann Berechnungen durchführt, kann sie Daten direkt aus ihrem Cache lesen und Daten darauf schreiben. Nach Abschluss des Vorgangs werden die Daten im Cache in den Hauptspeicher gespült. Geben wir ein einfaches Beispiel an, wie den folgenden Code:
i = i + 1;
Wenn der Thread diese Anweisung ausführt, wird zuerst der Wert von I aus dem Hauptspeicher gelesen und dann eine Kopie in den Cache kopiert. Anschließend wird die CPU den Anweisungen ausführen, um 1 zu i hinzuzufügen, dann die Daten in den Cache zu schreiben und schließlich den neuesten Wert von I im Cache zum Hauptspeicher zu aktualisieren.
Es gibt kein Problem mit diesem Code, der in einem einzigen Thread ausgeführt wird, aber es wird Probleme beim Laufen in einem Multi-Thread geben. In Multi-Core-CPUs kann jeder Thread in einer anderen CPU ausgeführt werden, sodass jeder Thread beim Ausführen seinen eigenen Cache hat (für Einzelcore-CPUs tritt dieses Problem tatsächlich auf, wird jedoch separat in Form der Thread-Planung ausgeführt). In diesem Artikel nehmen wir als Beispiel Multi-Core-CPU ein.
Beispielsweise führen zwei Threads diesen Code gleichzeitig aus. Wenn der Wert von I zu Beginn 0 ist, hoffen wir, dass der Wert von I 2 nach der Ausführung der beiden Threads wird. Aber wird dies der Fall sein?
Es kann eine der folgenden Situationen geben: Am Anfang lesen zwei Threads den Wert von i und speichern ihn im Cache ihres jeweiligen CPUs, und dann führt Thread 1 eine Operation des Hinzufügens von 1 durch und schreibt dann den neuesten Wert von i in den Speicher. Zu diesem Zeitpunkt beträgt der Wert von I im Cache von Thread 2 immer noch 0. Nach der Durchführung der 1 -Operation beträgt der Wert von I 1 und dann den Wert von I für den Speicher.
Der Wert des Endergebnisses I ist 1, nicht 2. Dies ist das berühmte Problem der Cache -Konsistenz. Diese Variable, auf die mehrere Threads zugegriffen werden, wird normalerweise als gemeinsam genutzte Variable bezeichnet.
Das heißt, wenn eine Variable in mehreren CPUs zwischengespeichert wird (normalerweise nur während der Multithreading -Programmierung auftritt), kann es ein Problem der Cache -Inkonsistenz geben.
Um das Problem der Cache -Inkonsistenz zu lösen, gibt es normalerweise zwei Lösungen:
1) Durch Hinzufügen von Sperre# Lock in den Bus
2) durch das Cache -Kohärenzprotokoll
Diese beiden Methoden werden auf Hardwareebene bereitgestellt.
Im frühen CPUs wurde das Problem der Cache -Inkonsistenz gelöst, indem dem Bus gesperrte Schleusen hinzugefügt wurden. Da die Kommunikation zwischen der CPU und anderen Komponenten über den Bus durchgeführt wird, bedeutet dies, dass andere CPUs aus dem Zugriff auf andere Komponenten (z. B. Speicher) blockiert sind, so dass nur eine CPU den Speicher dieser Variablen verwenden kann. Wenn beispielsweise im obigen Beispiel ein Thread i = i +1 ausführt und das LCOK# -Schress -Signal während der Ausführung dieses Codes in den Bus gesendet wird, kann andere CPUs erst nach der Wartezeit des Codes die Variable aus dem Speicher lesen, in dem sich die Variable I befindet, und dann die entsprechenden Vorgänge ausführen. Dies löst das Problem der Cache -Inkonsistenz.
Die obige Methode hat jedoch ein Problem, da andere CPUs während der Busschloss nicht auf den Speicher zugreifen können, was zu Ineffizienz führt.
So entsteht ein Cache -Konsistenzprotokoll. Das berühmteste ist das MESI -Protokoll von Intel, das sicherstellt, dass die Kopie der in jedem Cache verwendeten gemeinsamen Variablen konsistent ist. Die Kernidee lautet: Wenn die CPU Daten schreibt, wenn sie feststellt, dass die betriebene Variable eine gemeinsam genutzte Variable ist, dh eine Kopie der Variablen in anderen CPUs, signalisiert sie, dass andere CPUs die Cache -Zeile der Variablen auf einen ungültigen Zustand festlegen. Wenn andere CPUs diese Variable lesen und feststellen müssen, dass die Cache -Zeile, die die Variable in ihrem Cache zwischenspeichert, ungültig ist, wird sie aus dem Speicher erneut 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 ordentliches Problem. Schauen wir uns zuerst diese drei Konzepte an:
1. Atomizität
Atomizität: Das heißt, ein Operation oder mehrere Operationen werden entweder alle ausgeführt und der Ausführungsprozess wird nicht durch keine Faktoren unterbrochen, oder es wird nicht ausgeführt.
Ein sehr klassisches Beispiel ist das Problem der Bankkontoübertragung:
Wenn Sie beispielsweise 1.000 Yuan von Konto A nach Konto B überweisen, wird es zwangsläufig 2 Operationen enthalten: 1000 Yuan von Konto A und 1000 Yuan zu Konto B.
Stellen Sie sich vor, welche Konsequenzen verursachen, wenn diese beiden Operationen nicht atomar sind. Wenn 1.000 Yuan von Konto A abgezogen werden, wird die Operation plötzlich beendet. Dann wurde 500 Yuan aus B zurückgezogen, und nach dem Abheben von 500 Yuan wurde dann der Betrieb von 1.000 Yuan zu Rechenschaft B.
Daher müssen diese beiden Operationen atomar sein, um sicherzustellen, dass es keine unerwarteten Probleme gibt.
Was sind die Ergebnisse, die sich in der gleichzeitigen Programmierung widerspiegeln?
Um das einfachste Beispiel zu geben, denken Sie darüber nach, was passieren würde, wenn der Prozess der Zuweisung einer 32-Bit-Variablen nicht atomar ist?
I = 9;
Wenn ein Thread diese Anweisung ausführt, gehe ich davon aus, dass die Zuordnung einer 32-Bit-Variablen zwei Prozesse umfasst: Zuordnung eines niedrigeren 16-Bit und Zuordnung eines höheren 16-Bit.
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, und das Lesen ist die falschen Daten.
2. Sichtbarkeit
Die Sichtbarkeit bezieht sich darauf, wenn mehrere Threads auf dieselbe Variable zugreifen, ein Thread den Wert der Variablen verändert und andere Threads den geänderten Wert sofort sehen können.
Für ein einfaches Beispiel finden Sie den folgenden Code:
// Der von Thread 1 ausgeführte Code ist int i = 0; i = 10; // Der von Thread 2 ausgeführte Code ist j = i;
Wenn der Ausführungs -Thread 1 CPU1 ist und der Ausführungs -Thread 2 CPU2 ist. Aus der obigen Analyse können wir sehen, dass der Anfangswert von I in den Cache von CPU1 in den Cache von CPU1 geladen wird, wenn Thread 1 ausführt und dann einen Wert von 10 zugewiesen wird. Dann wird der Wert von i im CPU1 -Cache 10, aber er wird nicht sofort in den Hauptspeicher geschrieben.
Zu diesem Zeitpunkt führt Thread 2 J = I aus, und es wird zunächst zum Hauptspeicher gelesen, um den Wert von i zu lesen und ihn in den CPU2 -Cache zu laden. Beachten Sie, dass der Wert von I im Speicher immer noch 0 beträgt, sodass der Wert von j 0 beträgt, nicht 10.
Dies ist das Sichtbarkeitsproblem. Nachdem Thread 1 die Variable I verändert hat, wird Thread 2 nicht sofort den durch Thread 1 geänderten Wert angezeigt.
3. Bestellung
Reihenfolge: Das heißt, die Reihenfolge der Ausführung von Programmen wird in der Reihenfolge des Codes ausgeführt. Für ein einfaches Beispiel finden Sie den folgenden Code:
int i = 0; boolesche Flagge = falsch; i = 1; // Anweisung 1 Flag = True; // Anweisung 2
Der obige Code definiert eine INT-Typ-Variable, eine boolesche Variable, und weist dann den beiden Variablen Werte zu. Aus der Sicht der Codesequenz stammt 1 vor Anweisung 2. Wenn der JVM diesen Code tatsächlich ausführt, wird sichergestellt, dass Anweisung 1 vor Anweisung 2 ausgeführt wird? Nicht unbedingt, warum? Anweisungen können hier auftreten.
Lassen Sie uns erklären, was die Nachbestellung der Anweisungen ist. Um die Effizienz des Programmbetriebs zu verbessern, kann der Prozessor im Allgemeinen den Eingabescode optimieren. Es stellt nicht sicher, dass die Ausführungsreihenfolge jeder Anweisung im Programm mit der Reihenfolge im Code übereinstimmt, jedoch sicherstellt, dass das endgültige Ausführungsergebnis des Programms und das Ergebnis der Codeausführungssequenz konsistent sind.
Beispielsweise hat im obigen Code, der Anweisung 1 und Anweisung 2 ausführt, zuerst keinen Einfluss auf das endgültige Programmergebnis, und es ist möglich, dass während des Ausführungsprozesses Anweisung 2 zuerst ausgeführt wird und Anweisung 1 später ausgeführt wird.
Beachten Sie jedoch, dass der Prozessor zwar die Anweisungen neu ordnet, jedoch sicherstellt, dass das Endergebnis des Programms mit der Codeausführungssequenz übereinstimmt. Was garantiert es also? Schauen wir uns das folgende Beispiel an:
int a = 10; // Anweisung 1Int r = 2; // Anweisung 2A = A + 3; // Anweisung 3R = a*a; // Aussage 4
Dieser Code enthält 4 Anweisungen, sodass eine mögliche Ausführungsreihenfolge lautet:
Ist es also möglich, die Ausführungsreihenfolge zu sein: Aussage 2 Anweisung 1 Anweisung 4 Anweisung 3
Es ist nicht möglich, da der Prozessor die Datenabhängigkeit zwischen Anweisungen bei der Neuordnung berücksichtigt. Wenn eine Anweisung 2 das Ergebnis von Anweisung 1 verwenden muss, stellt der Prozessor sicher, dass Anweisung 1 vor Anweisungen 2 ausgeführt wird.
Obwohl die Neuordnung nicht die Ergebnisse der Programmausführung innerhalb eines einzelnen Threads beeinflusst, was ist mit Multithreading? Lassen Sie uns unten ein Beispiel sehen:
// Thread 1: context = loadContext (); // Status 1Inited = true; // Status 2 // Thread 2: while (! Inited) {sleep ()} dosomethingwithconfig (Kontext);Da die Anweisungen 1 und 2 keine Datenabhängigkeiten haben, können sie im obigen Code neu angeordnet werden. Wenn eine Neuordnung auftritt, wird Anweisung 2 zunächst während der Ausführung von Thread 1 ausgeführt, und dies ist Thread 2 wird der Meinung, dass die Initialisierungsarbeiten abgeschlossen wurden, und dann wird sie aus der WHHE -Schleife herausspringen, um die Dosendosen -Methode (Kontext) auszuführen. Zu diesem Zeitpunkt wird der Kontext nicht initialisiert, was zu einem Programmfehler führt.
Wie aus den oben genannten Erkenntnissen ersichtlich ist, beeinflusst die Neuordnung der Anweisungen nicht die Ausführung eines einzelnen Threads, sondern die Richtigkeit der gleichzeitigen Ausführung von Threads.
Mit anderen Worten, um gleichzeitige Programme korrekt auszuführen, müssen Atomizität, Sichtbarkeit und Ordnung sichergestellt werden. Solange man nicht garantiert ist, kann das Programm falsch ausgeführt werden.
3. Java -Speichermodell
Ich habe über einige Probleme gesprochen, die in Erinnerungsmodellen und gleichzeitiger Programmierung auftreten können. Schauen wir uns das Java-Speichermodell an und untersuchen, welche garantiert das Java-Speichermodell für uns und welche Methoden und Mechanismen in Java bereitgestellt werden, um die Richtigkeit der Programmausführung bei der Durchführung von Multi-Thread-Programmen sicherzustellen.
In der Spezifikation "Virtual Machine" Java wird versucht, ein Java -Speichermodell (JMM) zu definieren, um die Unterschiede zwischen den Speicherzugriffsgründen zwischen verschiedenen Hardware -Plattformen und Betriebssystemen zu blockieren, um JAVA -Programme zu ermöglichen, um konsistente Speicherzugriffseffekte auf verschiedenen Plattformen zu erzielen. Was sieht das Java -Speichermodell vor? Es definiert die Zugriffsregeln für Variablen in einem Programm. Um es weiter auszudrücken, definiert es die Reihenfolge der Programmausführung. Beachten Sie, dass das Java -Speichermodell die Ausführungs -Engine nicht daran hindert, die Register oder Caches des Prozessors zu verbessern, um die Ausführungsgeschwindigkeit des Anweisungen zu verbessern, und den Compiler nicht auf die Nachbestellung von Anweisungen einschränken. Mit anderen Worten, im Java -Speichermodell wird es auch Cache -Konsistenzprobleme und Anweisungen zur Neuordnung von Problemen geben.
Das Java -Speichermodell sieht vor, dass sich alle Variablen im Hauptspeicher befinden (ähnlich dem oben erwähnten physischen Speicher), und jeder Thread hat seinen eigenen Arbeitsspeicher (ähnlich dem vorherigen Cache). Alle Vorgänge eines Threads auf einer Variablen müssen im Arbeitsspeicher ausgeführt werden und können nicht direkt im Hauptspeicher arbeiten. Und jeder Thread kann nicht auf den Arbeitsgedächtnis anderer Threads zugreifen.
Um ein einfaches Beispiel zu geben: Führen Sie in Java die folgende Erklärung aus:
i = 10;
Der Ausführungs -Thread muss zuerst die Cache -Zeile zuweisen, in der sich die Variable I in seinem eigenen Arbeits -Thread befindet, und sie dann in den Hauptspeicher schreiben. Anstatt den Wert 10 direkt in den Hauptspeicher zu schreiben.
Welche Garantien sorgt die Java -Sprache selbst für Atomizität, Sichtbarkeit und Ordnung?
1. Atomizität
In Java sind die Lese- und Zuordnungsoperationen von Variablen grundlegender Datentypen atomare Operationen, dh diese Operationen können nicht unterbrochen und entweder ausgeführt werden oder nicht.
Obwohl der obige Satz einfach erscheint, ist es nicht so leicht zu verstehen. Siehe das folgende Beispiel I:
Bitte analysieren Sie, welche der folgenden Operationen atomare Operationen sind:
x = 10; // Anweisung 1y = x; // Anweisung 2x ++; // Anweisung 3x = x + 1; // Aussage 4
Auf den ersten Blick können einige Freunde sagen, dass die Operationen in den oben genannten vier Aussagen alle atomare Operationen sind. Tatsächlich ist nur Aussage 1 eine atomare Operation, und keine der anderen drei Aussagen sind atomare Operationen.
Anweisung 1 weist den Wert 10 direkt X zu, was bedeutet, dass der Thread diese Anweisung ausführt und den Wert 10 direkt in den Arbeitsspeicher schreibt.
Anweisung 2 enthält tatsächlich 2 Operationen. Es muss zuerst den Wert von x lesen und dann den Wert von X in den Arbeitsspeicher schreiben. Obwohl die beiden Operationen des Lesens des Wertes von X und dem Schreiben des Werts von X in den Arbeitsspeicher atomare Operationen sind, sind sie nicht zusammen atomarer Operationen.
In ähnlicher Weise enthalten X ++ und X = X+1 3 Operationen: Lesen Sie den Wert von x, führen Sie den Betrieb von 1 durch und schreiben Sie den neuen Wert.
Daher ist nur der Betrieb von Anweisung 1 in den obigen vier Aussagen atomar.
Mit anderen Worten, nur einfache Lektüre und Zuordnung (und die Nummer müssen einer Variablen zugeordnet werden, und die gegenseitige Zuordnung zwischen Variablen ist keine Atomoperation) ist eine atomare Operation.
Hier gibt es jedoch eine Sache zu beachten: Unter der 32-Bit-Plattform muss das Lesen und die Zuordnung von 64-Bit-Daten durch zwei Operationen abgeschlossen werden, und seine Atomizität kann nicht garantiert werden. Es scheint jedoch, dass in der jüngsten JDK die JVM sichergestellt hat, dass das Lesen und die Zuordnung von 64-Bit-Daten ebenfalls atomarer Betrieb ist.
Aus dem obigen oben ist ersichtlich, dass das Java -Speichermodell nur sicherstellt, dass grundlegende Lesevorgänge und Zuordnungen atomare Operationen sind. Wenn Sie die Atomizität eines größeren Operationsbereichs erreichen möchten, kann dies durch synchronisierte und sperren erreicht werden. Da synchronisiert und sperren können, dass jederzeit nur ein Thread den Codeblock ausführt, wird natürlich kein Atomizitätsproblem vorhanden, wodurch die Atomizität sichergestellt wird.
2. Sichtbarkeit
Für die Sichtbarkeit stellt Java das volatile Schlüsselwort bereit, um die Sichtbarkeit zu gewährleisten.
Wenn eine gemeinsam genutzte Variable durch volatile geändert wird, stellt sie sicher, dass der geänderte Wert sofort auf den Hauptspeicher aktualisiert wird. Wenn andere Threads ihn lesen müssen, wird der neue Wert im Speicher gelesen.
Stammhelme gemeinsame Variablen können jedoch die Sichtbarkeit nicht garantieren, da es ungewiss ist, wann die normale gemeinsam genutzte Variable nach der Änderung in den Hauptspeicher geschrieben wird. Wenn andere Themen es lesen, kann der ursprüngliche alte Wert noch im Speicher liegen, sodass die Sichtbarkeit nicht garantiert werden kann.
Darüber hinaus kann synchronisiert und sperren auch die Sichtbarkeit sicherstellen. Synchronisierte und Sperren können sicherstellen, dass nur ein Thread gleichzeitig die Sperre erfasst und den Synchronisierungscode ausführt. Vor der Veröffentlichung der Sperre wird die Änderung der Variablen zum Hauptspeicher aktualisiert. Daher kann die Sichtbarkeit garantiert werden.
3. Bestellung
Im Java-Speichermodell dürfen Compiler und Prozessoren Anweisungen neu anordnen, aber der Nachbestellungsprozess wirkt sich nicht auf die Ausführung von Programmen mit Single-Thread-Programmen aus, sondern wirkt sich auf die Richtigkeit der gleichzeitigen Ausführung mit mehreren Threads aus.
In Java kann eine bestimmte "Orderline" durch das volatile Schlüsselwort sichergestellt werden (das spezifische Prinzip wird im nächsten Abschnitt erklärt). Darüber hinaus kann synchronisiert und sperren verwendet werden, um die Reihenfolge sicherzustellen. Synchronisierte und Sperren stellen natürlich sicher, dass es in jedem Moment einen Thread gibt, der den Synchronisationscode ausführt, der dem Synchronisationscode in Sequenz entspricht, was natürlich die Reihenfolge gewährleistet.
Darüber hinaus verfügt das Java-Speichermodell über eine angeborene "Orderline", dh es kann ohne Mittel garantiert werden, was normalerweise als Prinzip bezeichnet wird. Wenn die Ausführungsreihenfolge von zwei Operationen nicht aus dem vorderen Prinzip abgeleitet werden kann, können sie ihre Ordnung nicht garantieren und virtuelle Maschinen können sie nach Belieben neu ordnen.
Lassen Sie uns das vorgefertigte Prinzip (Prioritätsvorkommensprinzip) einführen:
Diese 8 Prinzipien werden aus "ausführlichem Verständnis von Java-virtuellen Maschinen" ausgewählt.
Unter diesen 8 Regeln sind die ersten 4 Regeln wichtiger, während die letzten 4 Regeln alle offensichtlich sind.
Erläutern wir die ersten 4 folgenden Regeln:
Nach meinem Verständnis für Programmreihenregeln scheint die Ausführung eines Programms Code in einem einzigen Thread bestellt zu werden. Beachten Sie, dass diese Regel jedoch erwähnt, dass "die vorne geschriebene Operation zuerst in der in der Rückseite geschriebenen Operation erfolgt", dies sollte die Reihenfolge sein, in der das Programm in der Codesequenz ausgeführt zu werden scheint, da die virtuelle Maschine den angewiesenen Programmcode neu ordnen kann. Obwohl die Neuordnung durchgeführt wird, steht das endgültige Ausführungsergebnis im Einklang mit der Programmsequentialausführung und wird nur Anweisungen neu anordnen, die keine Datenabhängigkeiten haben. Daher scheint die Programmausführung in einem einzigen Thread ordentlich ausgeführt zu werden, was mit Sorgfalt verstanden werden sollte. Tatsächlich wird diese Regel verwendet, um die Richtigkeit der Ausführungsergebnisse des Programms in einem einzigen Thread zu gewährleisten, kann jedoch nicht die Richtigkeit des Programms auf multi-thread-Weise garantieren.
Die zweite Regel ist auch leichter zu verstehen, dh wenn sich das gleiche Schloss in einem verschlossenen Zustand befindet, muss sie freigegeben werden, bevor der Sperrvorgang fortgesetzt werden kann.
Die dritte Regel ist eine relativ wichtige Regel und wird auch später diskutiert. Wenn intuitiv ein Thread zuerst eine Variable schreibt und dann ein Thread liest, wird der Schreibvorgang definitiv zuerst in der Lesevorgang stattfinden.
Die vierte Regel spiegelt tatsächlich wider, dass das vor Ort transsitive Prinzip.
4. Eingehende Analyse der volatilen Schlüsselwörter
Ich habe schon einmal über viele Dinge gesprochen, aber sie ebnen tatsächlich den Weg, um das volatile Schlüsselwort zu erzählen. Kommen wir also zum Thema.
1. Zweischichtige Semantik flüchtiger Schlüsselwörter
Sobald eine gemeinsame Variable (Klassenmitgliedvariablen, statische Mitgliedsvariablen) durch volatile modifiziert ist, hat sie zwei Semantikschichten:
1) Stellen Sie beim Betrieb dieser Variablen die Sichtbarkeit verschiedener Threads sicher, dh ein Thread ändert den Wert einer bestimmten Variablen, und dieser neue Wert ist sofort für andere Threads sichtbar.
2) Es ist verboten, Anweisungen nachzuordnen.
Schauen wir uns zuerst einen Code an. Wenn Thread 1 zuerst ausgeführt wird und Thread 2 später ausgeführt wird:
// Thread 1Boolean stop = false; while (! stop) {dosomething ();} // Thread 2stop = true;Dieser Code ist ein sehr typischer Code, und viele Personen können diese Markup -Methode bei der Unterbrechung von Threads verwenden. Aber wird dieser Code in der Tat ganz richtig ausgeführt? Wird der Thread unterbrochen? Nicht unbedingt. Vielleicht kann dieser Code die meiste Zeit Threads unterbrechen, kann aber auch dazu führen, dass der Thread nicht unterbrochen wird (obwohl diese Möglichkeit sehr klein ist, sobald dies zu einer toten Schleife führt).
Lassen Sie uns erklären, warum dieser Code dazu führen kann, dass der Thread nicht unterbricht. Wie bereits erläutert, hat jeder Thread während des Betriebs seinen eigenen Arbeitsspeicher. Wenn Thread 1 ausgeführt wird, kopiert er den Wert der Stoppvariablen und stellt ihn in seinen eigenen Arbeitsspeicher.
Wenn Thread 2 dann den Wert der Stoppvariablen ändert, aber keine Zeit hatte, ihn in den Hauptspeicher zu schreiben, geht Thread 2 für andere Dinge, und Thread 1 weiß nicht über die Änderungen von Thread 2 an der Stoppvariablen, sodass es weiterhin schleifen wird.
Aber nach dem Modifizieren mit volatilen wird es anders:
Erstens: Die Verwendung des volatilen Schlüsselworts erzwingt den geänderten Wert sofort in den Hauptspeicher.
Zweitens: Wenn Sie das volatile Schlüsselwort verwenden, ist bei Thread 2 die Cache -Zeile des Cache -Variablen -Stopps im Arbeitsspeicher des Thread 1 ungültig (wenn es in der Hardwareschicht reflektiert wird, ist die entsprechende Cache -Zeile im L1- oder L2 -Cache des CPU ungültig).
Drittens: Da die Cache -Zeile der Cache -Variablen im Arbeitsspeicher von Thread 1 ungültig ist, wird Thread 1 im Hauptspeicher gelesen, wenn der Wert des Variablen -Stopps erneut liest.
Wenn Thread 2 dann den Stoppwert ändert (natürlich gibt es 2 Operationen hier, wobei der Wert im Arbeitsspeicher von Thread 2 geändert und dann den geänderten Wert in den Speicher geschrieben wird), ist die Cache -Zeile der Cache -Variablen -Stop -Stop im Arbeitsspeicher des Thread 1 ungültig. Wenn Thread 1 liest, wird festgestellt, dass seine Cache -Zeile ungültig ist. Es wartet, bis die entsprechende Hauptspeicheradresse der Cache -Zeile aktualisiert wird, und lesen dann den neuesten Wert im entsprechenden Hauptspeicher.
Was dann Thread 1 liest, ist der neueste korrekte Wert.
2. garantiert volatile Atomizität?
Aus dem obigen wissen wir, dass das volatile Schlüsselwort die Sichtbarkeit von Operationen sicherstellt, kann jedoch volatil sicherstellen, dass die Operationen auf Variablen atomar sind?
Lassen Sie uns unten ein Beispiel sehen:
public class test {public volatile inp Inc = 0; public void erhöht () {inc ++; } public static void main (string [] args) {endgültig test Test = new Test (); für (int i = 0; i <10; i ++) {neuer Thread () {public void run () {für (int j = 0; j <1000; j ++) test.increase (); }; }.Start(); } while (Thread.ActiveCount ()> 1) // Stellen Sie sicher, dass die vorherigen Threads Thread abgeschlossen haben. System.out.println (test.inc); }}Denken Sie darüber nach, was das Ausgabeergebnis dieses Programms ist? Vielleicht denken einige Freunde, es sind 10.000. In der Tat wird jedoch festgestellt, dass die Ergebnisse jedes Laufs inkonsistent sind und eine Zahl von weniger als 10.000 sind.
Einige Freunde können Fragen haben, es ist falsch. Das obige ist, einen selbstverletzten Betrieb auf der Variablen Inc. durchzuführen. Da volatile Sichtweite sicherstellt, ist nach der Selbstaufsicht von Inc in jedem Thread der modifizierte Wert in anderen Fäden zu sehen. Daher haben 10 Threads 1000 Operationen durchgeführt, sodass der Endwert von INC 1000*10 = 10000 betragen sollte.
Hier gibt es ein Missverständnis. Das volatile Schlüsselwort kann die Sichtbarkeit sicherstellen, das obige Programm ist jedoch falsch, da es keine Atomizität garantieren kann. Die Sichtbarkeit kann nur sicherstellen, dass der neueste Wert jedes Mal gelesen wird, aber volatil kann die Atomizität des Variablenbetriebs nicht garantieren.
Wie bereits erwähnt, ist der automatische Inkrementbetrieb nicht atomar. Es enthält das Lesen des ursprünglichen Wertes einer Variablen, die Durchführung einer zusätzlichen Operation und das Schreiben in den Arbeitsspeicher. Das heißt, die drei Unteroperationen der Selbstversorgung können separat durchgeführt werden, was zu der folgenden Situation führen kann:
Wenn der Wert von Variable Inc zu einem bestimmten Zeitpunkt 10 ist, 10,
Thread 1 führt einen Selbstunternehmensvorgang in der Variablen durch. Thread 1 liest zuerst den ursprünglichen Wert der Variablen inc, und dann wird Thread 1 blockiert.
Anschließend führt Thread 2 selbst zu einer Selbstversorgung in der Variablen aus, und Thread 2 liest auch den ursprünglichen Wert der Variablen inc. Da Thread 1 nur einen Lesevorgang auf der Variablen -Inc ausführt und die Variable nicht ändert, wird die Cache -Zeile des Cache Inc -Cache -Variablen -Inc in Thread 2 nicht ungültig. Daher geht Thread 2 direkt zum Hauptspeicher, um den Wert von Inc zu lesen. Wenn festgestellt wird, dass der Wert von INC 10 beträgt, dann eine Operation von Hinzufügen von 1 durchführt und 11 in das Arbeitsspeicher schreibt und schließlich in das Hauptspeicher schreibt.
Dann führt Faden 1 dann den Additionsvorgang durch. Da der Wert von INC gelesen wurde, beachten Sie, dass der Wert von Inc in Thread 1 zu diesem Zeitpunkt immer noch 10 beträgt. Nachdem Thread 1 Inc hinzugefügt wurde, beträgt der Wert von INC 11, schreibt dann 11 in die Arbeit und schreibt ihn schließlich zum Hauptspeicher.
Nachdem die beiden Threads eine Selbststillstandsoperation durchgeführt haben, erhöht sich Inc nur um 1.
Nachdem einige Freunde dies erklärt haben, haben sie möglicherweise Fragen, es ist falsch. Ist es nicht garantiert, dass eine Variable die Cache -Zeile ungültig macht, wenn sie die volatile Variable geändert hat? Dann lesen andere Threads den neuen Wert. Ja, das ist richtig. Dies ist die flüchtige Variable-Regel in der oben genannten Vorschrift, aber es ist zu beachten, dass der INC-Wert nicht geändert wird, wenn Thread 1 die Variable liest und blockiert wird. Obwohl volatil sicher ist, dass Thread 2 den Wert von Variable Inc aus dem Speicher liest, hat Thread 1 ihn nicht geändert, sodass Thread 2 den geänderten Wert überhaupt nicht angezeigt wird.
Die Ursache ist, dass der automatische Operation kein Atombetrieb ist, und flüchtig kann nicht garantieren, dass ein Betrieb auf Variablen atomar ist.
Ändern Sie den obigen Code in einen der folgenden Aussagen können den Effekt erzielen:
Verwenden Sie synchronisiert:
public class test {public in inc = 0; public synchronisierte void erhöht () {inc ++; } public static void main (string [] args) {endgültig test Test = new Test (); für (int i = 0; i <10; i ++) {neuer Thread () {public void run () {für (int j = 0; j <1000; j ++) test.increase (); }; }.Start(); } while (Thread.ActiveCount ()> 1) // Stellen Sie sicher, dass die vorherigen Threads Thread abgeschlossen haben. System.out.println (test.inc); }} Verwenden von Schloss:
public class test {public in inc = 0; Lock Lock = New Reentrantlock (); public void erhöht () {lock.lock (); Versuchen Sie {Inc ++; } endlich {lock.unlock (); }} public static void main (String [] args) {endgültig test = new Test (); für (int i = 0; i <10; i ++) {neuer Thread () {public void run () {für (int j = 0; j <1000; j ++) test.increase (); }; }.Start(); } while (Thread.ActiveCount ()> 1) // Stellen Sie sicher, dass die vorherigen Threads ausgeführt wurden. System.out.println (test.inc); }} Verwenden von Atomicinteger:
public class test {public atomicInteger Inc = new AtomicInteger (); public void erhöht () {inc.getandIncrement (); } public static void main (string [] args) {endgültig test Test = new Test (); für (int i = 0; i <10; i ++) {neuer Thread () {public void run () {für (int j = 0; j <1000; j ++) test.increase (); }; }.Start(); } while (Thread.ActiveCount ()> 1) // Stellen Sie sicher, dass die vorherigen Threads ausgeführt wurden. System.out.println (test.inc); }}Einige atomare Betriebsklassen werden unter der Java.util.Concurrent.atomic Package of Java 1.5 bereitgestellt, nämlich die Selbstaufnahme (Fügen Sie 1 Vorgang), Selbstverlust (addieren 1 Betrieb), Additionsoperation (Hinzufügen einer Zahl) und Subtraktionsvorgang (Hinzufügen einer Nummer) von grundlegenden Datentypen, um sicherzustellen, dass diese Operationen die Atomoperationen erstellen. Atomic verwendet CAS, um Atomoperationen zu implementieren (vergleichen und Swap). CAS wird tatsächlich mit den vom Prozessor bereitgestellten CMPXchg -Anweisungen implementiert, und der Prozessor führt die CMPXCHG -Anweisungen aus, die ein Atombetrieb ist.
3. CAN VOLATILE SORNISSION?
Wie bereits erwähnt, kann das volatile Schlüsselwort die Neuordnung der Anweisungen untersagen, sodass flüchtig in gewissem Maße die Ordnung sicherstellen kann.
Es gibt zwei Bedeutungen, die die Nachbestellung von volatilen Schlüsselwörtern verboten haben:
1) Wenn das Programm einen Lese- oder Schreibbetrieb der volatilen Variablen ausführt, müssen alle Änderungen an den vorherigen Operationen vorgenommen worden sein, und das Ergebnis ist bereits für die nachfolgenden Operationen sichtbar. Die nachfolgenden Operationen müssen noch nicht durchgeführt worden sein;
2) Bei der Optimierung der Anweisungen kann die Anweisung, auf die die flüchtige Variable zugegriffen wird, nicht dahinter gestellt werden, noch können die Anweisungen nach der flüchtigen Variablen vor diesem platziert werden.
Vielleicht ist das, was oben gesagt wird, etwas verwirrend, also geben Sie ein einfaches Beispiel:
// x und y sind nichtflüchtige Variablen // Flag ist eine flüchtige Variable x = 2; // Anweisung 1y = 0; // Anweisung 2flag = true; // Anweisung 3x = 4; // Aussage 4y = -1; // Aussage 5
Da es sich bei der Flag -Variablen um eine flüchtige Variable handelt, wird bei der Durchführung des Anweisungsrekorders nicht vor Aussage 1 und 2 angegeben, und sie wird auch nicht nach Erklärung 3 und Aussage 4 und 5 platziert. Es ist jedoch nicht garantiert, dass die Reihenfolge der Aussage 1 und die Aussage 2 und die Aussage 4 und die Aussage 5 nicht garantiert sind.
Darüber hinaus kann das volatile Schlüsselwort sicherstellen, dass bei Ausführung der Anweisung 3 Anweisung 1 und Anweisung 2 ausgeführt werden müssen und die Ausführungsergebnisse von Anweisung 1 und Anweisung 2 in Anweisung 3, Anweisung 4 und Anweisung 5 sichtbar sind.
Kehren wir also zum vorherigen Beispiel zurück:
// Thread 1: context = loadContext (); // Status 1Inited = true; // Status 2 // Thread 2: while (! Inited) {sleep ()} dosomethingwithconfig (Kontext);Als ich dieses Beispiel gab, erwähnte ich, dass es möglich ist, dass Anweisung 2 vor Anweisung 1 ausgeführt wird, so lange, dass der Kontext nicht initialisiert wird, und Thread 2 verwendet den nicht initialisierten Kontext, um zu arbeiten, was zu einem Programmfehler führt.
Wenn die initierte Variable mit dem volatilen Schlüsselwort modifiziert wird, tritt dieses Problem nicht auf, da die Ausführung der Anweisung 2 auf jeden Fall sicherstellt, dass der Kontext initialisiert wurde.
4. Das Prinzip und der Umsetzungsmechanismus des flüchtigen Bestandteils
Die vorherige Beschreibung einiger Verwendungen des flüchtigen Schlüsselworts stammt aus. Let’s discuss how volatile ensures visibility and prohibits instructions to reorder.
下面这段话摘自《深入理解Java虚拟机》:
“观察加入volatile关键字和没有加入volatile关键字时所生成的汇编代码发现,加入volatile关键字时,会多出一个lock前缀指令”
lock前缀指令实际上相当于一个内存屏障(也成内存栅栏),内存屏障会提供3个功能:
1)它确保指令重排序时不会把其后面的指令排到内存屏障之前的位置,也不会把前面的指令排到内存屏障的后面;即在执行到内存屏障这句指令时,在它前面的操作已经全部完成;
2)它会强制将对缓存的修改操作立即写入主存;
3)如果是写操作,它会导致其他CPU中对应的缓存行无效。
五.使用volatile关键字的场景
synchronized关键字是防止多个线程同时执行一段代码,那么就会很影响程序执行效率,而volatile关键字在某些情况下性能要优于synchronized,但是要注意volatile关键字是无法替代synchronized关键字的,因为volatile关键字无法保证操作的原子性。通常来说,使用volatile必须具备以下2个条件:
1)对变量的写操作不依赖于当前值
2)该变量没有包含在具有其他变量的不变式中
实际上,这些条件表明,可以被写入volatile 变量的这些有效值独立于任何程序的状态,包括变量的当前状态。
事实上,我的理解就是上面的2个条件需要保证操作是原子性操作,才能保证使用volatile关键字的程序在并发时能够正确执行。
下面列举几个Java中使用volatile的几个场景。
1.状态标记量
volatile boolean flag = false; while(!flag){ doSomething();} public void setFlag() { flag = true;} volatile boolean inited = false;//线程1:context = loadContext(); inited = true; //线程2:while(!inited ){sleep()}doSomethingwithconfig(context);2.double check
class Singleton{ private volatile static Singleton instance = null; private Singleton() { } public static Singleton getInstance() { if(instance==null) { synchronized (Singleton.class) { if(instance==null) instance = new Singleton(); }} return Instance; }}参考资料:
《Java编程思想》
《深入理解Java虚拟机》
Das obige ist der gesamte Inhalt dieses Artikels. Ich hoffe, es wird für das Lernen aller hilfreich sein und ich hoffe, jeder wird Wulin.com mehr unterstützen.