1.エラーケース
volatileキーワードは、次のコード例のようなケースを通じて導入されます。現時点では、volatileキーワードを使用しない2つのスレッド間の通信に問題があります。
public class ThreadsShare {
private static boolean runFlag = false; // 此处没有加 volatile
public static void main(String[] args) throws InterruptedException {
new Thread(() -> {
System.out.println("线程一等待执行");
while (!runFlag) {
}
System.out.println("线程一开始执行");
}).start();
Thread.sleep(1000);
new Thread(() -> {
System.out.println("线程二开始执行");
runFlag = true;
System.out.println("线程二执行完毕");
}).start();
}
}
出力結果:
結論:スレッド1は、スレッド2がrunFlagをtrueに変更したというシグナルを感じなかったため、「スレッド実行の開始」という文は出力されず、プログラムは終了していません。
次のシナリオのように:
現在のシナリオでは、プロセッサAとプロセッサBがそれぞれの書き込みバッファのデータをメモリにフラッシュして戻さず、メモリから読み取った値A = 0とB = 0をXとYに割り当てていないように見える場合があります、この時点で、バッファ内のデータがメモリにフラッシュされるため、最終結果が実際の目的の結果と一致しなくなります。バッファ内のデータのみがメモリにフラッシュされるため、実際の実行です
この問題の原因:
コンピュータがプログラムを実行すると、すべての命令がプロセッサで実行されます。命令を実行するプロセスには、データの読み取りと書き込みが含まれます。プログラム実行中の一時データはメインメモリ(物理メモリ)に格納されます。このとき問題があります。プロセッサの実行速度が速いため、メモリからのデータの読み込みとメモリへの書き込みの処理と処理速度プロセッサが命令を実行する速度はそれよりもはるかに遅いため、データに対する操作がメモリとの相互作用によって行われると、命令の実行速度が大幅に低下します。この問題を解決するために、CPUキャッシュが設計されています。各スレッドがステートメントを実行するとき、最初にメインメモリから値を読み取り、次にそれをローカルメモリにコピーし、次にデータ操作を実行して最新の値を更新します。メインメモリ。これにより、キャッシュに一貫性がなくなるという現象が発生します
上記の現象に対応して、キャッシュコヒーレンシプロトコルが提案されます:MESI
中心的な考え方は次のとおりです。MESIプロトコルは、各キャッシュで使用される共有変数のコピーに一貫性があることを保証します。プロセッサがデータを書き込むときに、動作変数が共有変数であることが判明した場合、つまり、変数のコピーが他のプロセッサに存在する場合、共有変数(バス)のキャッシュラインを無効にする信号を他のプロセッサに送信します。スニッフィング)メカニズムの調査)。したがって、他のプロセッサがこの変数を読み取る必要があり、その変数をキャッシュにキャッシュするキャッシュラインが無効であることがわかった場合、メモリから再読み取りします。
スニッフィングキャッシュコヒーレンシプロトコル:
すべてのメモリ転送は共有メモリバス上で行われ、すべてのプロセッサがこのバスを認識できます。キャッシュ自体は独立していますが、メモリは共有されています。すべてのメモリアクセスを調停する必要があります。つまり、同じ命令サイクルでデータを読み書きできるのは1つのプロセッサだけです。プロセッサは、メモリ転送中にメモリバスと対話するだけでなく、スニッフィングバス上のデータを絶えず交換して、他のキャッシュが実行していることを追跡します。そのため、プロセッサがメモリを読み書きすると、他のプロセッサに通知されます(プロアクティブ通知)。これは、キャッシュの保存を同期するためです。1つのプロセッサがメモリを書き込む限り、他のプロセッサはこのメモリがキャッシュセグメントで無効であることを認識します。
詳細なMESI:
MESIプロトコルでは、各キャッシュラインには次の4つの状態があります。
- 変更済みとは、このデータ行が有効であり、データが変更されており、メモリ内のデータに一貫性がなく、データが現在のキャッシュにのみ存在することを意味します。
- ExclusiveからExclusiveまで、このデータ行は有効であり、データはメモリ内のデータと一致しており、データはこのキャッシュにのみ存在します
- 共有共有、このデータ行は有効であり、データはメモリ内のデータと一致しており、データは多くのキャッシュに保存されます。
- 無効このデータ行は無効です
ここで、無効、共有、および変更はスニッフィングキャッシュコヒーレンシプロトコルに準拠していますが、排他的とは排他的を意味し、現在のデータは有効でメモリ内のデータと一致していますが、現在のキャッシュ内の排他的状態のみがプロセッサが読み取りメモリに書き込む前に、この問題を他のプロセッサに通知する必要があります。プロセッサは、キャッシュラインが排他的で変更されている場合にのみ書き込むことができます。つまり、これら2つの状態でのみ、プロセッサはキャッシュラインを独占します。
プロセッサがキャッシュラインを書き込みたい場合、制御権がない場合は、最初に制御権の要求をバスに送信する必要があります。このとき、他のプロセッサは同じキャッシュセグメントのコピーを無効にするように通知されます。プロセッサは、データが制御されているときにデータを変更できます。現時点では、プロセッサにはキャッシュラインのコピーが1つしかなく、キャッシュ内にのみ存在します。競合は発生しません。それ以外の場合、他のプロセッサが常にキャッシュラインを読み取りたい場合、排他的または変更されたキャッシュラインを最初に共有状態に戻す必要があります。変更されたキャッシュラインの場合は、コンテンツを最初にメモリに書き戻す必要があります。
したがって、Javaは揮発性の軽量同期メカニズムを提供します
2.機能
Volatileは、Javaによって提供される軽量の同期メカニズムです。Volatileは、スレッドコンテキストの切り替えやスケジューリングを行わないため、軽量です。ただし、揮発性変数の同期は不十分であり、コードブロックの同期を保証できず、その使用はエラーが発生しやすくなります。volatileキーワードは、可視性を確保するため、つまり、共有変数のメモリ可視性を確保してキャッシュコヒーレンシの問題を解決するために使用されます。共有変数がvolatileキーワードによって変更されると、メモリの可視性と命令の並べ替えの禁止という2つのセマンティクスがあります。マルチスレッド環境では、volatileキーワードは主に、共有変数の変更をタイムリーに認識し、他のスレッドが変数の最新の値をすぐに取得できるようにするために使用されます。
volatileキーワードを使用した後のプログラムの効果:
使い方:
private volatile static boolean runFlag = false;
コード:
public class ThreadsShare {
private volatile static boolean runFlag = false;
public static void main(String[] args) throws InterruptedException {
new Thread(() -> {
System.out.println("线程一等待执行");
while (!runFlag) {
}
System.out.println("线程一开始执行");
}).start();
Thread.sleep(1000);
new Thread(() -> {
System.out.println("线程二开始执行");
runFlag = true;
System.out.println("线程二执行完毕");
}).start();
}
}
出力結果:
結論:スレッド1は、スレッド2がrunFlagをtrueに変更したというシグナルを検知したため、「スレッド実行開始」という文が出力され、プログラムが終了します。
揮発性の2つの影響:
- スレッドが揮発性変数を書き込むと、JMMはスレッドに対応するローカルメモリ内の変数の値をメインメモリに強制的に更新します。
- この書き込み操作により、他のスレッドのこの共有変数のキャッシュが無効になります。この変数を使用する場合は、メインメモリの値を再取得する必要があります。
思考:2つのプロセッサが同じ共有変数を同時に読み取ったり変更したりするとどうなりますか?
メモリにアクセスするには、最初に複数のプロセッサがメモリバスロックを取得する必要があります。メモリバスの制御を取得できるのは常に1つのプロセッサのみであるため、上記の状況は発生しません。
重要:volatileキーワードは、可視性を確保するため、つまり、共有変数のメモリ可視性を確保してキャッシュコヒーレンシの問題を解決するために使用されます。
3.機能
3.1可視性
とき共有変数は揮発性である修正、それが変更された値がすぐにメインメモリに更新されるようになります。他のスレッドがそれを読むために必要がある場合、それは新しい値を読み出すためにメモリに移動します。通常の共有変数が変更された後、それらがいつメインメモリに書き込まれるかが不確実であるため、通常の共有変数の可視性は保証できません。他のスレッドが読み取った場合、メモリはこの時点で元の古い値を保持している可能性があります。可視性は保証できません(上記のケースはすでに可視性の役割を示しています)
3.2注文の再配置の禁止
Javaメモリモデルでは、コンパイラとプロセッサは命令を並べ替えることができますが、並べ替えプロセスはシングルスレッドプログラムの実行には影響しませんが、複数のスレッドの同時実行の正確さに影響します。
volatileキーワードは、命令の並べ替えを禁止します。2つの意味があります。
- プログラムが揮発性変数の読み取り操作または書き込み操作を実行するとき、前の操作のすべての変更が実行され、結果が後続の操作に表示されている必要があります。後続の操作は実行されていない必要があります。
- 命令を最適化する場合、揮発性変数にアクセスするステートメントを実行のためにその背後に配置することはできません。また、揮発性変数に続くステートメントを実行のために配置することはできません。
プロセッサの並べ替えによって引き起こされるメモリエラーを解決するために、Javaコンパイラは、生成された命令シーケンスの適切な位置にメモリバリア命令を挿入して、特定の種類のプロセッサの並べ替えを禁止します。
メモリバリア命令:メモリバリアは、揮発性セマンティクスの実現です。これについては、以下で説明します。
バリアタイプ | コマンド例 | 説明 |
---|---|---|
LoadLoadBarriers | Load1; LoadLoad; Load2 | Load1データのロードは、Load2以降のすべてのデータロードの前に発生します |
StoreStoreBarriers | Store1; StoreStore; Store2 | Store1のデータがメインストレージにフラッシュバックされるのは、Store2とそれに続くすべてのデータがメインストレージにフラッシュバックされる前に発生します。 |
LoadStoreBarriers | Load1; LoadStore; Store2 | Load1データのロードはStore2の前に行われ、後続のすべてのデータはメインメモリにフラッシュバックされます |
StoreLoadBarriers | Store1; StoreLoad; Load2 | Store1データのメモリへのフラッシュバックは、Load2およびその後のすべてのデータロードの前に発生します |
4.揮発性与が起こる-前に
public class Example {
int r = 0;
double π = 3.14;
volatile boolean flag = false; // volatile 修饰
/**
* 数据初始化
*/
void dataInit() {
r = 1; // 1
flag = true; // 2
}
/**
* 数据计算
*/
void compute() {
if(flag){
// 3
System.out.println(π * r * r); //4
}
}
}
スレッドAがdataInit()を実行する場合、スレッドBはhappens-before(以前のJavaメモリモデルについて説明します)によって提供されるルールに従ってcompute()を実行します。Javaメモリモデルには、ステップ2が揮発性ルールに沿っている必要があるという話があります。ステップ3の前に、ステップ1はステップ2の前にあり、ステップ3はステップ4の前にあるため、遷移規則に従って、ステップ1もステップ4の前にあります。
5.メモリセマンティクス
5.1読み取りメモリのセマンティクス
揮発性変数を読み取ると、ローカルの作業メモリーが無効になり、揮発性の変更された変数の現在の値がメモリーから取得されます。
5.2書き込みメモリセマンティクス
揮発性変数を書き込むとき、ローカル作業メモリーの値は強制的にメモリーに戻されます。
5.3メモリセマンティクスの実装
コンパイラによって作成されたJMM揮発性並べ替えルールテーブル
再注文できます | 2番目の操作 | ||
---|---|---|---|
最初の操作 | 通常の読み取りまたは書き込み | 揮発性読み取り | 揮発性書き込み |
普通または書く | 番号 | ||
揮発性読み取り | 番号 | 番号 | 番号 |
揮発性書き込み | 番号 | 番号 |
たとえば、3行目の最後のセルの意味は次のとおりです。
ローカル操作が通常の操作であり、2番目の操作が揮発性書き込みである場合、コンパイラーはこれら2つの操作を並べ替えることができません。
5.4まとめ
- 2番目の操作が揮発性の書き込みである場合、最初の操作は何があっても並べ替えることができません。このルールは、揮発性書き込みの前の操作をコンパイラーが揮発性書き込みの背後に再配置できないことを保証します。
- 最初の操作が揮発性読み取りである場合、2番目の操作が何であっても、並べ替えることはできません。このルールにより、volatile読み取り後の操作が、コンパイラーによってvolatile前にコンパイルされないことが保証されます。
- 最初の操作が揮発性書き込みで、2番目の操作が揮発性読み取りの場合、並べ替えることはできません
volatileのメモリセマンティクスを実現するために、コンパイラは命令シーケンスにメモリバリアを挿入して、バイトコードを生成するときに特定のタイプのプロセッサの並べ替えを禁止します。
JMMメモリバリア挿入戦略:
- すべての揮発性書き込み操作の前にStoreStoreバリアを挿入します。
- 各揮発性書き込み操作の後にStoreLoadバリアを挿入します。
- 各揮発性読み取り操作の後にLoadLoadバリアを挿入します。
- 各揮発性読み取り操作の後にLoadStoreバリアを挿入します。
揮発性書き込みがメモリバリアに挿入された後に生成される命令シーケンスの概略図:
StoreStoreバリアは、以前のすべての通常の書き込み操作が揮発性書き込みの前にすべてのプロセッサに表示されるようにすることができます。これは、StoreStoreバリアが、上記のすべての通常の書き込みが揮発性書き込みの前にメインメモリにフラッシュされるようにするためです。
StoreLoadバリアは、揮発性書き込みの並べ替えと、それに続く揮発性読み取りまたは書き込み操作の可能性を保証できます。
揮発性読み取りがメモリバリアに挿入された後に生成される命令シーケンスの概略図:
LoadLoadバリアは、プロセッサが上記の揮発性読み取りと以下の通常の読み取りを並べ替えることを禁止するために使用されます。
LoadStoreバリアは、プロセッサが上記の揮発性読み取りと以下の通常の読み取りおよび書き込みを並べ替えることを禁止するために使用されます。
6.実際の戦闘
6.1揮発性物質の使用は、条件を満たす必要があります
- 変数への書き込み操作は現在の値に依存しません
- 変数は他の変数との不変量に含まれていません
実際、これらの条件は、揮発性変数に書き込むことができる有効な値が、変数の現在の状態を含む、プログラムの状態とは無関係であることを示しています。実際、上記の2つの条件は、volatile変数の操作がアトミックであることを確認し、volatileキーワードを使用するプログラムを同時に正しく実行できるようにすることです。
6.2揮発性が主に使用されるシナリオ
マルチスレッド環境で共有変数の変更を時間内に認識し、他のスレッドが変数の最新の値をすぐに取得できるようにします
シナリオ1:ステータスマークの金額(本文の例)
public class ThreadsShare {
private volatile static boolean runFlag = false; // 状态标记
public static void main(String[] args) throws InterruptedException {
new Thread(() -> {
System.out.println("线程一等待执行");
while (!runFlag) {
}
System.out.println("线程一开始执行");
}).start();
Thread.sleep(1000);
new Thread(() -> {
System.out.println("线程二开始执行");
runFlag = true;
System.out.println("线程二执行完毕");
}).start();
}
}
シーン2ダブルチェック
シングルトンモードのDCLバージョンはダブルチェックロックの略語であり、中国語の名前は両端検索メカニズムです。いわゆるダブルエンド検索は、ロックの前後に判断を下すことです。
public class Singleton1 {
private static Singleton1 singleton1 = null;
private Singleton1 (){
System.out.println("构造方法被执行.....");
}
public static Singleton1 getInstance(){
if (singleton1 == null){
// 第一次check
synchronized (Singleton1.class){
if (singleton1 == null) // 第二次check
singleton1 = new Singleton1();
}
}
return singleton1 ;
}
}
同期を使用して、メソッド全体ではなく、インスタンスを作成するコードの一部のみをロックします。ロックの前後の両方が判断される、これは両端検索メカニズムと呼ばれます。これは実際には1つのオブジェクトのみを作成します。ただし、これは絶対に安全というわけではありません。新しいオブジェクトも3つのステップに分かれています。
- 1.オブジェクトのメモリスペースを割り当てます
- 2.オブジェクトを初期化します
- 3.オブジェクトを割り当てられたメモリアドレスにポイントします。この時点で、オブジェクトはnullではありません。
手順2と3にはデータの依存関係がないため、コンパイラーでは、最適化中にこれら2つの文の順序を逆にすることができます。命令を並べ替えると、マルチスレッドアクセスで問題が発生します。したがって、シングルトンパターンの次の最終バージョンがあります。この場合、順序の再配置は発生しません
public class Singleton2 {
private static volatile Singleton2 singleton2 = null;
private Singleton2() {
System.out.println("构造方法被执行......");
}
public static Singleton2 getInstance() {
if (singleton2 == null) {
// 第一次check
synchronized (Singleton2.class) {
if (singleton2 == null) // 第二次check
singleton2 = new Singleton2();
}
}
return singleton2;
}
}