「5年間働いた後、私は揮発性のキーワードさえ知りません!」
インタビューを終えたばかりの建築家の話を聞いた後、他の何人かの同僚もこの苦情に参加しました。
国内面接は「空母への面接と職場でのネジ止め」と言われていますが、問題で合格することもあります。
何時間働いた?volatileキーワードを知っていますか?
今日は、揮発性キーワードについて一緒に学び、インタビューで航空機運搬船を作ることができるスクリューワーカーになりましょう!
揮発性
Java言語仕様の第3版でのvolatileの定義は次のとおりです。
Javaプログラミング言語を使用すると、スレッドは共有変数にアクセスできます。共有変数を正確かつ一貫して更新できるようにするには、スレッドは、この変数が排他ロックを介して個別に取得されるようにする必要があります。
Java言語は揮発性を提供します。これは、場合によってはロックよりも便利です。
フィールドが揮発性であると宣言されている場合、Javaスレッドメモリモデルは、すべてのスレッドがこの変数の値に一貫性があることを確認します。
セマンティクス
共有変数(クラスメンバー変数、クラス静的メンバー変数)がvolatileによって変更されると、2層のセマンティクスがあります。
-
これにより、この変数で動作しているさまざまなスレッドの可視性が保証されます。つまり、1つのスレッドが変数の値を変更した場合、新しい値は他のスレッドにすぐに表示されます。
- 説明書の並べ替えは禁止されています。
- 注意
最終変数も揮発性として宣言されている場合、これはコンパイル時エラーです。
ps:1つは変化が見えることを意味し、もう1つは決して変化しないことを意味します。自然の火と水は両立しません。
問題の紹介
- Error.java
//线程1
boolean stop = false;
while(!stop){
doSomething();
}
//线程2
stop = true;
このコードは典型的なコードであり、多くの人がスレッドを中断するときにこのマーキング方法を使用する可能性があります。
問題分析
しかし実際には、このコードは正しく実行されますか?スレッドは中断されますか?
必ずしもそうとは限りませんが、ほとんどの場合、このコードはスレッドに割り込むことができますが、スレッドが割り込まれないようにすることもできます(この可能性は非常に小さいですが、これが発生すると、無限ループが発生します)。
以下に、このコードによってスレッドの中断が失敗する理由を説明します。
前に説明したように、各スレッドは操作中に独自の作業メモリを持っているため、スレッド1が実行されているとき、停止変数の値をコピーして独自の作業メモリに配置します。
次に、スレッド2が停止変数の値を変更したが、それをメインメモリに書き込む時間がなかった場合、スレッド2は他のことを行うためにシフトします。
その場合、スレッド1はスレッド2の停止変数への変更を認識しないため、ループを続行します。
揮発性物質を使用する
まず、volatileキーワードを使用すると、変更された値がすぐにメインメモリに書き込まれます。
2番目:volatileキーワードを使用すると、スレッド2が変更されると、スレッド1の作業メモリー内のキャッシュ変数stopのキャッシュラインが無効になります(ハードウェアレイヤーに反映され、CPUのL1またはL2キャッシュ内の対応するキャッシュラインです)無効);
3番目:スレッド1の作業メモリー内のバッファー変数stopのキャッシュ行が無効であるため、スレッド1はメインメモリーに移動して変数stopの値を再度読み取ります。
次に、スレッド2が停止値を変更すると(もちろん、これにはスレッド2の作業メモリーの値を変更してから、変更された値をメモリーに書き込むという2つの操作が含まれます)、
変数stopのキャッシュ行がスレッド1の作業メモリーにキャッシュされます。無効であるため、スレッド1が読み取る
と、キャッシュラインが無効であることが検出され、キャッシュラインに対応するメインメモリアドレスが更新されるのを待ってから、対応するメインメモリに移動して最新の値を読み取ります。
次に、スレッド1は最新の正しい値を読み取ります。
揮発性は原子性を保証しますか?
上記から、volatileキーワードは操作の可視性を保証することがわかりますが、volatileは変数の操作がアトミックであることを保証できますか?
問題の紹介
public class VolatileAtomicTest {
public volatile int inc = 0;
public void increase() {
inc++;
}
public static void main(String[] args) {
final VolatileAtomicTest test = new VolatileAtomicTest();
for (int i = 0; i < 10; i++) {
new Thread(() -> {
for (int j = 0; j < 1000; j++) {
test.increase();
}
}).start();
}
//保证前面的线程都执行完
while (Thread.activeCount() > 1) {
Thread.yield();
}
System.out.println(test.inc);
}
}
- 計算結果は?
10,000と思われるかもしれませんが、実際にはこの数値よりも小さくなっています。
理由
たぶん何人かの友人が質問をするでしょう。そうではありません。上記は変数incを自動インクリメントすることです。volatileは可視性を保証するので
、各スレッドでincを自動インクリメントした後、他のスレッドで見ることができます。変更された値、つまり10スレッドがそれぞれ1000回の操作を実行した場合、最終的なinc値は1000 * 10 = 10000になります。
ここには誤解があります。volatileキーワードは可視性が間違っていないことを保証できますが、上記のプログラムは原子性を保証しないという点で間違っています。
Visibilityは、毎回最新の値が読み取られることを保証することしかできませんが、volatileは、変数に対する操作のアトミック性を保証できません。
- 解決
LocksynchronizedまたはAtomicIntegerを使用する
揮発性は秩序を保証できますか?
volatileキーワードは、命令の並べ替えを禁止します。2つの意味があります。
-
プログラムが揮発性変数の読み取り操作または書き込み操作を実行するとき、前の操作のすべての変更が実行されている必要があり、その結果は後続の操作に表示されている必要があります。後続の操作は実行されていない必要があります。
- 命令を最適化する場合、揮発性変数にアクセスするステートメントを実行のためにその背後に配置することはできません。また、揮発性変数に続くステートメントを実行のためにそれらの前に配置することはできません。
インスタンス
- 例1
//x、y为非volatile变量
//flag为volatile变量
x = 2; //语句1
y = 0; //语句2
flag = true; //语句3
x = 4; //语句4
y = -1; //语句5
フラグ変数は揮発性変数であるため、命令の並べ替えの過程で、ステートメント3はステートメント1とステートメント2の前に配置されず、ステートメント3はステートメント4とステートメント5の後に配置されません。
ただし、ステートメント1とステートメント2の順序、およびステートメント4とステートメント5の順序は保証されないことに注意してください。
また、volatileキーワードは、ステートメント3が実行されるときに、ステートメント1とステートメント2を実行する必要があり、ステートメント1とステートメント2の実行結果がステートメント3、ステートメント4、およびステートメント5に表示されることを保証できます。
- 例2
//线程1:
context = loadContext(); //语句1
inited = true; //语句2
//线程2:
while(!inited ){
sleep()
}
doSomethingwithconfig(context);
前の例では、ステートメント2がステートメント1の前に実行されるため、コンテキストが長期間初期化されておらず、初期化されていないコンテキストがスレッド2で使用されて動作し、プログラムエラーが発生することが説明されています。
初期化された変数がvolatileキーワードで変更された場合、この種の問題は発生しません。ステートメント2が実行されるときに、コンテキストが初期化されていることを保証する必要があるためです。
一般的な使用シナリオ
volatileキーワードは、場合によっては同期よりもパフォーマンスが優れています。
ただし、volatileキーワードは操作のアトミック性を保証できないため、volatileキーワードはsynchronizedキーワードを置き換えることができないことに注意してください。
一般的に、揮発性物質の使用は、次の2つの条件を満たす必要があります。
-
変数への書き込み操作は現在の値に依存しません
- 変数は他の変数との不変量に含まれていません
実際、これらの条件は、揮発性変数に書き込むことができる実効値が、変数の現在の状態を含む、プログラムの状態とは無関係であることを示しています。
実際、私の理解では、volatileキーワードを使用するプログラムを同時に正しく実行できるようにするには、上記の2つの条件で操作がアトミックであることを確認する必要があります。
一般的なシナリオ
- ステータスフラグ
volatile boolean flag = false;
while(!flag){
doSomething();
}
public void setFlag() {
flag = true;
}
- シングルトンダブルチェック
public class Singleton{
private volatile static Singleton instance = null;
private Singleton() {
}
public static Singleton getInstance() {
if(instance==null) {
synchronized (Singleton.class) {
if(instance==null)
instance = new Singleton();
}
}
return instance;
}
}
JSR-133の機能強化
JSR-133より前の古いJavaメモリモデルでは、揮発性変数間の並べ替えは許可されていませんでしたが、古いJavaメモリモデルでは、揮発性変数と通常の変数間の並べ替えが許可されていました。
古いメモリモデルでは、VolatileExampleサンプルプログラムは、次の順序で実行するように並べ替えることができます。
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
}
}
}
- タイムライン
时间线:----------------------------------------------------------------->
线程 A:(2)写 volatile 变量; (1)修改共享变量
线程 B: (3)读取 volatile 变量; (4)读共享变量
古いメモリモデルでは、1と2の間にデータの依存関係がない場合、1と2の間の並べ替えが可能である可能性があります(3と4は類似しています)。
結果は次のとおりです。リーダースレッドBが4を実行すると、ライタースレッドAが1を実行したときに、共有変数の変更が必ずしも表示されない場合があります。
したがって、古いメモリモデルでは、volatile write-readには、monitorrelease-acquisitionのメモリセマンティクスがありません。
モニターロックよりもスレッド間の通信に軽量なメカニズムを提供するために、
JSR-133専門家グループは、volatileのメモリセマンティクスを強化することを決定しました。
コンパイラとプロセッサによる揮発性変数と通常の変数の並べ替えを厳密に制限し、揮発性の書き込み/読み取りとモニタのリリース取得が同じメモリセマンティクスを持つようにします。
コンパイラの並べ替えルールとプロセッサのメモリバリア挿入戦略の観点から、揮発性変数と通常の変数の間の並べ替えがvolatileのメモリセマンティクスを破壊する可能性がある限り、
この並べ替えはコンパイラとプロセッサのメモリバリアによって並べ替えられます。挿入ポリシーは禁止されています。
揮発性の実装原則
用語の定義
用語 | 英語の語彙 | 説明 |
---|---|---|
共有変数 | 共有変数 | 複数のスレッド間で共有できる変数は、共有変数と呼ばれます。共有変数には、すべてのインスタンス変数、静的変数、および配列要素が含まれます。それらはすべてヒープメモリに保存され、volatileは共有変数にのみ作用します |
メモリバリア | メモリバリア | メモリ操作の順序を制限するために使用されるプロセッサ命令のセットです |
バッファライン | キャッシュライン | キャッシュに割り当てることができる最小のストレージユニット。プロセッサがキャッシュラインを埋めると、キャッシュライン全体がロードされます。これには、複数のメインメモリの読み取りサイクルが必要です。 |
原子操作 | 不可分操作 | 中断のない操作または一連の操作 |
キャッシュラインフィル | キャッシュラインフィル | プロセッサがメモリから読み取ったオペランドがキャッシュ可能であることを認識すると、プロセッサはキャッシュライン全体を適切なキャッシュ(L1、L2、L3、またはすべて)に読み取ります。 |
キャッシュヒット | キャッシュヒット | キャッシュライン充填操作のメモリ位置が、次回プロセッサがアクセスするアドレスのままである場合、プロセッサはメモリからではなくキャッシュからオペランドを読み取ります。 |
ヒットを書き込む | ヒットを書く | プロセッサがオペランドをメモリキャッシュ領域に書き戻すとき、最初にキャッシュのメモリアドレスがキャッシュラインにあるかどうかをチェックします。有効なキャッシュラインがある場合、プロセッサはオペランドをキャッシュに書き戻します。この操作は、メモリに書き戻す代わりに、書き込みヒットと呼ばれます。 |
行方不明 | 書き込みがキャッシュを逃す | 有効なキャッシュラインが存在しないメモリ領域に書き込まれます |
原理
では、揮発性はどのように可視性を保証するのでしょうか?
x86プロセッサでは、ツールを使用してJITコンパイラによって生成されたアセンブリ命令を取得し、volatileを書き込むときにCPUが何を実行するかを確認します。
- java
instance = new Singleton();//instance是volatile变量
対応するアセンブリ
0x01a3de1d: movb $0x0,0x1104800(%esi);
0x01a3de24: lock addl $0x0,(%esp);
揮発性変数で変更された共有変数を作成する場合、アセンブリコードの2行目があります。IA-32アーキテクチャソフトウェア開発者マニュアルを
確認すると、接頭辞付きの命令がマルチコアプロセッサで2つの原因になることがわかります。lock
-
現在のプロセッサキャッシュラインのデータは、システムメモリに書き戻されます。
- このメモリへの書き戻し操作は、他のCPUのそのメモリアドレスにキャッシュされているデータを無効にします。
処理速度を向上させるために、プロセッサはメモリと直接通信せず、操作を実行する前に最初にシステムメモリ内のデータを内部キャッシュ(L1、L2など)に読み取りますが、操作後、いつメモリに書き込まれるかはわかりません。 、
揮発性変数が書き込み操作に対して宣言されている場合、JVMはLockプレフィックス命令をプロセッサに送信して、変数が配置されているキャッシュラインのデータをシステムメモリに書き込みます。
ただし、メモリに書き戻されても、他のプロセッサのキャッシュされた値がまだ古い場合は、計算操作を実行するときに問題が発生します。
したがって、マルチプロセッサでは、各プロセッサのキャッシュの一貫性を確保するために、キャッシュコヒーレンシプロトコルが実装されます。各プロセッサは、バスで送信されたデータをスニッフィングして自身のキャッシュの値をチェックし、キャッシュの値が期限切れになっていないかどうかをチェックします。
プロセッサは、キャッシュラインに対応するメモリアドレスが変更されていることを検出すると、現在のプロセッサのキャッシュラインを無効な状態に設定します。プロセッサがデータを変更する場合、システムメモリからデータを強制的に再ロードします。プロセッサキャッシュに読み込みます。
これら2つのことは、IA-32ソフトウェア開発者アーキテクチャマニュアルの第3巻のマルチプロセッサ管理の章(第8章)で詳細に説明されています。
Lock prefix命令により、プロセッサキャッシュがメモリに書き戻されます
Lock prefix命令は、命令の実行中に音声プロセッサのLOCK#信号を発生させます。
マルチプロセッサ環境では、LOCK#信号により、信号のアサート中にプロセッサが共有メモリを排他的に使用できるようになります。(バスをロックするため、他のCPUはバスにアクセスできません。バスにアクセスできないと、システムメモリにアクセスできなくなります。)ただし、最近のプロセッサでは、LOCK#信号は通常、バスをロックせず、キャッシュをロックします。バスのオーバーヘッドは比較的大きいです。
8.1.4章に、プロセッサキャッシュに対するロック操作の影響の詳細な説明があります。Intel486およびPentiumプロセッサの場合、LOCK#信号は、ロック操作中に常にバス上で宣言されます。
ただし、P6および最近のプロセッサでは、アクセスされたメモリ領域がプロセッサ内にキャッシュされている場合、LOCK#信号はアサートされません。
逆に、このメモリ領域のキャッシュをロックしてメモリに書き戻し、キャッシュコヒーレンシメカニズムを使用して変更のアトミック性を確保します。この操作は「キャッシュロック」と呼ばれます。
キャッシュコヒーレンシメカニズムは、同時変更が変更されるのを防ぎます。 2つ以上のプロセッサによってキャッシュされたメモリ領域データ。
1つのプロセッサのキャッシュをメモリに書き戻すと、他のプロセッサのキャッシュが無効になります
IA-32プロセッサとIntel64プロセッサは、MESI(変更、排他、共有、無効化)制御プロトコルを使用して、内部キャッシュと他のプロセッサキャッシュの一貫性を維持します。
マルチコアプロセッサシステムで動作している場合、IA-32およびIntel 64プロセッサは、他のプロセッサをスニッフィングして、システムメモリとその内部キャッシュにアクセスできます。
彼らはスニッフィング技術を使用して、内部キャッシュ、システムメモリ、およびその他のプロセッサキャッシュ内のデータがバス上で一貫性を保つようにします。
たとえば、PentiumおよびP6ファミリ
のプロセッサでは、あるプロセッサがスニッフィングされて、別のプロセッサがメモリアドレスを書き込もうとしていることを検出し、このアドレスが現在共有状態を処理している場合、スニッフィングしているプロセッサはキャッシュラインを無効にします。同じメモリアドレスに同時にアクセスすると、キャッシュラインの充填が強制されます。
揮発性物質の使用の最適化
有名なJavaコンカレントプログラミングマスターのDougLeaは、JDK7のコンカレントパッケージにキューコレクションクラスを追加しました。揮発性変数を使用する場合LinkedTransferQueue
、
キューのデキューとエンキューのパフォーマンスを最適化するためにバイトを追加する方法を使用しました。
追加のバイトでパフォーマンスを最適化できますか?この方法はすばらしいように見えますが、プロセッサアーキテクチャを深く理解していれば、謎を理解することができます。
LinkedTransferQueue
このクラスを見てみましょう。
内部クラスタイプを使用してキューキューのヘッド(Head)とテールノード(tail)を定義
し、親クラスAtomicReferenceに関連するこの内部クラスPaddedAtomicReferenceは1つのことだけを実行します。共有変数は64バイトに追加されます。
オブジェクトの参照が4バイトを占めると計算でき、15個の変数を追加して合計60バイトを占め、さらに親クラスのValue変数を追加して合計64バイトを占めます。
- LinkedTransferQueue.java
/** head of the queue */
private transient final PaddedAtomicReference < QNode > head;
/** tail of the queue */
private transient final PaddedAtomicReference < QNode > tail;
static final class PaddedAtomicReference < T > extends AtomicReference < T > {
// enough padding for 64bytes with 4byte refs
Object p0, p1, p2, p3, p4, p5, p6, p7, p8, p9, pa, pb, pc, pd, pe;
PaddedAtomicReference(T r) {
super(r);
}
}
public class AtomicReference < V > implements java.io.Serializable {
private volatile V value;
//省略其他代码
}
64バイトを追加すると、同時プログラミングの効率が向上するのはなぜですか?
Intel Core i7、Core、AtomおよびNetBurst、CoreSoloおよびPentiumMプロセッサの場合、L1、L2、またはL3キャッシュのキャッシュラインは64バイト幅であり、部分的に満たされたキャッシュラインをサポートしていません。ヘッドノードとテールノードが64バイト未満の場合、プロセッサはそれらをすべて同じキャッシュラインに読み込みます。マルチプロセッサでは、各プロセッサは同じヘッドノードとテールノードをキャッシュします。プロセッサが変更を試みると、ヘッドコンタクトはキャッシュライン全体をロックするため、キャッシュコヒーレンシメカニズムの影響下で、他のプロセッサは自身のキャッシュ内のテールノードにアクセスできなくなり、キューエントリおよびデキュー操作は常にヘッドを変更する必要がありますジョイントとテールノード。したがって、マルチプロセッサの場合、キューの出入りの効率に深刻な影響を及ぼします。
Doug leaは、64バイトを追加して高速バッファーのキャッシュラインを埋め、ヘッドノードとテールノードが同じキャッシュラインにロードされないようにして、ヘッドノードとテールノードが変更されたときに互いにロックしないようにします。
- では、揮発性変数を使用する場合、64バイトに追加する必要がありますか?
番号。
この方法は、両方のシナリオで使用しないでください。
1つ目:P6シリーズやPentiumプロセッサなど、キャッシュラインの幅が64バイトではないプロセッサの場合、L1およびL2キャッシュラインの幅は32バイトです。
2番目:共有変数は頻繁に書き込まれません。
追加のバイトを使用する方法では、プロセッサが高速バッファにさらにバイトを読み取る必要があり、それ自体が一定のパフォーマンス消費をもたらすため、共有変数が頻繁に書き込まれない場合、ロックの可能性も非常に低くなります。相互ロックを回避するためにバイトを追加する必要はありません。
ps:いきなりアートの分野に特化したいと思い、知識と知恵が欠かせません。
ダブル/ロングスレッドは安全ではありません
Java仮想マシン仕様で定義されている多くのルールの1つ:long型とdouble型での特定の操作を除いて、基本型でのすべての操作はアトミックです。
現在のJVM(java仮想マシン)は、64ビットではなく32ビットをアトミック操作として使用します。
スレッドがメインメモリのlong / doubleタイプの値をスレッドメモリに読み込む場合、32ビット値の2回の書き込み操作である可能性があります。明らかに、複数のスレッドが同時に動作する場合、2つの上位32ビットと下位32ビットが存在する可能性があります。値エラーが発生します。
スレッド間でlongフィールドとdoubleフィールドを共有するには、それらを同期して操作するか、揮発性として宣言する必要があります。
概要
JMMの非常に重要なキーワードとしての揮発性は、基本的に、高い同時性のインタビューのために求められなければならない知識ポイントです。
この記事があなたの仕事の勉強のインタビューに役立つことを願っています。他にアイデアがあれば、コメントセクションで共有することもできます。
オタク」likesが、お気に入りは、転送馬の執筆のための最大の動機です!
よりエキサイティングなコンテンツについては、[Lao MaXiaoxifeng]をフォローしてください。