はじめに
マルチスレッディングを学習しているかどうかは分かりませんが、知識は非常に断片化されている感じがあります。例えば、原子性、可視性、順序付け、volatile、Synchronized、RecentLock、CASなど、概念的なものがたくさんあり、これらの知識はネット上にたくさんありますが、これらの知識を組み合わせることができていないといつも感じます。volatileは実際にどのような問題を解決するのか?volatileはどのような問題を解決し、Happens-beforeはJVMの動作を制限するのでしょうか?常に何らかの接続があります。
この記事では、初心者の方がマルチスレッドの概念をよりよく学び、理解できるように、これらの重要な知識ポイントの意味を理解し、それらの本質的なつながりを見つけるために結びつけてみました。しかし、この記事では、これらの知識ポイントの使用方法については取り上げません。具体的な使用方法については、インターネット上に多くの情報がありますので、ご自身で調べてみてください。
マルチスレッドの3つの主な困難から始めます。
なぜマルチスレッドプログラムが書きにくいかというと、マルチスレッド環境では、プログラム実行の3大特性である「原子性」「可視性」「順序性」が当然ながら破壊される可能性があるからです。
クラス内の変数に対して累積演算を行うプログラムを見てみましょう。
class AddTest{
private Integer count = 0;
public void add(){
count++;
}
public static void main(String[] args) throws InterruptedException {
AddTest addTest = new AddTest();
ExecutorService executorService = Executors.newFixedThreadPool(10);
/**スレッド・プールに10個のタスクを投入し、各タスクがカウントに1000を追加する。**/
for(int i=0; i<10; i++){
executorService.execute(()->{
for(int j=0; j<1000; j++)
addTest.add();
});
}
/** スレッドプールに1タスクを投入し、10000回カウントを追加する。
executorService.execute(()->{
for(int j=0; j<10000; j++)
addTest.add();
});
**/
//スレッドプールのスレッドが実行を終えるのを待つ
executorService.shutdown();
while (!executorService.awaitTermination(1,TimeUnit.SECONDS)){
Thread.yield();
}
System.out.println("最終結果: "+addTest.count);
}
}
10個のタスクをスレッドプールに投入して累積させると、最終的な結果は常に10000ではなく、10000より小さい数字になることがわかりますが、1個のタスクをスレッドプールに投入して累積処理を実行すると、結果は常に期待通りのものになります。ということは、やはり蓄積処理の同時実行に問題があるのでしょう。
原子性
原子性というのは、おそらくデータベース・トランザクションの原子性に関連しているのでしょう。
Javaでは、アトミティティとは、一連の処理がCPUによって中断されないことを意味するのではなく、一連の処理がCPUによって中断されたとしても、この一連の未完成の処理の中間状態が外部から見えないことが保証されなければならず、この原則に違反すると予期せぬ結果を招く可能性があります。
しかし、JVMがアトミックであることを保証できるのは、いくつかの基本操作だけです。たとえば、最も単純なi++操作は、JVMにとって3つの操作命令を含んでいます:
- iの値をメイン・メモリーから自分のスレッドのローカル・メモリーに読み込みます。
- 自分のローカルメモリでアキュムレート操作を実行します。
- 結果が最終的にフラッシュされるメインメモリ。
JVMは、これらの3つの個々の操作の1、2、3がアトミックであることを保証することができます、JVMの組み合わせは、メインメモリから同時に2つのスレッドを追加i = 0の値を読み取るために、保証することはできませんし、1000回蓄積し、メインメモリに結果をリフレッシュするには、両方の1000は明らかに正しくないメインメモリにリフレッシュされます。
では、どうやって解決するのでしょうか?それは開発者がコードレベルでアトミック性を確保することです。
可視性
可視性はJavaのマルチスレッドによって直接引き起こされるのではなく、実際にはコンピュータCPUのマルチレベル・キャッシュ・アーキテクチャによって引き起こされます。現代のCPUは、実行の効率を向上させるために、データをキャッシュしていることを知って、一般的に言えば、CPUはキャッシュの3つのレベルを持って、各CPUは、独自の独立したレベル1とレベル2キャッシュを持っており、その後、共通のレベル3キャッシュは、メインメモリにデータを読み書きするとき、それは実際にメインメモリから自分のキャッシュにデータを読み取る、またはメインメモリにキャッシュ内のデータをフラッシュしています。これは、キャッシュの存在は、その結果、CPUが最新のデータの実行では、メインメモリでは見られないので、当然、これは問題が発生します。
では、どうすれば可視性の問題を解決できるのでしょうか?実際、CPUベンダーもこうした問題を考えており、MESIプロトコルなど、CPUのキャッシュ動作を管理するためのプロトコルをいくつか開発しています。キャッシュを強制的に無効にしたり、メインメモリから最新のデータを読み出したり、いくつかの変数を更新した後にメインメモリに強制的にリフレッシュさせたりといった、マルチレベルキャッシュによって引き起こされる問題を解決するためのプリミティブ命令をいくつか提供することで、この問題を解決しています。
知っている、Javaは高レベルの言語であり、ソフトウェア開発の問題にハードウェアをシールドすることを目的とするので、コンピュータのキャッシュシステムの抽象化、メインメモリとスレッドローカルストレージの概念上のJavaだけでなく、プロトタイプを制限するためにCPUキャッシュ上のJavaレベルを提供し、私は個人的に、これらのプロトタイプの命令は、コンピュータのマルチキャッシュプロトコルの抽象化とカプセル化であると感じています。たとえば、多くの場合、同期、揮発性などのように使用されます。
Javaによって提供される元の言語命令のいくつかに加えて、JVMはまた、JVMの設計では、規定のいくつかを遵守する必要がありますベンダーを制限するためにいくつかのルールを提案する、そうでない場合は、マルチスレッド環境でのプログラムは、予期しない結果で発生しますハザード-前の原則です。
順序性
順序性とは、CPUが実行中にいくつかの命令を並べ替えるという事実を指すことがほとんどですが、並べ替えられた実行結果はシングルスレッド環境では何の違いもありません。
CPUに加えて、いくつかの命令のコンパイルでは、JVMはまた、シングルスレッド環境では、並び替えは、プログラムの実行結果に影響を与えることはありませんが、いくつかの極端な環境ではまだ問題があるでしょう、非常に古典的なケースで次のようになります順序を変更されます:怠惰なダブルチェックロックシングルトンパターン
public class SingletonObject {
private static SingletonObject instance = null; //何か問題があるのだろうか?
private SingletonObject(){} //コンストラクタの私有化
public static SingletonObject getInstance() {
if (instance == null) { // 最初にチェックする
synchronized (Singleton.class) {
if (instance == null) { // 2回目のチェック
instance = new SingletonObject();
}
}
}
return instance;
}
}
インスタンスがnullかどうかを2回チェックする必要があるのは、複数のスレッドが同時に最初の判定を行うと、オブジェクトが複数になってしまい、シングルトン・パターンとは呼べなくなるのを防ぐためです。
- ヒープ内のオブジェクトの作成
- オブジェクトの初期化
- スタック内の変数をヒープ内のオブジェクトにポイントします。
これは、3番目のステップが完了したら、インスタンスは、JVMが2と3に順序を切り替える場合は、スレッドが直接戻って、NULLではない判定インスタンスを実行することができるかもしれませんが、この時点で、実際のインスタンスは、初期化アクションを完了していないことに注意する必要がありますオブジェクトの使用中の他のスレッドがされる可能性があります。NULLポインタ例外をスローします。 private static volatile SingletonObject instance = null そのため、解決策としては.NETに変更する必要があります。このキーワードは何のためにあるのでしょうか?
Javaマルチスレッドの制約
マルチスレッドプログラムは、これらの3つの主要な問題に存在する可能性があるため、もちろん、解決策ああが存在しなければならないので、ここでJavaプログラマは、同期キーワードまたは元の言語の同期の数と接触している概念が存在する必要があります上記の3つの主要な問題と存在を解決することです。ここではそれについて詳しく説明します。
Javaは、コードを書くときにプログラムとJVMの動作を適切に制約するために、jdkレベルで多くの同期ディレクティブを提供しています。
synchronized
開発者としては、同期命令との最初の接触は、同期する必要があります知っているメソッドの同期された変更の使用は、同時に唯一の実行で同じスレッドを持つことができます前に、クリティカルコードの実行では、最初に相互排他ロックを取得する必要がありますので、これも原子性のクリティカルコードの実行を保証します。また、前のスレッドが実行を終了した後、ロックを取得した次のスレッドが変数の変更を見ることができ、これも可視性を保証します。 実際のセマンティクスは、ロックリソースを取得した後、メインメモリから最新の値を取得するためにCPUキャッシュを強制的に無効にし、ロックリソースを解放するときは、強制的にローカルスレッドキャッシュをメインメモリにフラッシュします。
volatile
前述したように、volatileはJVMによる命令の並べ替えを無効にします。 実際、volatileには可視性のセマンティクスもあり、あるスレッドがvolatile変数に対して更新を行った後、最新の変更が別のスレッドから見えるようになります。この正確な理由は、これから紹介するハザード・ビフォアの原則にあります。誰かがvolatileについて質問してきたら、並べ替えは禁止されており、可視性は保証されていると答えればよいのです。
また、volatileは変数の読み書きにアトミックであることを保証していません。 volatile変数を更新する際にも、中断されて誤った値が読み込まれる可能性があるため、変数に対して組合せ演算を行う場合は、同期プリミティブを使用して演算のアトミック性を保証する必要があり、そうしないと問題が発生する可能性があります。
happens-before
happens-beforeの原則は、JVMを設計する際にベンダーが従うべき仕様を制限するために使用され、その下でプログラムの可視性が保証されます。この原則が本当に意味するのは、前の操作の結果が次のスレッドから見えるということです。
逐次手続規則
このルールは、スレッドでは、プログラムの順序で、前の操作が後の操作の前に起こることを意味します。
揮発性変数規則
この規則は、揮発性変数への書き込み操作が、その揮発性変数への後続の読み取り操作の前に起こることを指します。
パイプラインのロック規則
このルールは、ロックのロック解除は、そのロックの後続のロックの前に起こることを意味します。
このルールを理解するには、まず「パイプライン化」の意味を理解する必要があります。パイプラインはsynchronisationの総称で、Javaではsynchronizedを意味し、synchronizedはJavaのパイプラインの実装です。
例えば、以下のコードでは、同期ブロックに入るとロックが自動的に追加され、ブロックの実行が終了するとロックが自動的に解放され、コンパイラによってロックが追加されると同時に解放されます。
synchronized (this) { //ロックはここで自動的に適用される。 // x共有変数、初期値=10 if (this.x < 12) { this.x = 12; } } //ここに自動ロック解除がある
xの初期値が10だとすると、スレッドAがコードのブロックを実行し、xの値が12になり、スレッドBがコードのブロックに入ると、スレッドAがxに書き込み操作をしたことがわかります。
スレッド開始ルール
つまり、メインスレッドAがサブスレッドBを起動した後、サブスレッドBはメインスレッドの動作を確認してからサブスレッドBを起動することができます。
スレッド終了規則
つまり、メインスレッドAは子スレッドBの終了を待ち、子スレッドBが終了すると、メインスレッドは子スレッドの動作を見ることができるということです。もちろん、いわゆる「見える」というのは共有変数の操作のことです。
スレッド中断ルール
スレッド interrupt() メソッドの呼び出しは、割り込まれたスレッドコードが割り込みを検出する前に起こります。どちらも Thread.interrupt() によって割り込みを検出できます。
機能を渡す
このルールには大きな謎があるので、コードの断片を見てみましょう:
//コード例
class VolatileExample {
int x = 0;
volatile boolean v = false;
public void writer() {
x = 42;
v = true;
}
public void reader() {
if (v == true) {
// ここでxは何になるのだろうか?
}
}
}
"x=42 "発生-ルール1の内容である変数 "v=true "を書き込む前に;
変数 "v=true "の書き込みは、ルール2の内容である変数 "v=true "の読み取りの前に起こります。
繰り返しますが、この推移的ルールに基づけば、「x=42」という結果が得られます。これはどういう意味でしょうか?スレッドBが "v=true "を読むと、スレッドAによって設定された "x=42 "がスレッドBから見えるようになります。
他のスレッドは、揮発性変数に対する操作であれば、揮発性変数への読み取り後に前のスレッドのXへの更新を見ることができます。
これがReentrantLockすなわちAQSの核心であり、ReentrantLockがなぜ原子性と可視性を保証するのか、その理由です。
CAS
CASはJavaが提供するロックフリーのスキームであり、CAS変数に同期操作を追加することなくその正しさを保証しています。
実際には、CASは、CPUの使用は、プリミティブ言語の一種を提供するために、そのコアは、変数に操作を書き込むには、同時に別のCPUコアで複数のスレッドがある場合は、1つだけが正常に実行することができるという事実にある必要があります、他のCPUは、実行の失敗が通知されます、実行が失敗したときに、Javaレベルは、最新の値を再度読み取るために、次に、更新操作を実行するために行くので、操作の成功の実行まで、などなど。この操作は、Javaレベルではなく、CPUが直接再試行するために行われることに注意してください、多くの人々がこの誤解を持っています。





