volatile の特徴
共有変数を volatile として宣言すると、この変数の読み取り/書き込みは非常に特殊になります。 volatile の性質を理解する良い方法は、volatile 変数への個々の読み取り/書き込みが、同じモニター ロックを使用してこれらの個々の読み取り/書き込み操作を同期していると考えることです。具体的な例で説明してみましょう。次のサンプル コードを参照してください。
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变量的读 } }
上記のプログラムの 3 つのメソッドをそれぞれ呼び出す複数のスレッドがあるとします。このプログラムは、意味的には次のプログラムと同等です。例 上記のプログラム例に示すように、揮発性変数に対する単一の読み取り/書き込み操作は、同じモニター ロックを使用して通常の変数に対する読み取り/書き込み操作と同期し、それらの実行効果は同じです。
モニター ロックの事前発生ルールは、モニターを解放し、モニターを取得する 2 つのスレッド間のメモリの可視性を保証します。これは、揮発性変数の読み取りでは、常に (どのスレッドでも) 揮発性変数への最後の書き込みを確認できることを意味します。変数。モニター ロックのセマンティクスにより、クリティカル セクション コードの実行がアトミックであることが決定されます。これは、64 ビット長の double 変数であっても、それが揮発性変数である限り、変数への読み取りと書き込みはアトミックであることを意味します。複数の volatile 操作または volatile++ のような複合操作がある場合、これらの操作は全体としてアトミックではありません。
つまり、揮発性変数自体には次のプロパティがあります:
可視性。 volatile 変数からの読み取りでは、volatile 変数への (スレッドによる) 最後の書き込みが常に参照されます。
原子性: 単一の volatile 変数の読み取り/書き込みは原子的ですが、volatile++ のような複合操作は原子的ではありません。
volatile の書き込みと読み取りによって確立される関係の前に起こること
上記は、volatile 変数自体の特性に関するものですが、プログラマにとって、volatile 自体の特性よりも、スレッドのメモリ可視性に対する volatile の影響の方が重要であり、必要です。注意してみましょう。
JSR-133 以降、揮発性変数の書き込みと読み取りによってスレッド間の通信を実現できるようになりました。
メモリ セマンティクスの観点から見ると、揮発性ロックとモニタ ロックは同じ効果を持ちます。揮発性書き込みとモニタの解放は同じメモリ セマンティクスを持ち、揮発性読み取りとモニタの取得は同じメモリ セマンティクスを持ちます。
以下の volatile 変数を使用したサンプル コードをご覧ください:
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; } }
上記の発生前関係のグラフィック表現は次のとおりです:
スレッド A が volatile 変数を書き込んだ後、スレッド B は同じ volatile 変数を読み取ります。 volatile 変数を書き込む前にスレッド A に表示されていたすべての共有変数は、スレッド B が同じ volatile 変数を読み取った直後にスレッド B に表示されるようになります。
揮発性書き込みと読み取りのメモリ セマンティクス
揮発性書き込みのメモリ セマンティクスは次のとおりです:
揮発性変数を書き込むとき、JMM はスレッドに対応するローカル メモリ内の共有変数をメイン メモリにフラッシュします。
上記のサンプル プログラム VolatileExample を例に挙げると、最初にスレッド A が Writer() メソッドを実行し、次にスレッド B が Reader() メソッドを実行するとします。最初は両方のスレッドのローカル メモリにフラグと a が存在します。初期状態。以下の図は、スレッド A が volatile 書き込みを実行した後の共有変数の状態の模式図です。
揮発性読み取りのメモリ セマンティクスは次のとおりです:
揮発性変数を読み取るとき、JMM はスレッドに対応するローカル メモリを無効にします。次にスレッドはメイン メモリから共有変数を読み取ります。
以下は、スレッド B が同じ volatile 変数を読み取った後の共有変数のステータスの図です:
上の図に示すように、フラグ変数を読み込んだ後、ローカルメモリBは無効化されています。この時点で、スレッド B はメイン メモリから共有変数を読み取る必要があります。スレッド B の読み取り操作により、ローカル メモリ B とメイン メモリの共有変数の値が一致します。
volatile 書き込みと volatile 読み取りの 2 つのステップを組み合わせると、読み取りスレッド B が volatile 変数を読み取った後、書き込みスレッド A が volatile 変数を書き込む前に表示されているすべての共有変数の値がすぐに正しくなります。読み取りスレッド B が表示されます。
以下は、揮発性書き込みと揮発性読み取りのメモリ セマンティクスの概要です:
スレッド A は、揮発性変数を書き込みます。本質的に、スレッド A は、次に揮発性変数を読み取るスレッドにメッセージを送信します (その参照)。共有変数の場所が変更されました) メッセージを表示します。
スレッド B は揮発性変数を読み取ります。本質的に、スレッド B は前のスレッドによって送信されたメッセージを受信します (この揮発性変数を書き込む前に共有変数が変更されました)。
スレッド A が volatile 変数を書き込み、次にスレッド B が volatile 変数を読み取ります。このプロセスは基本的に、スレッド A がメイン メモリを介してスレッド B にメッセージを送信します。
揮発性メモリ セマンティクスの実装
次に、JMM が揮発性書き込み/読み取りメモリ セマンティクスを実装する方法を見てみましょう。
並べ替えはコンパイラの並べ替えとプロセッサの並べ替えに分けられると前に述べました。揮発性メモリのセマンティクスを実現するために、JMM はこれら 2 つのタイプの並べ替えタイプをそれぞれ制限します。以下は、コンパイラ用に JMM によって策定された揮発性並べ替え規則の表です:
並べ替え可能
2 番目の操作
最初の操作
通常の読み取り/書き込み
揮発性読み取り
揮発性書き込み
通常の読み取り/書き込み
いいえ
揮発性読み取り
いいえ
いいえ
NO
volatile write
NO
NO
たとえば、3 行目の最後のセルは次のことを意味します。プログラム シーケンスで、最初の操作が通常の変数の読み取りまたは書き込みである場合、2 番目の操作が volatile に書き込まれている場合、コンパイラは 2 つの操作の順序を変更できません。
上記の表から次のことがわかります:
2 番目の操作が揮発性書き込みである場合、最初の操作がどのようなものであっても、順序を変更することはできません。このルールにより、揮発性書き込み前の操作がコンパイラーによって揮発性書き込みの後に並べ替えられることがなくなります。
最初の操作が揮発性読み取りの場合、2 番目の操作がどのようなものであっても、並べ替えることはできません。このルールにより、揮発性読み取り後の操作がコンパイラーによって揮発性読み取りの前に並べ替えられることがなくなります。
最初の操作が揮発性書き込みで、2 回目の操作が揮発性読み取りの場合、並べ替えは実行できません。
揮発性メモリのセマンティクスを実現するために、コンパイラはバイトコードを生成するときに、命令シーケンスにメモリバリアを挿入して、特定のタイプのプロセッサの並べ替えを禁止します。コンパイラが、挿入されるバリアの総数を最小限に抑える最適な配置を見つけることはほとんど不可能であるため、JMM は保守的な戦略を採用します。以下は、保守的な戦略に基づく JMM メモリ バリア挿入戦略です:
各揮発性書き込み操作の前に StoreStore バリアを挿入します。
各揮発性書き込み操作の後に StoreLoad バリアを挿入します。
各揮発性読み取り操作の後に LoadLoad バリアを挿入します。
各揮発性読み取り操作の後に LoadStore バリアを挿入します。
上記のメモリ バリア挿入戦略は非常に保守的ですが、どのプロセッサ プラットフォーム上のどのプログラムでも正しい揮発性メモリ セマンティクスを確実に取得できます。
以下は、保守的な戦略に基づいて揮発性書き込みがメモリ バリアに挿入された後に生成される命令シーケンスの概略図です:
上図の StoreStore バリアは、揮発性書き込みの前に、すべての通常の書き込み操作を確実に実行できます。その前にあるプロセッサは可視のプロセッサによって処理されています。これは、StoreStore バリアにより、上記のすべての通常の書き込みが揮発性書き込みの前にメイン メモリに確実にフラッシュされるためです。
ここでさらに興味深いのは、揮発性書き込みの背後にある StoreLoad バリアです。このバリアの目的は、揮発性書き込みが後続の揮発性読み取り/書き込み操作によって並べ替えられるのを防ぐことです。コンパイラは、揮発性書き込みの後に StoreLoad バリアを挿入する必要があるかどうかを正確に判断できないことが多いためです (たとえば、メソッドは揮発性書き込みの直後に戻ります)。揮発性メモリのセマンティクスを正しく実装できることを保証するために、JMM はここで保守的な戦略を採用しています。つまり、各揮発性書き込みの後、または各揮発性読み取りの前に StoreLoad バリアを挿入します。全体的な実行効率の観点から、JMM は各揮発性書き込みの後に StoreLoad バリアを挿入することを選択しました。揮発性書き込み/読み取りメモリ セマンティクスの一般的な使用パターンは、1 つの書き込みスレッドが volatile 変数に書き込み、複数の読み取りスレッドが同じ volatile 変数を読み取るためです。読み取りスレッドの数が書き込みスレッドの数を大幅に超える場合、揮発性書き込みの後に StoreLoad バリアを挿入することを選択すると、実行効率が大幅に向上します。ここからは、まず正確性を確保し、次に実行効率を追求するという JMM 実装の特徴がわかります。
以下は、保守的な戦略の下で揮発性読み取りがメモリバリアを挿入した後に生成される命令シーケンスの概略図です。
上图中的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中文网(www.php.cn)!