Eigenschaften von Volatilität
Wenn wir eine gemeinsam genutzte Variable als Volatilität deklarieren, ist das Lesen/Schreiben dieser Variablen etwas ganz Besonderes. Eine gute Möglichkeit, die Natur von flüchtigen Variablen zu verstehen, besteht darin, sich einzelne Lese-/Schreibvorgänge auf flüchtige Variablen als Synchronisierung dieser einzelnen Lese-/Schreibvorgänge unter Verwendung derselben Monitorsperre vorzustellen. Lassen Sie uns dies anhand eines konkreten Beispiels veranschaulichen:
class VolatileFeaturesExample { volatile long vl = 0L; //使用volatile声明64位的long型变量 public void set(long l) { vl = l; //单个volatile变量的写 } public void getAndIncrement () { vl++; //复合(多个)volatile变量的读/写 } public long get() { return vl; //单个volatile变量的读 } }
Angenommen, es gibt mehrere Threads, die die drei Methoden des oben genannten Programms aufrufen. Dieses Programm entspricht semantisch dem folgenden Programm:
class VolatileFeaturesExample { long vl = 0L; // 64位的long型普通变量 public synchronized void set(long l) { //对单个的普通 变量的写用同一个监视器同步 vl = l; } public void getAndIncrement () { //普通方法调用 long temp = get(); //调用已同步的读方法 temp += 1L; //普通写操作 set(temp); //调用已同步的写方法 } public synchronized long get() { //对单个的普通变量的读用同一个监视器同步 return vl; } }
Wie im obigen Beispielprogramm gezeigt, verwendet ein einzelner Lese-/Schreibvorgang für eine flüchtige Variable dieselbe Monitorsperre wie ein Lese-/Schreibvorgang für eine gewöhnliche Variable Synchronized. Der Ausführungseffekt zwischen ihnen ist der gleiche.
Die Regel „Passiert vor“ der Monitorsperre gewährleistet die Speichersichtbarkeit zwischen den beiden Threads, die den Monitor freigeben und den Monitor abrufen. Dies bedeutet, dass ein Lesevorgang einer flüchtigen Variablen immer (jeder Thread) beim endgültigen Schreiben sichtbar ist zu dieser flüchtigen Variablen.
Die Semantik von Monitorsperren bestimmt, dass die Ausführung des Codes kritischer Abschnitte atomar ist. Dies bedeutet, dass selbst bei 64-Bit-langen und doppelten Variablen das Lesen und Schreiben in die Variable atomar ist, solange es sich um eine flüchtige Variable handelt. Wenn es mehrere flüchtige Operationen oder zusammengesetzte Operationen wie volatile++ gibt, sind diese Operationen als Ganzes nicht atomar.
Kurz gesagt, flüchtige Variablen selbst haben die folgenden Eigenschaften:
Sichtbarkeit. Bei einem Lesevorgang aus einer flüchtigen Variablen wird immer der letzte Schreibvorgang (durch einen beliebigen Thread) in die flüchtige Variable angezeigt.
Atomizität: Das Lesen/Schreiben einer einzelnen flüchtigen Variablen ist atomar, aber zusammengesetzte Operationen wie volatile++ sind nicht atomar.
Die Beziehung geschieht vor dem Aufbau der flüchtigen Schreib-/Lesebeziehung
Oben geht es um die Eigenschaften flüchtiger Variablen selbst. Für Programmierer hat flüchtig einen größeren Einfluss auf die Speichersichtbarkeit von Threads als flüchtig selbst . Funktionen sind wichtiger und erfordern mehr Aufmerksamkeit von uns.
Ab JSR-133 ermöglicht das Schreiben/Lesen flüchtiger Variablen die Kommunikation zwischen Threads.
Aus Sicht der Speichersemantik haben flüchtige Sperren und Monitorsperren den gleichen Effekt: flüchtiges Schreiben und Monitorfreigabe haben dieselbe Speichersemantik; flüchtiges Lesen und Monitorerfassung haben dieselbe Speichersemantik.
Bitte sehen Sie sich den folgenden Beispielcode an, der flüchtige Variablen verwendet:
class VolatileExample { int a = 0; volatile boolean flag = false; public void writer() { a = 1; //1 flag = true; //2 } public void reader() { if (flag) { //3 int i = a; //4 …… } } }
Angenommen, nachdem Thread A die Writer()-Methode ausgeführt hat, führt Thread B die Reader()-Methode aus. Gemäß der Regel „passiert vor“ kann die in diesem Prozess hergestellte Beziehung „passiert vor“ in zwei Kategorien unterteilt werden:
Gemäß der Regel für die Programmreihenfolge geschieht 1 vor 2;
Gemäß den Volatilitätsregeln passiert 2 vor 3.
Gemäß der Transitivitätsregel passiert 1 vor 4.
Die grafische Darstellung der oben genannten „Schiebt vor“-Beziehung sieht wie folgt aus:
In der obigen Abbildung stellen die beiden durch jeden Pfeil verbundenen Knoten ein Geschehen davor dar Beziehung. Schwarze Pfeile stellen Regeln für die Programmreihenfolge dar, orange Pfeile stellen flüchtige Regeln dar und blaue Pfeile stellen die Garantien dar, die durch die Kombination dieser Regeln gewährleistet werden.
Nachdem Thread A eine flüchtige Variable geschrieben hat, liest Thread B dieselbe flüchtige Variable. Alle gemeinsam genutzten Variablen, die für Thread A vor dem Schreiben der flüchtigen Variablen sichtbar waren, werden für Thread B sofort sichtbar, nachdem Thread B dieselbe flüchtige Variable gelesen hat.
Die Speichersemantik des flüchtigen Schreibens/Lesens
Die Speichersemantik des flüchtigen Schreibens ist wie folgt:
Beim Schreiben einer flüchtigen Variablen aktualisiert JMM die gemeinsam genutzte Variable im entsprechenden lokalen Speicher zum Thread zum Hauptspeicher.
Nehmen Sie als Beispiel das obige Beispielprogramm VolatileExample und gehen Sie davon aus, dass Thread A zuerst die Writer()-Methode und dann Thread B die Reader()-Methode ausführt. Zunächst werden das Flag und ein im lokalen Speicher gespeichert Die beiden Threads befinden sich im Ausgangszustand. Die folgende Abbildung ist ein schematisches Diagramm des Status gemeinsamer Variablen, nachdem Thread A flüchtiges Schreiben ausgeführt hat:
Wie in der Abbildung oben gezeigt, nachdem Thread A die Flag-Variable geschrieben hat, Der lokale Speicher von Thread A ist Die Werte der beiden von A aktualisierten gemeinsam genutzten Variablen werden in den Hauptspeicher geleert. Zu diesem Zeitpunkt sind die Werte der gemeinsam genutzten Variablen im lokalen Speicher A und im Hauptspeicher konsistent.
Die Speichersemantik des flüchtigen Lesens ist wie folgt:
Beim Lesen einer flüchtigen Variablen macht JMM den dem Thread entsprechenden lokalen Speicher ungültig. Als nächstes liest der Thread die gemeinsam genutzte Variable aus dem Hauptspeicher.
Das Folgende ist ein schematisches Diagramm des Status der gemeinsam genutzten Variablen, nachdem Thread B dieselbe flüchtige Variable gelesen hat:
Wie in der Abbildung oben gezeigt, wurde der lokale Speicher B nach dem Lesen der Flag-Variablen ungültig gemacht. Zu diesem Zeitpunkt muss Thread B die gemeinsam genutzte Variable aus dem Hauptspeicher lesen. Der Lesevorgang von Thread B führt dazu, dass die Werte der gemeinsam genutzten Variablen im lokalen Speicher B und im Hauptspeicher konsistent werden.
Wenn wir die beiden Schritte des flüchtigen Schreibens und des flüchtigen Lesens kombinieren, wird der Wert aller sichtbaren gemeinsam genutzten Variablen, bevor der schreibende Thread A die flüchtige Variable schreibt, sofort sichtbar, nachdem der lesende Thread B eine flüchtige Variable gelesen hat Zum Lesen von Thread B.
Das Folgende ist eine Zusammenfassung der Speichersemantik des flüchtigen Schreibens und des flüchtigen Lesens:
Thread A schreibt eine flüchtige Variable. Im Wesentlichen sendet Thread A eine Nachricht an einen Thread, der diese flüchtige Variable als nächstes liest . (seine Änderung der gemeinsam genutzten Variablen) Nachricht.
Thread B liest eine flüchtige Variable. Im Wesentlichen empfängt Thread B eine Nachricht, die von einem vorherigen Thread gesendet wurde (die gemeinsam genutzte Variable wurde vor dem Schreiben dieser flüchtigen Variablen geändert).
Thread A schreibt eine flüchtige Variable und Thread B liest dann die flüchtige Variable. Bei diesem Prozess sendet Thread A im Wesentlichen eine Nachricht über den Hauptspeicher an Thread B.
Implementierung der Semantik des flüchtigen Speichers
Als nächstes werfen wir einen Blick darauf, wie JMM die Semantik des flüchtigen Schreib-/Lesespeichers implementiert.
Wir haben bereits erwähnt, dass die Neuordnung in Compiler-Neuordnung und Prozessor-Neuordnung unterteilt ist. Um eine flüchtige Speichersemantik zu erreichen, begrenzt JMM die Neuordnungstypen dieser beiden Typen. Das Folgende ist eine Tabelle mit flüchtigen Neuordnungsregeln, die von JMM für Compiler formuliert wurden:
Ob es neu angeordnet werden kann
Zweiter Vorgang
Erster Vorgang
Normales Lesen/Schreiben
Flüchtiges Lesen
Flüchtiges Schreiben
Normales Lesen/Schreiben
NEIN
flüchtiges Lesen
NEIN
NEIN
NEIN
flüchtiges Schreiben
NEIN
NEIN
Beispiel Zum Beispiel Die letzte Zelle in der dritten Zeile hat folgende Bedeutung: Wenn in der Programmsequenz die erste Operation ein Lesen oder Schreiben einer gewöhnlichen Variablen und die zweite Operation ein flüchtiges Schreiben ist, kann der Compiler diese beiden Operationen nicht neu anordnen .
Aus der obigen Tabelle können wir sehen:
Wenn die zweite Operation ein flüchtiger Schreibvorgang ist, kann sie unabhängig von der ersten Operation nicht neu angeordnet werden. Diese Regel stellt sicher, dass Vorgänge vor einem flüchtigen Schreibvorgang vom Compiler nicht nach einem flüchtigen Schreibvorgang neu angeordnet werden.
Wenn die erste Operation ein flüchtiger Lesevorgang ist, kann sie unabhängig von der zweiten Operation nicht neu angeordnet werden. Diese Regel stellt sicher, dass Vorgänge nach einem flüchtigen Lesevorgang vom Compiler nicht neu angeordnet werden, um einem flüchtigen Lesevorgang vorauszugehen.
Wenn der erste Vorgang ein flüchtiger Schreibvorgang und der zweite Vorgang ein flüchtiger Lesevorgang ist, kann keine Neuordnung durchgeführt werden.
Um eine flüchtige Speichersemantik zu erreichen, fügt der Compiler beim Generieren von Bytecode Speicherbarrieren in die Befehlssequenz ein, um bestimmte Arten der Neuordnung des Prozessors zu verhindern. Da es für den Compiler nahezu unmöglich ist, eine optimale Anordnung zu finden, die die Gesamtzahl der eingefügten Barrieren minimiert, verfolgt JMM eine konservative Strategie. Das Folgende ist eine Strategie zum Einfügen einer JMM-Speicherbarriere, die auf einer konservativen Strategie basiert:
Fügen Sie vor jedem flüchtigen Schreibvorgang eine StoreStore-Barriere ein.
Fügen Sie nach jedem flüchtigen Schreibvorgang eine StoreLoad-Barriere ein.
Fügen Sie nach jedem flüchtigen Lesevorgang eine LoadLoad-Barriere ein.
Fügen Sie nach jedem flüchtigen Lesevorgang eine LoadStore-Barriere ein.
Die obige Strategie zum Einfügen von Speicherbarrieren ist sehr konservativ, kann jedoch sicherstellen, dass in jedem Programm auf jeder Prozessorplattform eine korrekte flüchtige Speichersemantik erhalten werden kann.
Das Folgende ist ein schematisches Diagramm der Befehlssequenz, die generiert wird, nachdem flüchtige Schreibvorgänge im Rahmen der konservativen Strategie in die Speicherbarriere eingefügt werden:
Die StoreStore-Barriere in Die obige Abbildung kann sicherstellen, dass flüchtige Schreibvorgänge ausgeführt werden. Zuvor waren alle normalen Schreibvorgänge davor bereits für jeden Prozessor sichtbar. Dies liegt daran, dass die StoreStore-Barriere sicherstellt, dass alle oben genannten normalen Schreibvorgänge vor flüchtigen Schreibvorgängen in den Hauptspeicher geleert werden.
Was hier noch interessanter ist, ist die StoreLoad-Barriere hinter dem flüchtigen Schreiben. Der Zweck dieser Barriere besteht darin, zu verhindern, dass flüchtige Schreibvorgänge durch nachfolgende flüchtige Lese-/Schreibvorgänge neu angeordnet werden. Da der Compiler häufig nicht genau bestimmen kann, ob nach einem flüchtigen Schreibvorgang eine StoreLoad-Barriere eingefügt werden muss (z. B. kehrt eine Methode unmittelbar nach einem flüchtigen Schreibvorgang zurück). Um sicherzustellen, dass die Semantik des flüchtigen Speichers korrekt implementiert wird, verfolgt JMM hier eine konservative Strategie: das Einfügen einer StoreLoad-Barriere nach jedem flüchtigen Schreibvorgang oder vor jedem flüchtigen Lesevorgang. Aus Sicht der Gesamtausführungseffizienz hat sich JMM dafür entschieden, nach jedem flüchtigen Schreibvorgang eine StoreLoad-Barriere einzufügen. Denn das übliche Verwendungsmuster der flüchtigen Schreib-Lese-Speichersemantik ist: Ein Schreibthread schreibt eine flüchtige Variable, und mehrere Lesethreads lesen dieselbe flüchtige Variable. Wenn die Anzahl der Lesethreads die Anzahl der Schreibthreads bei weitem übersteigt, führt die Entscheidung, nach dem flüchtigen Schreiben eine StoreLoad-Barriere einzufügen, zu erheblichen Verbesserungen der Ausführungseffizienz. Von hier aus können wir ein Merkmal der JMM-Implementierung erkennen: Stellen Sie zunächst die Korrektheit sicher und streben Sie dann nach der Ausführungseffizienz.
Das Folgende ist ein schematisches Diagramm der Befehlssequenz, die generiert wird, nachdem flüchtige Lesevorgänge im Rahmen der konservativen Strategie in die Speicherbarriere eingefügt werden:
上图中的LoadLoad屏障用来禁止处理器把上面的volatile读与下面的普通读重排序。LoadStore屏障用来禁止处理器把上面的volatile读与下面的普通写重排序。
上述volatile写和volatile读的内存屏障插入策略非常保守。在实际执行时,只要不改变volatile写-读的内存语义,编译器可以根据具体情况省略不必要的屏障。下面我们通过具体的示例代码来说明:
class VolatileBarrierExample { int a; volatile int v1 = 1; volatile int v2 = 2; void readAndWrite() { int i = v1; //第一个volatile读 int j = v2; // 第二个volatile读 a = i + j; //普通写 v1 = i + 1; // 第一个volatile写 v2 = j * 2; //第二个 volatile写 } … //其他方法 }
针对readAndWrite()方法,编译器在生成字节码时可以做如下的优化:
注意,最后的StoreLoad屏障不能省略。因为第二个volatile写之后,方法立即return。此时编译器可能无法准确断定后面是否会有volatile读或写,为了安全起见,编译器常常会在这里插入一个StoreLoad屏障。
上面的优化是针对任意处理器平台,由于不同的处理器有不同“松紧度”的处理器内存模型,内存屏障的插入还可以根据具体的处理器内存模型继续优化。以x86处理器为例,上图中除最后的StoreLoad屏障外,其它的屏障都会被省略。
前面保守策略下的volatile读和写,在 x86处理器平台可以优化成:
前文提到过,x86处理器仅会对写-读操作做重排序。X86不会对读-读,读-写和写-写操作做重排序,因此在x86处理器中会省略掉这三种操作类型对应的内存屏障。在x86中,JMM仅需在volatile写后面插入一个StoreLoad屏障即可正确实现volatile写-读的内存语义。这意味着在x86处理器中,volatile写的开销比volatile读的开销会大很多(因为执行StoreLoad屏障开销会比较大)。
JSR-133为什么要增强volatile的内存语义
在JSR-133之前的旧Java内存模型中,虽然不允许volatile变量之间重排序,但旧的Java内存模型允许volatile变量与普通变量之间重排序。在旧的内存模型中,VolatileExample示例程序可能被重排序成下列时序来执行:
在旧的内存模型中,当1和2之间没有数据依赖关系时,1和2之间就可能被重排序(3和4类似)。其结果就是:读线程B执行4时,不一定能看到写线程A在执行1时对共享变量的修改。
因此在旧的内存模型中 ,volatile的写-读没有监视器的释放-获所具有的内存语义。为了提供一种比监视器锁更轻量级的线程之间通信的机制,JSR-133专家组决定增强volatile的内存语义:严格限制编译器和处理器对volatile变量与普通变量的重排序,确保volatile的写-读和监视器的释放-获取一样,具有相同的内存语义。从编译器重排序规则和处理器内存屏障插入策略来看,只要volatile变量与普通变量之间的重排序可能会破坏volatile的内存语意,这种重排序就会被编译器重排序规则和处理器内存屏障插入策略禁止。
由于volatile仅仅保证对单个volatile变量的读/写具有原子性,而监视器锁的互斥执行的特性可以确保对整个临界区代码的执行具有原子性。在功能上,监视器锁比volatile更强大;在可伸缩性和执行性能上,volatile更有优势。如果读者想在程序中用volatile代替监视器锁,请一定谨慎。
以上就是Java内存模型深度解析:volatile的内容,更多相关内容请关注PHP中文网(m.sbmmt.com)!