長い文章になってしまったので、概要だけ先に書きます。
以下のJavaプログラムは、常に上から下に順番に命令が実行されると思いますか?つまり、aに1が格納された後に、bに2が格納されると思いますか?
実は場合によってはこの実行順序が入れ替わる場合があります。これはJavaの言語仕様として定義されていることです。これを考慮しないと信頼性のある並行処理は実装できません。
気になる人は以下を読んでみてください。a = 1; b = 2;
すでにインターネットは社会インフラ化しています。ソーシャルネットワークで多くの人とコミュケーションやコラボレーションできる時代で、個人が情報を作り消費することは当たり前になってきています。そして、インターネット上のコンテンツは増加の一途を辿っています。「情報爆発」なんて言葉も耳慣れた言葉になりましたが、その問題解決のためにMapReduceなどの分散処理技術に注目が集まっています。このような背景もあり、コンピュータのプロセッサは益々処理能力を求められるようになります。半導体の集積度だけでムーアの法則を維持するのは難しいため、必然的な流れでマルチコア化していくわけです。実際、シングルコアのCPUってあるの?ぐらい勢いですw そのうち、スマートフォンだってマルチコア化していきますよ。
マルチコア時代になると求められるのは「並行処理プログラミング」であることは言うまでもありません。ボトルネックを考慮して、コアを生かしきる並行処理プログラミングをしないといけなくなるでしょう。
ともあれ、身近なところでは、単一のサーバでも並行処理することはよくあるので、勉強しておいて損はないと思います。その並行処理プログラミングは、本質的に設計も実装も難しく、思わぬ不具合を生み出しかねません。だから、並行処理でハマらないように、知識を深めて備えておく必要があると思っています。
まずは仕様書から
うんちくは、ここまでにして、今回は、とりあえず「JSR–133 Java Memory Model and Thread Specification」に規定されたJavaのメモリモデル(Java Memory Model = JMM)について解説したいと思います。斯く言う私もこの仕様については、最近本気を出して勉強した次第です。多分、この仕様をきちんと理解したら、今まで自分が書いたコードを見ると死にたくなるかもしれませんが、そこをなんとか乗り越えましょうw あ、当初想定したより、長文になってしまいました。しかも、このエントリだけでは本題に触れていません。そこはご容赦くださいorz 本題の方はマルチコア時代に備えて本気でメモリモデルを理解しておこう - メモリバリア編 -です。
JSR-133の"official specification"は多分これだと思います。
The JSR-133 specification, as sent to Final Approval Ballot.
This is the "official specification" (August 9, 2004). It doesn't contain much in the way of explanation.
厳密には、The Java Language Specification - 17. Threads and Locksを読んだほうがいいでしょう。
日本語版では「17章 スレッドとロック」P483です。
Java言語仕様 第3版 (The Java Series)
- 作者: ジェームズゴスリン,ガイスティール,ビルジョイ,ギッラードブラーハ,James Gosling,Guy Steele,Bill Joy,Gilad Bracha,村上雅章
- 出版社/メーカー: ピアソンエデュケーション
- 発売日: 2006/12
- メディア: 単行本
- 購入: 1人 クリック: 108回
- この商品を含むブログ (42件) を見る
JSR–133はJava5でリリースされた仕様なので、Java言語仕様を読んで理解するしかないのですが、難しいので読み下すためのヒントを提供するような内容です。だからすべての機能は説明しません。
(モヒカン族の方々、以下、仕様理解として表現がよろしくないところがあればツッコミお願いしますm(__)m)
可視性保証にはメモリバリア
先日のエントリで紹介した固有ロック(synchronized)とvolatile修飾子が提供している可視性保証には、「メモリバリア*1」と呼ばれる特殊な命令が利用されます。このメモリバリアから理解していきたいと思います。
「Java並行処理プログラミング」P259より引用
プロセスのキャッシュをフラッシュまたは無効化し、ハードウェアの書き込みバッファをフラッシュし、実行パイプラインを停止します。メモリバリヤはコンパイラによるそのほかの最適化も禁じるので、間接的に実行性能に影響を与えます。メモリバリヤがあると、操作の順序を変える最適化はほとんどできません。
Java並行処理プログラミング ―その「基盤」と「最新API」を究める―
- 作者: Brian Goetz,Joshua Bloch,Doug Lea
- 出版社/メーカー: ソフトバンククリエイティブ
- 発売日: 2006/11/22
- メディア: 単行本
- 購入: 24人 クリック: 419回
- この商品を含むブログ (163件) を見る
リオーダー(順序変更)とは
そのメモリバリアを理解するには、まず「命令の順序変更」の仕様から説明したいと思います。この命令の順序変更は「リオーダー(reordering)」とも呼ばれます。話がいきなり飛ぶのですが、、メモリバリアに依存する仕様なのでご容赦ください。
ここでいう命令の順序とは、プログラムの実行順序のことです。「Java並行処理プログラミング」「16-1-1 プラットホームのメモリモデル」では、そもそもプロセッサのプログラムの実行順序は、人間のメンタルモデル(逐次一貫性、フォン・ノイマン・モデルなどと表現)に依存しないということを述べています。だから、ある変数に書き込んだ値が、次に最新の値が取得できるとは限らないということが起こる。実行時にはプログラムの命令というのはリオーダーされる可能性があるということです。
プログラムの実行状態を表す便利なメンタルモデルの一つとして、プログラムに書かれている操作が、どんなプロセッサの上でも一定の順序で行われ、変数のリードはつねに、その変数に対してどれかのプロセッサが最後そのリード時点から見て最後に行ったライトの結果を見る、と想像しましょう。この幸せでしかし非現実的なモデルは逐次的一貫性(sequential consistency) と呼ばれます。ソフトウェアのデベロッパは無意識のうちに逐次的一貫性を想定することが多いですが、しかし現実は、現代のマルチプロセッサが逐次的一貫性を提供することはなく、またJMMも提供しません。この古典的な逐次計算モデル、いわゆるフォン・ノイマン・モデルは、現代のマルチプロセッサの上では、計算動作のごく曖昧な近似にすぎません。
では、ガタガタ言わずに、早速リオーダーされるコードを見てみましょう。
以下のプログラムでは正しく同期化されていないため、タイミングやJVMのスレッドのスケジューリングによっては、最後のSystem.out.println文は(0,1),(1,0),(1,1)になったりします。しかし結果が(0,0)となる場合もあります。
public class ReOrderingTest { static int x = 0; static int y = 0; static int a = 0; static int b = 0; public static void main(String[] args) throws InterruptedException { Thread aThread = new Thread(new Runnable() { // スレッドA @Override public void run() { a = 1; x = b; } }); Thread bThread = new Thread(new Runnable() { // スレッドB @Override public void run() { b = 1; y = a; } }); aThread.start(); bThread.start(); try { aThread.join(); bThread.join(); } catch (InterruptedException e) { e.printStackTrace(); } System.out.println(" ( " + x + " , " + y + " )"); } }
たとえば、下記の表でスレッドAの(1)と(4)の命令がリオーダーされた場合は、結果が(0,0)となります。実際に、私の実行環境(Mac OS X/Java1.6.0_22/64bit Server VM)では、このコードを動作させて(0,0)の再現性を確認しましたが、再現しませんでした。
しかし、これはJMMの仕様としては許された最適化なのです。信頼性のある並行処理を実装するなら、この仕様を考慮したプログラミングをせざるを得ません。
スレッド | - | (1) | (2) | - | (3) | (4) | |||||||
スレッドA | -> | x = b(0) | -> | -> | -> | a = 1 | |||||||
スレッドB | -> | -> | b = 1 | -> | y = a(0) | -> |
メモリモデルはスレッド単位で解釈される
なぜ、このようなことが起こるのか。難しいので文献を引用して説明。
「Java言語仕様3版」では、JMMではメモリモデルの一貫性を失わないのであれば、順序の変更や不要な同期化の除去を含む、さまざまなコードの変換を行うと示されています。同じく「17.4 メモリモデル」では、JMMは基本的にスレッド単体(シングルスレッド)でのメモリモデルを作って解釈する、イントラスレッド・セマンティクス(intra-thread semantics)に基づいているとしています。
独立した各スレッドの動作は,各読み込みにより得られる値がメモリ・モデルによって判定されるという点を除き,そのスレッドのセマンティックスによって規定されたとおりに振る舞わなければならない。このことに言及する際,該当プログラムはイントラスレッド・セマンティックス(intra-thread semantics)に従っていると表現する。
イントラスレッド・セマンティックスは,シングル・スレッドのプログラムに対するセマンティックスであり,スレッド内の読み込み動作によって観測できる値に基づいたスレッドの振る舞いを完全に予測できるようにするものである。
「Java並行処理プログラミング」の「16-1 メモリモデルとは何か? なぜ必要か?」 P380 でも同じことが説明されています。プログラムの結果が逐次に処理した結果が同じにあるなら、ランタイムは何をやってもよいという意味ですね*2。
Javaの言語仕様は、JVMがスレッド内部で直列的なセマンティクスを維持することを要求しています。これは、プログラムの結果が、そのプログラムを厳密に逐次的な環境中のプログラム順序で実行したときと同じ結果になるなら、環境は何をやってもよいというルールです。コンパイラやJVMが行うトリックによってプログラムの実行性能は良くなりますから、このルールは大歓迎です。
そして、「Java言語仕様3版」の「17.4.3 プログラムとプログラムの順序」では以下のように記されています。
インタースレッド動作(inter-thread action)とは、複数のスレッドから実行可能な以下の動作をいいます*3。
- 読み込み(通常,あるいは非揮発的な)動作。変数の読み込み0
- 書き込み(通常,あるいは非揮発的な)動作。変数への書き込み。
- 同期動作。以下のものがある:
- 揮発的な読み込み。変数の揮発的な読み込み。
- 揮発的な書き込み。変数への揮発的な書き込み。
- ロック。モニタのロック。
- アンロック。モニタのアンロック。
- スレッドの(合成された)最初と最後の動作。
- 17.4.4で解説している,スレッドを開始する動作や,スレッド停止の検出。
(他にも定義される動作がありますが、ここでは以下省略。詳しくはJava言語仕様3版 17.4.2 動作を参照してください)
このインタースレッド動作で作られるプログラムの順序は、イントラスレッド・セマンティックスとして実行される順序であるといっています。インタースレッド動作といっても結局はスレッド単位の意味において解釈されるということでしょう。
各スレッドtによって実行されるインタースレッド動作すべてにおいて. tにおけるプログラムの順序(progmm order)は,こういった動作がtのイントラスレッド・セマンティックスによって実行される順序を反映した全体的な順序となる。
「Java並行プログラミング」の「16-1-2・順序変え」でも、スレッド単位で順序の見え方が違ってもよいと言及しています。スレッド間で同期化してなければ、スレッド単位のメモリモデルで最適化されるよってことですね。スレッド単位でのメモリモデルの一貫性が崩れなければリオーダーできるというのは、マルチプロセッサにとって実行効率面で大きな優位性を持つのは言うまでもありません。
JMMは、「一連の活動を構成する複数の操作の順序の見え方がスレッドごとに違ってもよい」と言っています。そのため、同期化をしないときの実行順に関する判断が、一層難しくなっています。ある操作が不可解に遅れたり、へんな順序で実行されるように見えたりする現象をすべてひっくるめて、順序変え(reordering) と呼びます。
どのように並べ替えを行うかについては、「Java言語仕様3版」の「17.3 同期化を誤ったプログラムは意外な振る舞いを見せる」に具体的な説明がありました。ここでは以下のように仕様を説明しています。
しかしコンパイラは,各スレッドの実行を個別に解釈し,その実行に影響が及ばないと判断できた場合には,双方のスレッド中の命令を並べ替えることが許されているのである。
ここでは、リオーダーはコンパイラによって行われる例を説明しています。実際には複雑なメカニズムで並べ替えが行われるようです。JITコンパイラやプロセッサによって並び替えられる可能性があるとこの仕様書には記述があります。
さらに「Java言語仕様3版」の「17.3 同期化を誤ったプログラムは意外な振る舞いを見せる」では、どうように"見えることも"あると言っています。
また,仮想マシンが稼働しているマシンのアーキテクチャにおけるメモリの序列によって,コードが並べ替えられているかのように見えることもある。
前述では"ある操作が不可解に遅れたり、へんな順序で実行されるように見えたりする現象をすべてひっくるめて"とありましたが、これは、おそらく、コンパイラなどが"意図的に命令の順序を変更する(言語仕様には変換と書いている)"のと、"命令が並べ替えられているかのように見える"ことの両方を含めて、リオーダーという定義になのではないかと思います。
リオーダーを禁止して可視性を保証する
リオーダーにはメリットがあるのですが、複数のスレッド間で協調してメモリの可視性を保証したい場合は問題になります。先ほどの引用でもあったようにメモリバリアを使うとリオーダー(順序変更)が禁止されます。
The JSR-133 Cookbook - Volatiles and Monitors (volatile変数とモニタ)に示されているリオーダーの仕様です。リオーダー自体はJMMの仕様に含まれません。JVMがメモリバリアを挿入するための前提条件として必要です。
コンパイラ作成者にとってJMMは,主にモニター(ロック)だけでなくフィールド(ここでいう「フィールド」には配列の要素も含む)にアクセスする特定の命令列に関して,順序変更 (reorderings) を禁止するルールより構成されている.
volatile変数とモニタに関するJMMの主要なルールは,各要素が特定のバイトコード命令の並びと関連した順序変更不可能な命令群を意味する行列とみなすことができる.この表はそれ自体はJMMの仕様ではない;これは単にコンパイラやランタイムシステムに関するJMMの主な結論を,便利な形で図示したものだ.
The JSR-133 Cookbook - Volatiles and Monitors (volatile変数とモニタ)にある表を参照してください。この表では最初の命令と次の命令の組み合わせでリオーダーを許可するのか、禁止するのかが示されています。命令の種類は以下です。
通常ロード(Normal Load)とは,非volatile変数からのgetfield, getstatic, 配列からのロードのいずれかのことを指す.
通常ストア(Normal Store)とは,非volatile変数へのputfield, putstatic, 配列へのストアのいずれかのことを指す.
volatileロード(Volatile Load)とは,複数スレッドよりアクセス可能な volatile 変数への getfield, getstatic のいずれかのことである.
volatileストア(Volatile Store)とは,複数スレッドよりアクセス可能な volatile 変数への putfield, putstatic のいずれかのことである.
MonitorEnter (synchronizedメソッドの開始を含む)とは,複数スレッドよりアクセス可能なロックオブジェクトに関するものである.
MonitorExit (synchronizedメソッドの終了を含む)とは,複数スレッドよりアクセス可能なロックオブジェクトに関するものである.
この例を分かりやすく説明すると
1stと2ndで指定される命令の間には,任意の個数のその他の演算が出現することが許される.だから,例えば, [Normal StoreとVolatile Store]の組に対するマス目に書かれている「No」とは,「非volatileストアは,それより後にある(少なくともマルチスレッドプログラム上のセマンティクス的に違いを生じる可能性のある)全ての Volatile Storeと,順序変更してはならない.」ということを意味する.
以下のようなコードのことをいいます。この場合はloadメソッド内のリオーダーは禁止されます。このリオーダーを禁止することで、(2)から(1)は可視性が保証されます。
publicl class Employee { private volatile String name; private int age; public void load() { age = 39; // Normal Store (1) name = "KATO"; // Volatile Store (2) } }
先ほどのリオーダーされて、整合性が取れていないプログラム例で見てみましょう。
スレッドAもスレッドBも、[Normal Store, Normal Load],[Normal Store, Normal Store],[Normal Load, Normal Store]の3つの組み合わせになります。この表ではリオーダーは許されているので、(0,0)の結果になりうるわけですね。
Thread aThread = new Thread(new Runnable() { // スレッドA @Override public void run() { a = 1; // Normal Store (1) x = b; // Normal Load (2) -> Normal Store (3) } }); Thread bThread = new Thread(new Runnable() { // スレッドB @Override public void run() { b = 1; // Normal Store (1) y = a; // Normal Load (2) -> Normal Store (3) } });
事前発生(happens-before)で可視性を保証する
可視性を保証するには、事前発生(または先行発生。英語ではhappens-before)というルールに従う必要があります。
「Java言語仕様3版」の「17.4.5 先行発生の順序」には
2つの動作は,先行発生の関係(happens-before relationship)によって順序付けることができる。ある動作が他の動作よりも先行発生する場合,最初の動作は2番目の動作から可視となり,それよりも前に順序付けられているととになる。
happens-beforeには以下のルールで実現可能です。
- xとyが同じスレッドにおける動作であり,プログラムの順序においてxがyの前にある場合,hb(x, y) となる。
- オブジェクトのコンストラクタ終端から該当オブジェクトのファイナライザ(12.6) の開始端までに先行発生の端がある。
- 動作xが続く動作yと同期関係にある場合,hb(x, y)となる。
- hb(x, y)かつhb(y, z)である場合,hb(x, z) となる。
上記から以下のルールが導出できるとのこと。なぜそうなるかは不明...。
- モニタのアンロックは,後に続くすべての該当モニタに対するロックよりも先行発生する。
- volatileフィールド(8.3.1.4)に対する書き込みは,後に続くすべての該当フィルドの読み込みよりも先行発生する。
- スレッドのstart()呼び出しは,開始されるスレッド中の任意の動作よりも先行発生する。
- スレッド中のすべての動作は,該当スレッドに対するjoin()から正常に戻ってきたその他のスレッドよりも先行発生する。
- 任意のオブジェク卜のデフォル卜による初期化は,プログラムにおける他の任意の動作(デフォルト書き込み以外)よりも先行発生する。
先ほどの例で、(2)から(1)は可視性が保証されると言いましたが、(1)と(2)の順序が変更されないため、hb(1, 2)が成り立つので可視となるわけです。
publicl class Employee { private volatile String name; private int age; // hb(1, 2)が成り立つ。 public void load() { age = 39; // Normal Store (1) name = "KATO"; // Volatile Store (2) } }
上記のEmployee例では name = "KATO"の時点で、ageが可視となっても値を利用していないので、なんとなく理解しにくいので、もう少し複雑な例でみてみます。
public class Hoge { private int a; private volatile int v; public void func1() { a = 1; // Normal Store (1) v = a; // Normal Load (2) -> Volatile Store (3) }
func1の場合は[Normal Store (1), Normal Load (2)],[Normal Store (1), Volatile Store (3)],[Normal Load, Volatile Store (3)]の組み合わせです。
このうちリオーダー可能なのは[(1),(2)]です。[(1),(3)],[(2),(3)]はリオーダー禁止です。つまり、hb(1, 3),hb(2, 3)で(3)から(1)と(2)は可視であるといえます。
// [(2),(3)]はリオーダー可, [(1),(2)],[(3),(4)],[(1),(4)]はリオーダー不可 public synchronized void func2() { // Monitor Enter (1) a = 1; // Normal Store (2) int b = a; // Normal Load (3) } // Monitor Exit (4) // [(1),(2)],[(3),(4)],[(1),(4)]はリオーダー可,[(2),(3)]はリオーダー不可 public void func3() { a = 1; // Normal Store (1) synchronized (this) { // Monitor Enter (2) } // Monitor Exit (3) int b = a; // Normal Load (4) } }
また、func2メソッドと、func3メソッドを見れば、synchronizedメソッドやsynchronizedブロックでもモニターの開始〜終了の順序は変更できないことがわかります。func2メソッドの[(2),(3)]のリオーダーが発生しても単一のスレッドしかロックを取得できないのでスレッド間での命令順序の整合性は保証されます。
追記:
さて、ReOrderingTestクラスをリオーダー禁止にして(0,0)にならないように改修してみました。volatile版です。要はリオーダー禁止になるようなLoad/Storeを狙い撃ちすればよいと思う。ただし、アトミック性がないので(1,0),(0,1),(1,1)の答えが表示される。まぁ、わざわざこんなコードを書く必要はなく、お互いの命令の順序に影響を受けない設計をしたほうがいいです。
public class ReOrderingTest { static volatile int x = 0; static volatile int y = 0; static volatile int a = 0; static volatile int b = 0; public static void main(String[] args) throws InterruptedException { Thread aThread = new Thread(new Runnable() { // スレッドA // hb(1, 2), hb(2,3)となれば hb(1,3) だと思うが、成り立つか確認した // [Volatile Store (1), Volatile Load (2)] = No // [Volatile Load (2), Volatile Store (3)] = No // [Volatile Store (1), Volatile Store (3)] = No @Override public void run() { a = 1; // Volatile Store (1) x = b; // Volatile Load (2) -> Volatile Store (3) } }); Thread bThread = new Thread(new Runnable() { // スレッドB // hb(1, 2), hb(2,3), hb(1,3) // [Volatile Store (1), Volatile Load (2)] = No // [Volatile Load (2), Volatile Store (3)] = No // [Volatile Store (1), Volatile Store (3)] = No @Override public void run() { b = 1; // Volatile Store (1) y = a; // Volatile Load (2) -> Volatile Store (3) } }); aThread.start(); bThread.start(); aThread.join(); bThread.join(); System.out.println(" ( " + x + " , " + y + " )"); } }
ここまで説明したのは、リオーダーとその許可される場合と禁止される場合の仕様と、事前発生による可視性保証です。注意が必要なのはリオーダーを禁止して、可視性を保証するのですが、あくまで観測できるだけです。メモリアクセスに対する同期がどのように発生するか(どういう順番でメモリの読み書きが同期的に発生するか)は別のエントリで具体的に説明します。
finalフィールドの場合
JSR-133には、finalフィールドに関するリオーダーについても規定されています。Normalのように扱われますが、以下の条件が追加されています。finalなローカル変数とは違うので注意が必要です。
1.(コンストラクタ中での)finalフィールドへのストアと,finalフィールドが参照型の時にこのfinalが参照可能な全てのストアは,その後に続く(コンストラクタ外の), そのfinalフィールドを保持するオブジェクトの参照の,他のスレッドよりアクセス可能な変数へのストアと,順序変更してはならない.例えば,以下の例では順序変更は許されない.
x.finalField = v; ... ; sharedRef = x;
以下の(1)と(2)は順序を変更してはならない。
public class Employee { public final String name; public Employee(String name) { this.name = name; } }
employee = new Employee("Junichi Kato"); // employee.name = "Junichi Kato" (1) // ... // (2)を(1)の後にスレッドで実行してもhb(1, 2)である EmployeeHolder.employee = employee; // (2)
例えば"..."が,コンストラクタの論理的な終端にまで及ぶコンストラクタのインライン化の際に,これが関係してくる.コンストラクタ中のfinalへのストアは,そのオブジェクトを他のスレッドから見える様にするかもしれないコンストラクタ外にあるストアの後に,移動してはならない.(以下で見られるように,これはバリアの発行が必要になるかもしれない.)同様に以下の例において,三つ目の割り当ては,前の二つのいずれかと順序変更することは許されない.
v.afield = 1; x.finalField = v; ... ; sharedRef = x;
(3)は、(1),(2)のいずれかと順序を変更してはならない。
public class PersonName { public String firstName; public String lastName; public PersonName(String firstName, String lastName) { this.firstName = firstName; this.lastName = lastName; } } public class Employee { public final PersonName name; public Employee(PersonName name) { this.name = name; } }
PersonName personName = new PersonName("Junichi","Kato"); // personName.firstName = "Junichi"; personName.lastName = "Kato"; (1) employee = new Employee(personName); // employee.name = personName; (2) // ... // (3)をスレッドで実行しても順序は保証される EmployeeHolder.employee = employee; // (3)
2.finalフィールドの初期化ロード(例えば,本当に最初にスレッドに遭遇した時)は、finalフィールドを保持するオブジェクトへの参照の初期化ロードと順序変更してはならない.これは次の例で意味を持つ.
x = sharedRef; ... ; i = x.finalField;
(1)と(2)の順序を変更してはならない。
public class EmployeeHolder { private Employee employee = new Employee("hoge"); public Employee getEmployee() { return employee; } } public class Employee { public final String name; public Employee(String name) { this.name = name; } }
Employee employee = employeeHolder.getEemployee(); // (1) スレッドが初めてロードする // ... // (2)を(1)の後にスレッドで実行してもhb(1, 2)である String name = employee.name; // (2)
これを見た限りではfinalフィールドが利用される前にはロードとストアが完了していることを保証しているようです。
そして、次が重要です。finalフィールドを信頼して使う条件が示されています。
Javaプログラマが finalフィールドを信頼して使用するには, finalフィールドを持つオブジェクトへの参照型が共有される時に,その参照型のロードは,それ自体が同期化されているか, volatile又は finalであるか,又はそのようなロードに由来するかのいずれかでなければならない.そして,それゆえに究極的にはコンストラクタ中の初期化ストアと,それの後に続くコンストラクタ外の参照の利用が順序づけられる.
finalであるnameフィールドを持つEmployeeオブジェクトへの参照が共有される時に、その参照型のロードは、固有ロックなどで同期化されているか、volatileか、finalであるか、そのようなロードに由来する、いずかでなければならないと。
以下のようなEmployeeとEmployeeのインスタンスを保持するEmployeeHolderがあって、EmployeeHolderがgetEmployeeメソッドでEmployeeオブジェクトを共有するケースで考えます。
public class Employee { private final String name; // getter省略 public Employee(String name){ this.name = name; } }
public class EmployeeHolder { private Employee employee = new Employee("KATO"); public Employee getEmployee() { return employee; } public void doSomething() { // ... } }
EmployeeHolder employeeHolder = new EmployeeHolder(); // (1) employee = new Employee(); employeeHolder.doSomething(); // ... // (2)を(1)の後にスレッドで実行してもhb(1, 2)である Employee employee = employeeHolder.getEmployee(); // (2) return employee; Employeeコンストラクタ外でのEmployeeへの参照の利用 String name = employee.name;
Employeeのfinalフィールドであるnameを信頼して利用するには、以下の(1)と(2)以降が順序付けられる必要があります。上記のEmployeeHolderのgetEmployeeメソッドでは不十分で以下のいずれかでなければならいということだと思います。
しかし、この文脈に具体的にどのようにこれらを組み合わせてコードを書けばよいかは示していないので、考えてみました。
固有ロックとvolatile版
public class EmployeeHolder { // volatile修飾子 private volatile Employee employee = new Employee("KATO"); // Volatile Store (1) // 固有ロック public synchronized Employee getEmployee() { // Monitor Enter (2) return employee; } }
EmployeeHolder employeeHolder = new EmployeeHolder(); // Volatile Store (1) // ... Employee employee = employeeHolder.getEmployee(); // Monitor Enter (2)
[(1),(2)]はリオーダー禁止で順序付けられるので、(2)からみて(1)の可視性が保証されます。また、getEmployeeメソッドで返されるEmployeeのnameフィールドは信頼できます。
そして、次はfinal版。
先ほどの"x.finalField = v; ... ; sharedRef = x;"に該当するので順序変更が禁止されます。つまり、(2)からみて(1)の可視性が保証されます。これは後述するfinalフィールドのセマンティクスでわかります。
public class EmployeeHolder { // final private final Employee employee = new Employee("KATO"); // Normal Store (1) public Employee getEmployee() { return employee; // Normal Load (2) } }
EmployeeHolder employeeHolder = new EmployeeHolder(); // employeeHolder.employee = new Employee("KATO"); Normal Store (1) // ... Employee employee = employeeHolder.getEmployee(); // Employee employee = employee; Normal Load (2)
finalフィールドのセマンティクス
もう "脳汁でまくりんぐ" ですが、ここでfinalフィールドのセマンティクスです。
「Java言語仕様3版」の「17.5 finalフィールドのセマンティックス」P500から引用。
これはさすがにJavaの初級で学ぶこと。
final宣言されたフィールドが一度初期化された場合,通常の状況においては変更されることがない。
そして次。finalフィールドはコンストラクタで初期化が完了した時点で完全に初期化されて、その後に正しく観測できるといっています。観測というのは、可視性があるとか、見えるとかって意味です。
finalフィールドによって,プログラマは同期化を用いることなく,スレッド・セーフな不変オブジェクトを実装できるようになる。スレッド・セーフな不変オブジェクトは,スレッド間で不変オブジェクトへの参照を引き渡すようなデータ競合がある場合であっても,すべてのスレッドから不変であるかのように見える。これによって,誤ったまたは悪意のあるコードによる不変クラスの濫用に対する安全性を保証できるようになる。finalフィールドは,不変性を保証するために正しく使用しなければならない。
オブジェクトは,そのコンストラクタが完了した時点で完全に初期化された(completelyinitialized) と考えられる。オブジェクトが完全に初期化された後のオブジェクトに対する参照のみを観測することができるスレッドは,該当オブジェクトのfinalフィールドの初期値を正しく観測できることが保証されている。
追記:
finalフィールドは同期化が不要でスレッドセーフです。また、コンストラクタが完了した時点で完全に初期化されます。
スレッドからfinalフィールドが観測できるかどうかについては、この文章だと分かりにくいので、原文を参照しました。
17.5 Final Field Semantics
A thread that can only see a reference to an object after that object has been completely initialized is guaranteed to see the correctly initialized values for that object's final fields.
"そのオブジェクトの参照が見えるスレッドは、完全に初期化されたそのオブジェクトのfinalフィールドの値が見えることが保証されている"という意味ではないかと思います。なので、スレッドからの可視性は保証されているわけですね。
また、さりげなくこんなことも書いてある。
finalフィールドから参照される任意のオブジェクトや配列も観測することになる。
分かりにくいと思うので、ここから引用。
オブジェクトが適切にコンストラクトされている(つまりそのオブジェクトへの参照はコンストラクターが完了するまで公開されない)限り、あるスレッドから別のスレッドへ参照を渡すために同期が使われているか否かによらず、すべてのスレッドが、(そのオブジェクトのコンストラクターの中で設定された)オブジェクトのfinal field の値を見るのです。さらに、適切にコンストラクトされたオブジェクトのfinal field を通して到達できる変数(例えばfinal field が参照する、オブジェクトのフィールド)はどんな変数でも、やはり他のスレッドから見える事が保証されているのです。これはつまりfinal field が、(他のスレッドから見える、参照の正しい値に加えて)例えばLinkedListへの参照を含んでいる場合には、(コンストラクト時の)そのLinkedListの内容も、同期化無しで他のスレッドから見えることを意味します。その結果として、finalの意味が大幅に強化されたのです。つまり同期化無しでもfinal field には安全にアクセスする事ができ、コンパイラーはfinal field が変わらないものと想定する事ができ、従って繰り返しのフェッチを避けて最適化する事ができるのです。
まぁ、これでもちょっと分かりにくいかもしれませんw。
コードで説明しますが、こんな感じ。
先ほどのEmployeeHolderクラスです。
public class EmployeeHolder { // final private final Employee employee = new Employee("KATO"); public Employee getEmployee() { return employee; } }
そのEmployeeオブジェクトのフィールドらは、finalであろうとなかろうと正しく観測できるということを言っています。
public class Employee { private final String name; private List<Department> departments; // getter省略 public Employee(String name, List<Department> departments){ // ... } }
final EmployeeHolder employeeHolder = new EmployeeHolder(); // ... Thread t = new Thread(new Runnable() { @Override public void run(){ Employee employee = employeeHolder.getEmployee(); // 初期化したemployeeが観測できる System.out.println(employee.getName()); // nameフィールドも観測できる System.out.println(employee.getDepartments().get(0)); // departmentsもその要素も観測できる } } t.start(); t.join(); // 追記: Runnableの無名クラス版に修正
もうここまで来るとお分かりだと思いますが、固有ロックとvolatile修飾子でリオーダーを制御するより、finalフィールドを使ったほうが初期化は安全で同期化も不要です。
ちなみに、勘違いしやすい例を。
上記のEmployeeクラスのままで、EmployeeHolderのemployeeフィールドのfinalを外します。
public class EmployeeHolder { private Employee employee = new Employee("KATO"); public Employee getEmployee() { return employee; } public void doSomething() { // ... } }
その上で、先ほどと同じコードではどうでしょうか?
// EmployeeHolderのemployeeはfinalじゃないけど、ここでEmployeeHolderをfinalで宣言すれば大丈夫だよね? final EmployeeHolder employeeHolder = new EmployeeHolder(); // employee = new Employee(); Normal Store (1) // ... Thread t = new Thread(new Runnable() { @Override public void run(){ Employee employee = employeeHolder.getEmployee(); // return employee; Normal Load (2) employeeの観測が保証されない System.out.println(employee.getName()); // nameフィールドも観測が保証されない System.out.println(employee.getDepartments().get(0)); // departmentsもその要素も保証されない } }); t.start(); t.join(); // 追記: Runnableの無名クラス版に修正
EmployeeHolderがfinalなローカル変数だから、初期化安全性があると思うと危険だと思います。finalローカル変数の場合は再代入が禁止されるだけでリオーダーとしてNormal扱いなのです。
EmployeeHolderのemployeeフィールドにはfinalフィールドのセマンティクスが適用されないはずです。つまり、以下の[(1),(2)]はリオーダーされうるということです。リオーダーされた場合はemployeeフィールドの観測は保証されません。注意してください。
追記:
EmployeeHolderのemployeeフィールドにはfinalフィールドのセマンティクスが適用されないはずです。つまり、以下の[(1),(2)]はリオーダーされうるということです。リオーダーされた場合はemployeeフィールドの観測は保証されません。
言語仕様を確認してないのであれですが、この下り間違いかも employeeHolderの参照が無名Runnableクラスのfinalフィールドに暗黙的にコンパイルされるとのことなので。
リオーダーについてはここまで。
次は本題のメモリバリア編です。
マルチコア時代に備えて本気でメモリモデルを理解しておこう - メモリバリア編 -