Im vorherigen Kapitel haben wir gelernt, dass es sich bei der Synchronisierung um eine Schwergewichtssperre handelt. Obwohl die JVM viele Optimierungen dafür vorgenommen hat, handelt es sich bei der unten eingeführten Volatilität um eine Leichtgewichtssperre. Wenn eine Variable flüchtig ist, ist dies kostengünstiger als die Verwendung von synchronisiert, da sie keinen Thread-Kontextwechsel und keine Thread-Planung verursacht. Die Java-Sprachspezifikation definiert „volatil“ wie folgt:
Die Java-Programmiersprache ermöglicht Threads den Zugriff auf gemeinsam genutzte Variablen. Um sicherzustellen, dass gemeinsam genutzte Variablen genau und konsistent aktualisiert werden können, sollten Threads dies sicherstellen Sie werden einzeln durch exklusive Sperren dieser Variablen erfasst.
Das Obige ist etwas verwirrend: Wenn eine Variable mit volatile geändert wird, kann Java sicherstellen, dass der Wert dieser Variablen konsistent ist Wenn die gemeinsam genutzte Variable aktualisiert wird, können andere Threads die Aktualisierung sofort sehen. Dies wird als Thread-Sichtbarkeit bezeichnet.
Obwohl volatile relativ einfach aussieht, ist die Verwendung nichts anderes als das Hinzufügen von volatile vor einer Variablen, aber es ist nicht einfach, es gut zu verwenden (LZ gibt zu, dass ich es immer noch schlecht verwende und bei der Verwendung immer noch unklar bin Es) ).
Volatile zu verstehen ist tatsächlich etwas schwierig. Es hängt mit dem Java-Speichermodell zusammen. Bevor wir Volatile verstehen, müssen wir die Konzepte des Java-Speichermodells verstehen. Dies ist Dies ist nur eine vorläufige Einführung. LZ wird das Java-Speichermodell später ausführlich vorstellen.
Wenn der Computer ein Programm ausführt, wird jede Anweisung in der CPU ausgeführt, und das Lesen und Schreiben von Daten ist während des Ausführungsprozesses zwangsläufig erforderlich. Wir wissen, dass die Daten für die Programmausführung im Hauptspeicher gespeichert sind. Das Lesen und Schreiben von Daten im Hauptspeicher ist nicht so schnell wie das Ausführen von Anweisungen in der CPU Der Hauptspeicher wird stark beeinträchtigt, daher gibt es einen CPU-Cache. Der CPU-Cache gilt nur für eine CPU und ist nur für den Thread relevant, der auf dieser CPU ausgeführt wird.
Obwohl der CPU-Cache das Effizienzproblem löst, bringt er ein neues Problem mit sich: die Datenkonsistenz. Wenn das Programm ausgeführt wird, werden die für den Betrieb erforderlichen Daten in den CPU-Cache kopiert. Bei der Ausführung von Vorgängen verarbeitet die CPU nicht mehr den Hauptspeicher, sondern liest und schreibt Daten direkt aus dem Cache. Die CPU wird die Daten in den Hauptspeicher übertragen. Geben Sie ein einfaches Beispiel:
i++
Wenn der Thread diesen Code ausführt, liest er zuerst i (i = 1) aus dem Hauptspeicher, kopiert es dann in den CPU-Cache und führt dann die CPU aus Operation von + 1 (2), dann die Daten (2) in den Cache schreiben und sie schließlich in den Hauptspeicher leeren. Tatsächlich ist es kein Problem, dies in einem einzelnen Thread zu tun, aber das Problem liegt im Multithread. Wie folgt:
Wenn es zwei Threads A und B gibt, die beide diese Operation ausführen (i++), sollte der i-Wert im Hauptspeicher nach unserem normalen logischen Denken = 3 sein, aber ist das der Fall? Die Analyse lautet wie folgt:
Zwei Threads lesen den Wert von i (1) aus dem Hauptspeicher in ihren jeweiligen Cache, dann führt Thread A die +1-Operation aus, schreibt das Ergebnis in den Cache und schreibt schließlich Im Hauptspeicher ist zu diesem Zeitpunkt der Hauptspeicher i == 2, Thread B führt den gleichen Vorgang aus und das i im Hauptspeicher ist immer noch = 2. Das Endergebnis ist also 2 und nicht 3. Bei diesem Phänomen handelt es sich um ein Cache-Konsistenzproblem.
Es gibt zwei Lösungen, um Kohärenz zwischenzuspeichern:
Durch Hinzufügen von LOCK# zum Bus
Durch Zwischenspeichern des Konsistenzprotokolls
Aber es gibt ein Problem mit Option 1. Sie ist exklusiv implementiert, das heißt, wenn der Bus mit LOCK# gesperrt ist, kann er nur von einer CPU ausgeführt werden blockiert werden und die Effizienz ist relativ gering.
Die zweite Option ist das Cache-Kohärenzprotokoll (MESI-Protokoll), das sicherstellt, dass die Kopie der in jedem Cache verwendeten gemeinsam genutzten Variablen konsistent ist. Die Kernidee ist wie folgt: Wenn eine CPU Daten schreibt und festgestellt wird, dass es sich bei der bearbeiteten Variablen um eine gemeinsam genutzte Variable handelt, werden andere CPUs benachrichtigt, dass die Cache-Zeile der Variablen ungültig ist Wenn Sie eine Variable verwenden, werden Sie feststellen, dass die Variable ungültig ist. Durch die Invalidierung werden die Daten aus dem Hauptspeicher neu geladen.
Oben wird erläutert, wie die Datenkonsistenz auf Betriebssystemebene sichergestellt werden kann. Werfen wir einen Blick auf das Java-Speichermodell und untersuchen das Java-Speichermodell ein wenig: Welche Garantien bieten wir und welche Methoden und Mechanismen werden in Java bereitgestellt, um die Korrektheit der Programmausführung bei der Multithread-Programmierung sicherzustellen?
Bei der gleichzeitigen Programmierung stoßen wir im Allgemeinen auf diese drei Grundkonzepte: Atomizität, Sichtbarkeit und Ordnung. Werfen wir einen Blick auf Volatilität
Atomizität: Das heißt, eine Operation oder mehrere Operationen werden entweder alle ausgeführt und der Ausführungsprozess wird nicht durch irgendwelche Faktoren unterbrochen, oder sie sind alle nicht ausgeführt.
Atomizität ist genau wie die Transaktionen in der Datenbank. Sie sind ein Team, das zusammen lebt und stirbt. Tatsächlich ist das Verständnis der Atomizität sehr einfach. Schauen wir uns das folgende einfache Beispiel an:
i = 0; ---1 j = i ; ---2 i++; ---3 i = j + 1; ---4
上面四个操作,有哪个几个是原子操作,那几个不是?如果不是很理解,可能会认为都是原子性操作,其实只有1才是原子操作,其余均不是。
1—在Java中,对基本数据类型的变量和赋值操作都是原子性操作;
2—包含了两个操作:读取i,将i值赋值给j
3—包含了三个操作:读取i值、i + 1 、将+1结果赋值给i;
4—同三一样
在单线程环境下我们可以认为整个步骤都是原子性操作,但是在多线程环境下则不同,Java只保证了基本数据类型的变量和赋值操作才是原子性的(注:在32位的JDK环境下,对64位数据的读取不是原子性操作*,如long、double)。要想在多线程环境下保证原子性,则可以通过锁、synchronized来确保。
volatile是无法保证复合操作的原子性
可见性是指当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看得到修改的值。
在上面已经分析了,在多线程环境下,一个线程对共享变量的操作对其他线程是不可见的。
Java提供了volatile来保证可见性。
当一个变量被volatile修饰后,表示着线程本地内存无效,当一个线程修改共享变量后他会立即被更新到主内存中,当其他线程读取共享变量时,它会直接从主内存中读取。
当然,synchronize和锁都可以保证可见性。
有序性:即程序执行的顺序按照代码的先后顺序执行。
在Java内存模型中,为了效率是允许编译器和处理器对指令进行重排序,当然重排序它不会影响单线程的运行结果,但是对多线程会有影响。
Java提供volatile来保证一定的有序性。最著名的例子就是单例模式里面的DCL(双重检查锁)。这里LZ就不再阐述了。
JMM比较庞大,不是上面一点点就能够阐述的。上面简单地介绍都是为了volatile做铺垫的。
volatile可以保证线程可见性且提供了一定的有序性,但是无法保证原子性。在JVM底层volatile是采用“内存屏障”来实现的。
上面那段话,有两层语义
保证可见性、不保证原子性
禁止指令重排序
第一层语义就不做介绍了,下面重点介绍指令重排序。
在执行程序时为了提高性能,编译器和处理器通常会对指令做重排序:
编译器重排序。编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序;
处理器重排序。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序;
指令重排序对单线程没有什么影响,他不会影响程序的运行结果,但是会影响多线程的正确性。既然指令重排序会影响到多线程执行的正确性,那么我们就需要禁止重排序。那么JVM是如何禁止重排序的呢?这个问题稍后回答,我们先看另一个原则happens-before,happen-before原则保证了程序的“有序性”,它规定如果两个操作的执行顺序无法从happens-before原则中推到出来,那么他们就不能保证有序性,可以随意进行重排序。其定义如下:
同一个线程中的,前面的操作 happen-before 后续的操作。(即单线程内按代码顺序执行。但是,在不影响在单线程环境执行结果的前提下,编译器和处理器可以进行重排序,这是合法的。换句话说,这一是规则无法保证编译重排和指令重排)。
监视器上的解锁操作 happen-before 其后续的加锁操作。(Synchronized 规则)
对volatile变量的写操作 happen-before 后续的读操作。(volatile 规则)
线程的start() 方法 happen-before 该线程所有的后续操作。(线程启动规则)
线程所有的操作 happen-before 其他线程在该线程上调用 join 返回成功后的操作。
如果 a happen-before b,b happen-before c,则a happen-before c(传递性)。
我们着重看第三点volatile规则:对volatile变量的写操作 happen-before 后续的读操作。为了实现volatile内存语义,JMM会重排序,其规则如下:
对happen-before原则有了稍微的了解,我们再来回答这个问题JVM是如何禁止重排序的?
Beobachten Sie den Assemblercode, der generiert wird, wenn das Schlüsselwort flüchtig hinzugefügt und nicht hinzugefügt wird. Es wird festgestellt, dass beim Hinzufügen des Schlüsselworts flüchtig eine zusätzliche Sperrpräfixanweisung vorhanden ist . Der Sperrpräfixbefehl entspricht tatsächlich einer Speicherbarriere. Eine Speicherbarriere ist eine Reihe von Verarbeitungsanweisungen, mit denen sequentielle Einschränkungen für Speicheroperationen implementiert werden. Die unterste flüchtige Schicht wird durch Speicherbarrieren implementiert. Die folgende Abbildung zeigt die Speicherbarrieren, die zur Vervollständigung der oben genannten Regeln erforderlich sind:
Lassen Sie uns die Analyse von Volatile vorerst beenden. Das JMM-System ist relativ groß und kann nicht in wenigen Worten erklärt werden JMM mit einer weiteren eingehenden Analyse von Volatilität.
Volatile scheint einfach, aber es ist immer noch schwierig, es zu verstehen. Hier ist nur ein grundlegendes Verständnis davon. „Volatile“ ist etwas leichter als „Synchronized“. Es kann „Synchronized“ in einigen Situationen ersetzen, kann „Volatile“ jedoch nur in bestimmten Situationen vollständig ersetzen. Um es zu verwenden, müssen die folgenden zwei Bedingungen erfüllt sein:
Der Schreibvorgang der Variablen hängt nicht vom aktuellen Wert ab.
Der Die Variable ist nicht in der Invariante der Variablen enthalten.
flüchtig wird häufig in zwei Szenarien verwendet: Statusmarkierung zwei, doppelte Überprüfung
Zhou Zhiming: „Vertiefendes Verständnis der Java Virtual Machine“
Fang Tengfei: „Die Kunst der gleichzeitigen Java-Programmierung“
Java Gleichzeitige Programmierung: Volatile-Schlüsselwortanalyse
Java Concurrent Programming: Die Verwendung und das Prinzip von Volatile
Im vorherigen Kapitel haben wir gelernt, dass synchronisiert ist eine Gewichtsstufensperre, obwohl die JVM viele Optimierungen dafür vorgenommen hat und die unten eingeführte Volatilität leichtgewichtig synchronisiert ist. Wenn eine Variable flüchtig ist, ist dies kostengünstiger als die Verwendung von synchronisiert, da sie keinen Thread-Kontextwechsel und keine Thread-Planung verursacht. Die Java-Sprachspezifikation definiert „volatil“ wie folgt:
Die Java-Programmiersprache ermöglicht Threads den Zugriff auf gemeinsam genutzte Variablen. Um sicherzustellen, dass gemeinsam genutzte Variablen genau und konsistent aktualisiert werden können, sollten Threads sicherstellen, dass diese Variable vorhanden ist einzeln durch eine exklusive Sperre erhältlich.
Das Obige ist etwas verwirrend: Wenn eine Variable mit volatile geändert wird, kann Java sicherstellen, dass der Wert dieser Variablen konsistent ist Wenn die gemeinsam genutzte Variable aktualisiert wird, können andere Threads die Aktualisierung sofort sehen. Dies wird als Thread-Sichtbarkeit bezeichnet.
Obwohl volatile relativ einfach aussieht, ist die Verwendung nichts anderes als das Hinzufügen von volatile vor einer Variablen, aber es ist nicht einfach, es gut zu verwenden (LZ gibt zu, dass ich es immer noch schlecht verwende und bei der Verwendung immer noch unklar bin Es) ).
Volatile zu verstehen ist tatsächlich etwas schwierig. Es hängt mit dem Java-Speichermodell zusammen. Bevor wir Volatile verstehen, müssen wir die Konzepte des Java-Speichermodells verstehen. Dies ist Dies ist nur eine vorläufige Einführung. LZ wird das Java-Speichermodell später ausführlich vorstellen.
Wenn der Computer ein Programm ausführt, wird jede Anweisung in der CPU ausgeführt, und das Lesen und Schreiben von Daten ist während des Ausführungsprozesses zwangsläufig erforderlich. Wir wissen, dass die Daten für die Programmausführung im Hauptspeicher gespeichert sind. Das Lesen und Schreiben von Daten im Hauptspeicher ist nicht so schnell wie das Ausführen von Anweisungen in der CPU Der Hauptspeicher wird stark beeinträchtigt, daher gibt es einen CPU-Cache. Der CPU-Cache gilt nur für eine CPU und ist nur für den Thread relevant, der auf dieser CPU ausgeführt wird.
Obwohl der CPU-Cache das Effizienzproblem löst, bringt er ein neues Problem mit sich: die Datenkonsistenz. Wenn das Programm ausgeführt wird, werden die für den Betrieb erforderlichen Daten in den CPU-Cache kopiert. Bei der Ausführung von Vorgängen verarbeitet die CPU nicht mehr den Hauptspeicher, sondern liest und schreibt Daten direkt aus dem Cache. Die CPU wird die Daten in den Hauptspeicher übertragen. Geben Sie ein einfaches Beispiel:
i++
Wenn der Thread diesen Code ausführt, liest er zuerst i (i = 1) aus dem Hauptspeicher, kopiert es dann in den CPU-Cache und führt dann die CPU aus Operation von + 1 (2), dann die Daten (2) in den Cache schreiben und sie schließlich in den Hauptspeicher leeren. Tatsächlich ist es kein Problem, dies in einem einzelnen Thread zu tun, aber das Problem liegt im Multithread. Wie folgt:
Wenn es zwei Threads A und B gibt, die beide diese Operation ausführen (i++), sollte der i-Wert im Hauptspeicher nach unserem normalen logischen Denken = 3 sein, aber ist das der Fall? Die Analyse lautet wie folgt:
Zwei Threads lesen den Wert von i (1) aus dem Hauptspeicher in ihren jeweiligen Cache, dann führt Thread A die +1-Operation aus, schreibt das Ergebnis in den Cache und schreibt schließlich Im Hauptspeicher ist zu diesem Zeitpunkt der Hauptspeicher i == 2, Thread B führt den gleichen Vorgang aus und das i im Hauptspeicher ist immer noch = 2. Das Endergebnis ist also 2 und nicht 3. Bei diesem Phänomen handelt es sich um ein Cache-Konsistenzproblem.
Es gibt zwei Lösungen für die Cache-Kohärenz:
Durch Hinzufügen von LOCK# zum Bus
通过缓存一致性协议
但是方案1存在一个问题,它是采用一种独占的方式来实现的,即总线加LOCK#锁的话,只能有一个CPU能够运行,其他CPU都得阻塞,效率较为低下。
第二种方案,缓存一致性协议(MESI协议)它确保每个缓存中使用的共享变量的副本是一致的。其核心思想如下:当某个CPU在写数据时,如果发现操作的变量是共享变量,则会通知其他CPU告知该变量的缓存行是无效的,因此其他CPU在读取该变量时,发现其无效会重新从主存中加载数据。
上面从操作系统层次阐述了如何保证数据一致性,下面我们来看一下Java内存模型,稍微研究一下Java内存模型为我们提供了哪些保证以及在Java中提供了哪些方法和机制来让我们在进行多线程编程时能够保证程序执行的正确性。
在并发编程中我们一般都会遇到这三个基本概念:原子性、可见性、有序性。我们稍微看下volatile
原子性:即一个操作或者多个操作 要么全部执行并且执行的过程不会被任何因素打断,要么就都不执行。
原子性就像数据库里面的事务一样,他们是一个团队,同生共死。其实理解原子性非常简单,我们看下面一个简单的例子即可:
i = 0; ---1 j = i ; ---2 i++; ---3 i = j + 1; ---4
上面四个操作,有哪个几个是原子操作,那几个不是?如果不是很理解,可能会认为都是原子性操作,其实只有1才是原子操作,其余均不是。
1—在Java中,对基本数据类型的变量和赋值操作都是原子性操作;
2—包含了两个操作:读取i,将i值赋值给j
3—包含了三个操作:读取i值、i + 1 、将+1结果赋值给i;
4—同三一样
在单线程环境下我们可以认为整个步骤都是原子性操作,但是在多线程环境下则不同,Java只保证了基本数据类型的变量和赋值操作才是原子性的(注:在32位的JDK环境下,对64位数据的读取不是原子性操作*,如long、double)。要想在多线程环境下保证原子性,则可以通过锁、synchronized来确保。
volatile是无法保证复合操作的原子性
可见性是指当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看得到修改的值。
在上面已经分析了,在多线程环境下,一个线程对共享变量的操作对其他线程是不可见的。
Java提供了volatile来保证可见性。
当一个变量被volatile修饰后,表示着线程本地内存无效,当一个线程修改共享变量后他会立即被更新到主内存中,当其他线程读取共享变量时,它会直接从主内存中读取。
当然,synchronize和锁都可以保证可见性。
有序性:即程序执行的顺序按照代码的先后顺序执行。
在Java内存模型中,为了效率是允许编译器和处理器对指令进行重排序,当然重排序它不会影响单线程的运行结果,但是对多线程会有影响。
Java提供volatile来保证一定的有序性。最著名的例子就是单例模式里面的DCL(双重检查锁)。这里LZ就不再阐述了。
JMM比较庞大,不是上面一点点就能够阐述的。上面简单地介绍都是为了volatile做铺垫的。
volatile可以保证线程可见性且提供了一定的有序性,但是无法保证原子性。在JVM底层volatile是采用“内存屏障”来实现的。
上面那段话,有两层语义
保证可见性、不保证原子性
禁止指令重排序
第一层语义就不做介绍了,下面重点介绍指令重排序。
在执行程序时为了提高性能,编译器和处理器通常会对指令做重排序:
编译器重排序。编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序;
处理器重排序。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序;
Die Neuordnung von Anweisungen hat keine Auswirkungen auf einzelne Threads. Sie hat keinen Einfluss auf die Ausführungsergebnisse des Programms, hat jedoch Auswirkungen auf die Korrektheit von Multi-Threads. Da die Neuordnung von Befehlen die Richtigkeit der Multithread-Ausführung beeinträchtigt, müssen wir die Neuordnung verbieten. Wie verhindert die JVM also eine Neuordnung? Diese Frage wird später beantwortet. Schauen wir uns zunächst ein anderes Prinzip an: Das Vorher-Geschehen-Prinzip stellt die „Ordnung“ des Programms sicher Sie können grundsätzlich keine Ordnung gewährleisten und können nach Belieben nachbestellt werden. Es ist wie folgt definiert:
Im selben Thread findet die vorherige Operation statt – vor der nachfolgenden Operation. (Das heißt, der Code wird der Reihe nach innerhalb eines einzelnen Threads ausgeführt. Der Compiler und der Prozessor können jedoch die Reihenfolge neu anordnen, ohne die Ausführungsergebnisse in einer Single-Thread-Umgebung zu beeinträchtigen, was zulässig ist. Mit anderen Worten, diese Regel kann keine Neuordnung der Kompilierung garantieren Befehlsumordnung).
Der Entsperrvorgang am Monitor erfolgt vor dem anschließenden Sperrvorgang. (Synchronisierte Regeln)
Schreibvorgänge für flüchtige Variablen erfolgen vor nachfolgenden Lesevorgängen. (flüchtige Regel)
Die start()-Methode des Threads wird ausgeführt – vor allen nachfolgenden Vorgängen des Threads. (Thread-Startregeln)
Alle Vorgänge eines Threads werden ausgeführt, bevor andere Threads Join für diesen Thread aufrufen und erfolgreich zurückkehren.
Wenn a vor b passiert, b vor c passiert, dann passiert a vor c (transitiv).
Konzentrieren wir uns auf die dritte flüchtige Regel: Schreibvorgänge für flüchtige Variablen erfolgen – vor nachfolgenden Lesevorgängen. Um eine flüchtige Speichersemantik zu erreichen, führt JMM eine Neuordnung durch. Die Regeln lauten wie folgt:
Nachdem wir nun ein wenig Verständnis für das Vorher-Vorher-Prinzip haben, beantworten wir diese Frage: Wie verhindert die JVM eine Neuordnung? ?
Beobachtet man den Assemblercode, der generiert wird, wenn das Schlüsselwort volatile hinzugefügt wird, und ohne das Schlüsselwort volatile, wird festgestellt, dass beim Hinzufügen des Schlüsselworts volatile ein zusätzliche Sperrpräfixanweisung . Der Sperrpräfixbefehl entspricht tatsächlich einer Speicherbarriere. Eine Speicherbarriere ist eine Reihe von Verarbeitungsanweisungen, mit denen sequentielle Einschränkungen für Speicheroperationen implementiert werden. Die unterste flüchtige Schicht wird durch Speicherbarrieren implementiert. Die folgende Abbildung zeigt die Speicherbarrieren, die zur Vervollständigung der oben genannten Regeln erforderlich sind:
Lassen Sie uns die Analyse von Volatile vorerst beenden. Das JMM-System ist relativ groß und kann nicht in wenigen Worten erklärt werden JMM mit einer weiteren eingehenden Analyse von Volatilität.
Volatile scheint einfach, aber es ist immer noch schwierig, es zu verstehen. Hier ist nur ein grundlegendes Verständnis davon. „Volatile“ ist etwas leichter als „Synchronized“. Es kann „Synchronized“ in einigen Situationen ersetzen, kann „Volatile“ jedoch nur in bestimmten Situationen vollständig ersetzen. Um es zu verwenden, müssen die folgenden zwei Bedingungen erfüllt sein:
Der Schreibvorgang der Variablen hängt nicht vom aktuellen Wert ab.
Der Die Variable ist nicht in der Invariante der Variablen enthalten.
flüchtig wird häufig in zwei Szenarien verwendet: Statusmarkierung zwei, doppelte Überprüfung
Das Obige ist [Dead Java Concurrency]-- -- -Eingehende Analyse des Implementierungsprinzips von Volatile. Weitere verwandte Inhalte finden Sie auf der chinesischen PHP-Website (www.php.cn)!