前の章を通して、同期は重いロックであることを学びました。JVM はそれに対して多くの最適化を行っていますが、以下で紹介する volatile は軽量の同期です。変数が揮発性の場合、スレッド コンテキストの切り替えやスケジューリングが発生しないため、同期を使用するよりもコストが低くなります。 Java 言語仕様では、volatile を次のように定義しています。 Java プログラミング言語では、スレッドが共有変数にアクセスできるようにし、共有変数を正確かつ一貫して更新できるようにするために、スレッドはこの変数が排他ロックを通じて個別に取得されるようにする必要があります。
上記は少し複雑ですが、変数が volatile で変更された場合、スレッドが volatile で変更された共有変数を更新すると、Java はすべてのスレッドでこの変数の値が一貫していることを確認できます。他のスレッドはこの更新をすぐに見ることができます。これをスレッド可視性と呼びます。 volatile は比較的単純に見えますが、使用方法は変数の前に volatile を追加するだけですが、上手に使用するのは簡単ではありません (LZ は、私がまだ使い方が悪く、使用する際にまだ曖昧であることを認めています)。
メモリ モデル関連の概念
volatile を理解するのは実際には少し難しいため、volatile を理解する前に Java メモリ モデルの概念を理解する必要があります。ここでは、LZ について説明します。 Javaのメモリモデルについて詳しく紹介します。
コンピュータがプログラムを実行するとき、各命令はCPUで実行され、実行プロセス中にデータの読み取りと書き込みが必然的に伴います。現時点では、プログラムを実行するためのデータがメイン メモリに保存されているため、CPU での命令の実行ほど高速ではないという問題が発生します。メインメモリは効率に大きく影響するため、CPU キャッシュがあります。 CPU キャッシュは CPU に固有であり、その CPU で実行されているスレッドにのみ関連します。
i++
スレッドがこのコードを実行するとき、最初にメイン メモリから i ( i = 1) を読み取り、次にそのコピーを CPU キャッシュにコピーし、その後 CPU が + 1 (2) を実行します。次にデータ (2) をキャッシュに書き込み、最後にメイン メモリにリフレッシュします。実際、これをシングルスレッドで実行する場合には問題ありませんが、問題はマルチスレッドで発生します。以下の通り:
この操作 (i++) を実行する 2 つのスレッド A と B が存在する場合、通常の論理的思考によれば、メイン メモリ内の i 値は = 3 になるはずですが、これは事実でしょうか?分析は次のとおりです:
2 つのスレッドが i (1) の値をメイン メモリからそれぞれのキャッシュに読み取り、次にスレッド A が +1 演算を実行して結果をキャッシュに書き込み、最後にそれをメイン メモリに書き込みます。このとき、メインメモリ i==2 で、スレッド B が同じ操作を実行しますが、メインメモリの i =2 のままです。したがって、最終結果は 3 ではなく 2 になります。この現象はキャッシュの整合性の問題です。
キャッシュの一貫性には 2 つの解決策があります:
バスに LOCK# を追加する
キャッシュ一貫性プロトコルを介して
ただし、オプション 1 には問題があり、排他的なプロトコルが使用されます。この方法では、つまり、バスが LOCK# でロックされている場合、1 つの CPU のみが実行でき、他の CPU はブロックされなければなりませんが、これは比較的非効率です。
Java メモリ モデル
上記では、オペレーティング システム レベルからデータの一貫性を確保する方法について説明しています。Java メモリ モデルを見て、Java メモリ モデルが何を保証し、何を提供するのかを少し調べてみましょう。 Java のメソッドとメカニズムにより、マルチスレッド プログラミングを実行する際にプログラムの実行が正確であることが保証されます。
アトミック性
アトミック性を見てみましょう。つまり、1 つまたは複数の操作がすべて実行され、実行プロセスがいかなる要因によっても中断されないか、またはまったく実行されません。アトミック性はデータベース内のトランザクションと同じであり、一緒に生き、そして死んでいきます。実際、原子性を理解するのは非常に簡単です。以下の簡単な例を見てみましょう。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就不再阐述了。
剖析volatile原理
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是如何禁止重排序的?
volatile キーワードを追加した場合と、volatile キーワードを追加しない場合に生成されたアセンブリ コードを観察すると、volatile キーワードを追加すると、余分なロック プレフィックス命令があることがわかります。ロック プレフィックス命令は、実際にはメモリ バリアに相当します。メモリ バリアは、メモリ操作に対する逐次制限を実装するために使用される一連の処理命令です。 volatile の最下層はメモリバリアを通じて実装されます。次の図は、上記のルールを完了するために必要なメモリ バリアを示しています:
Volatile は今のところ分析されません。JMM システムは比較的大きいため、簡単に説明することはできません。後で、JMM を別のシステムと組み合わせます。揮発性物質の深度分析。
概要
volatile は単純に見えますが、理解するのはまだ難しいです。ここでは、その基本的な理解を示します。 Volatile は synchronized よりもわずかに軽いですが、場合によっては synchronized を置き換えることができますが、Volatile は特定の状況でのみ使用できます。これを使用するには、次の 2 つの条件を満たす必要があります:
変数への書き込み操作は現在の値に依存しません。
変数は他の変数との不変式に含まれません。
volatile は、ステータス マーク 2、ダブル チェックの 2 つのシナリオでよく使用されます
参考文献
Zhou Zhiming: 「Java 仮想マシンの深い理解」
Fang Tengfei: 「Java Concurrent」プログラミングの芸術
Java 並行プログラミング: volatile キーワード分析
Java 並行プログラミング: volatile の使用法と原理
前の章を通して、同期が強力なロックであることを学びました。 JVM 多くの最適化が行われており、以下に紹介する volatile は軽量で同期されています。変数で volatile を使用すると、スレッド コンテキストの切り替えやスケジューリングが発生しないため、synchronized を使用するよりもコストが低くなります。 Java 言語仕様では、volatile を次のように定義しています。 Java プログラミング言語では、スレッドが共有変数にアクセスできるようにし、共有変数を正確かつ一貫して更新できるようにするために、スレッドはこの変数が排他ロックを通じて個別に取得されるようにする必要があります。
volatile は比較的単純に見えますが、使用方法は変数の前に volatile を追加するだけですが、上手に使用するのは簡単ではありません (LZ は、私がまだ使い方が悪く、使用する際にまだ曖昧であることを認めています)。 メモリ モデル関連の概念 volatile を理解するのは実際には少し難しいため、volatile を理解する前に Java メモリ モデルの概念を理解する必要があります。ここでは、LZ について説明します。 Javaのメモリモデルについて詳しく紹介します。 オペレーティング システムのセマンティクス コンピューターがプログラムを実行するとき、各命令は CPU で実行され、実行プロセス中にデータの読み取りと書き込みが必ず発生します。現時点では、プログラムを実行するためのデータがメイン メモリに保存されているため、CPU での命令の実行ほど高速ではないという問題が発生します。メインメモリは効率に大きく影響するため、CPU キャッシュがあります。 CPU キャッシュは CPU に固有であり、その CPU で実行されているスレッドにのみ関連します。 CPU キャッシュは効率の問題を解決しますが、データの一貫性という新たな問題をもたらします。プログラムが実行されると、演算に必要なデータが CPU キャッシュにコピーされ、演算を実行するとき、CPU はメイン メモリを扱わなくなり、演算が完了したときにのみキャッシュからデータを直接読み書きします。 CPU はデータをメイン メモリにフラッシュします。簡単な例を挙げます:上記は少し複雑ですが、変数が volatile で変更された場合、スレッドが volatile で変更された共有変数を更新すると、Java はすべてのスレッドでこの変数の値が一貫していることを確認できます。他のスレッドはこの更新をすぐに見ることができます。これをスレッド可視性と呼びます。
i++スレッドがこのコードを実行すると、まずメイン メモリから i ( i = 1) を読み取り、次にそのコピーを CPU キャッシュにコピーし、その後 CPU が + 1 (2) を実行します。次にデータ (2) をキャッシュに書き込み、最後にメイン メモリにリフレッシュします。実際、これをシングルスレッドで実行する場合には問題ありませんが、問題はマルチスレッドで発生します。以下の通り: この操作 (i++) を実行する 2 つのスレッド A と B が存在する場合、通常の論理的思考によれば、メイン メモリ内の i 値は = 3 になるはずですが、これは事実でしょうか?分析は次のとおりです: 2 つのスレッドが i (1) の値をメイン メモリからそれぞれのキャッシュに読み取り、次にスレッド A が +1 演算を実行して結果をキャッシュに書き込み、最後にそれをメイン メモリに書き込みます。このとき、メインメモリ i==2 で、スレッド B が同じ操作を実行しますが、メインメモリの i =2 のままです。したがって、最終結果は 3 ではなく 2 になります。この現象はキャッシュの整合性の問題です。 キャッシュのコヒーレンスには 2 つの解決策があります: バスに LOCK# を追加することによる
通过缓存一致性协议
但是方案1存在一个问题,它是采用一种独占的方式来实现的,即总线加LOCK#锁的话,只能有一个CPU能够运行,其他CPU都得阻塞,效率较为低下。
第二种方案,缓存一致性协议(MESI协议)它确保每个缓存中使用的共享变量的副本是一致的。其核心思想如下:当某个CPU在写数据时,如果发现操作的变量是共享变量,则会通知其他CPU告知该变量的缓存行是无效的,因此其他CPU在读取该变量时,发现其无效会重新从主存中加载数据。
Java内存模型
上面从操作系统层次阐述了如何保证数据一致性,下面我们来看一下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就不再阐述了。
剖析volatile原理
JMM比较庞大,不是上面一点点就能够阐述的。上面简单地介绍都是为了volatile做铺垫的。
volatile可以保证线程可见性且提供了一定的有序性,但是无法保证原子性。在JVM底层volatile是采用“内存屏障”来实现的。
上面那段话,有两层语义
保证可见性、不保证原子性
禁止指令重排序
第一层语义就不做介绍了,下面重点介绍指令重排序。
在执行程序时为了提高性能,编译器和处理器通常会对指令做重排序:
编译器重排序。编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序;
处理器重排序。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序;
命令の並べ替えは、単一スレッドには影響しませんが、プログラムの実行結果には影響しませんが、マルチスレッドの正確さには影響します。命令の並べ替えはマルチスレッド実行の正確性に影響を与えるため、並べ替えを禁止する必要があります。では、JVM はどのようにして並べ替えを禁止するのでしょうか?この質問については後で説明します。happens before 原則は、プログラムの「順序性」を保証します。これは、2 つの操作の実行順序が、happens before 原則から推測できない場合に規定されます。 , その場合、秩序性を保証することはできず、自由に並べ替えることができます。その定義は次のとおりです:
同じスレッド内で、前の操作は後続の操作の前に発生します。 (つまり、コードは単一スレッド内で順番に実行されます。ただし、コンパイラーとプロセッサーは、単一スレッド環境での実行結果に影響を与えることなく順序を変更できます。これは合法です。言い換えれば、このルールはコンパイルの再配置と命令の再配置)。
モニターのロック解除操作は、その後のロック操作の前に行われます。 (同期ルール)
揮発性変数に対する書き込み操作は、後続の読み取り操作の前に発生します。 (揮発性ルール)
スレッドの start() メソッドは、スレッドの後続のすべての操作の前に発生します。 (スレッド起動ルール)
スレッドのすべての操作は、他のスレッドがこのスレッドで join を呼び出して正常に戻る前に発生します。
a が b より前に発生し、b が c より前に発生する場合、a は c より前に発生します (推移的)。
3 番目の volatile ルールに注目してみましょう。volatile 変数への書き込み操作は、後続の読み取り操作の前に発生します。揮発性メモリのセマンティクスを実現するために、JMM は並べ替えを行います。そのルールは次のとおりです:
前発生の原則を少し理解したところで、次の質問に答えてみましょう: JVM はどのように並べ替えを禁止しているのですか?
volatile キーワードを追加した場合と追加しなかった場合に生成されるアセンブリ コードを観察すると、volatile キーワードを追加すると、追加のロック プレフィックス命令が存在することがわかります。ロック プレフィックス命令は、実際にはメモリ バリアに相当します。メモリ バリアは、メモリ操作に対する逐次制限を実装するために使用される一連の処理命令です。 volatile の最下層はメモリバリアを通じて実装されます。次の図は、上記のルールを完了するために必要なメモリ バリアを示しています:
Volatile は今のところ分析されません。JMM システムは比較的大きいため、簡単に説明することはできません。後で、JMM を別のシステムと組み合わせます。揮発性物質の深度分析。
概要
volatile は単純に見えますが、理解するのはまだ難しいです。ここでは、その基本的な理解を示します。 Volatile は synchronized よりもわずかに軽いですが、場合によっては synchronized を置き換えることができますが、Volatile は特定の状況でのみ使用できます。これを使用するには、次の 2 つの条件を満たす必要があります:
変数への書き込み操作は現在の値に依存しません。
変数は他の変数との不変式に含まれません。
volatile は 2 つのシナリオでよく使用されます: ステータス マーク 2、ダブル チェック
上記は [Dead Java Concurrency]-----volatile の実装原理の詳細な分析、およびその他の関連事項です。 content PHP 中国語 Web サイト (www.php.cn) にご注意ください。