前回、前々回と割とヘビーな仕様の話だったので、今回は若干実用的なネタとして、シングルトンパターンの遅延初期化をメモリモデルの視点から、どのようにすればスレッドセーフになるか考えてみたいと思います。
そのシングルトンの遅延初期化はスレッドセーフか
以下のようなシングルトンパターンは日常的に使うデザインパターンの一つだと思います。
ただ、今回はSingletonクラスのgetInstanceメソッドに、わざとロックを掛けずに実装してみました。この場合にどういうことが起こりうるか考えてみたいと思います。
public class Singleton { private static Singleton instance; public static Singleton getInstance() { // バリアがない if (instance == null) { // Normal Load (1) // バリアがない instance = new Singleton(); // Normal Store (2) // バリアがない } // バリアがない return instance; // Normal Load(3) // バリアがない } public static void main(String[] args) { Thread aThread = new Thread(new Runnable() { // スレッドA @Override public void run() { Singleton s = Singleton.getInstance(); } }); Thread bThread = new Thread(new Runnable() { // スレッドB @Override public void run() { Singleton s = Singleton.getInstance(); } }); aThread.start(); bThread.start(); try { aThread.join(); bThread.join(); } catch (InterruptedException e) { e.printStackTrace(); } } private Singleton() { } }
とりあえず、mainメソッドを見てください。二つのスレッドからgetInstanceメソッドを呼ぶ前提で、そのgetInstanceメソッド内部では、変数への読み書きは、Normal Load, Normal Store です。リオーダーするとセマンティクスが変わるので、おそらくリオーダーされないと思うのですが、いずれにしてもNormalなので、メモリバリア命令は挿入されないはずです。ロックがないのでスレッドが割り込んで初期化が複数回行われる可能性があります。さらにあるスレッドで初期化したのに、他のスレッドではその初期化が見えないという問題も起こります。これはメモリバリアがないために起こる問題です。
なぜかというと、instanceフィールドへの読み書きは、スレッドごとのローカルキャッシュなどに対して行われるはずで、メインメモリに対して読み書きできる保証がないということが理由。
つまり、スレッドAで実行された書込みがスレッドBで読み込める保証がありません。また、その逆も同じで、スレッドBで実行された書込みがスレッドAで読み込める保証がありません。 ステートを他のスレッドに伝えるような通信効果を求めるならば、JVMがバリア命令を挿入するようなコードを書く必要があります。
Effective Java第二版の「項目66 共有された可変データへのアクセスを同期する」にも同様のことが書かれていますが、この説明だけではなぜそうなるのか、根拠がわからないので以下にメモリモデルの視点から対策を考えてみました。
スレッドセーフ対策案
対策1:volatileフィールドを使う
volatileフィールドを利用した場合は、以下。
随所にバリアが挿入されているので、メインメモリへの読み書きは反映されそうですが、アトミック性が保証されていないので、Volatile Load (1)とVolatile Store (2)の間にスレッドが割り込んで、初期化が複数回実行される可能性があります。これではスレッドセーフとは言えないので対策としては相応しくないです。
(*)はThe JSR-133 CookbookのInserting Barriers(バリアの挿入)にある特別なルールに基づくバリア命令を想定しています。*1
private static volatile Singleton instance; public static Singleton getInstance() { // LoadLoadバリア (*) // LoadStoreバリア (*) if (instance == null) { // Volatile Load (1) // LoadStoreバリア // StoreStoreバリア (*) instance = new Singleton(); // Volatile Store (2) // StoreLoadバリア (*) } // LoadLoadバリア (*) // LoadStoreバリア (*) return instance; // Volatile Load(3) }
対策2:固有ロック(synchronized)を使う
ロックをかけた場合は、EnterLoadバリアとStoreExitバリアが以下のように挿入されます。
最初にロックを取得したスレッドAで行ったinstanceフィールドへの書込みは、StoreExitバリアによりメインメモリにストアされます。次にロックを取得したスレッドBでは、先行発生の仕様によりinstanceフィールドの最新の値をメインメモリからロードすることができます。
スレッドが頻繁にgetInstnaceメソッドを呼び出す場合はロックの争奪が起こりパフォーマンス的に問題がありそうですが、シンプルなコードでスレッドセーフであることも考えると無難な設計かもしれません。
public static synchronized Singleton getInstance() { // MonitorEnter // EnterLoadバリア if (instance == null) { // Normal Load (1) instance = new Singleton(); // Normal Store (2) } return instance; // Normal Load(3) // StoreExitバリア } // MonitorExit
対策3:volatileフィールドと固有ロック(synchronized)を併せて使う
次は、volatileとsynchronizedを使ったダブルチェッキングロジックの場合。
volatileを使ってロックの争奪を防ぐ仕組みを提供しますので、パフォーマンスを気にする場合は有効かもしれません。
Volatile Load (1) は 複数回呼ばれることを想定すると、Volatile Load (1)の前にLoadLoadバリアが発生すると考えられます。メインメモリからinstanceフィールドの最新の値を取得できます。
instanceがnullの場合はロックを獲得します。ロックを取得する前に他のスレッドがロックを解放した可能性があります。その結果を事前発生の仕組みで見るために、Volatile Load (3)で最新の値をロードします。すでに初期化済みであればロックを解放し、最新の値を返します。初期化済みでなければ、インスタンスを生成してinstanceフィールドにストアします。ストアの結果はメインメモリに反映されます。
ロック回避という点でメリットがあるが、コードが複雑というのがデメリットです。あと、そもそもJava5以前のJVMではこのコードは期待通りに動作しません。
private static volatile Singleton instance; public static Singleton getInstance() { // LoadLoadバリア // LoadStoreバリア (*) Singleton result = instance; // Volatile Load (1) if (result == null) { // LoadEnterバリア synchronized (Singleton.class) { // MonitorEnter (2) // EnterLoadバリア // EnterStoreバリア result = instance; // Volatile Load (3) if (result == null) { // LoadStoreバリア // StoreStoreバリア (*) result = instance = new Singleton(); // Volatile Store (4) // StoreLoadバリア (*) } // LoadExitバリア // StoreExitバリア } // MonitorExit (5) // ExitEnterバリア } return result; }
対策4:内部クラスを使って遅延初期化する
最終奥義に近いのがこれ。遅延初期化ホルダーイデオムとか、オンデマンド初期化ホルダークラスイデオムとかいうらしい。
まず初期化の順番としては、スレッドが初めてgetInstance()を呼び出すと、InstanceHolder.instanceが参照されます。この時にnew Singleton()が実行され初期化されます。
根拠は「Java言語仕様3版」の「12.4.1 初期化が行われる時」 P281から引用。
Tによって宣言されているstaticフィールドが使用され,そのフィールドが定数変数(4.12.4)でない場合。
staticフィールドが定数でない場合は使用される時に初期化されるということです。
このフィールドはfinalフィールドなのでリオーダーが禁止され、初期化後にStoreStoreのメモリバリア命令が挿入されるので、メインメモリにその結果が反映されます。そしてスレッドから可視になります。初期化が完了すれば通常の変数として同期化不要で参照できます。
コードもシンプルで同期化も不要(つまり、ロックフリー)ということなので、これが一番よいかもしれません。
private static class InstanceHolder{ private static final Singleton instance = new Singleton(); } public static Singleton getInstance() { return InstanceHolder.instance; }
おまけ
もちろん、遅延初期化しないシングルトンもありですね。
Scalaのobjectも以下のようなコードだったと思う。
public class EagerSingleton { public static final EagerSingleton INSTANCE = new EagerSingleton(); // ... }
The JSR-133 Cookbookには以下のようにあります。
Static final initialization requires StoreStore barriers that are normally entailed in mechanics needed to obey Java class loading and initialization rules.
static finalの初期化は StoreStoreバリアを要求する.通常これは, Javaのクラスローディングと初期化ルールに従うのに必要とされるメカニズムに含まれている.
StoreStoreバリアが発生するので初期化安全性があります。
// 以下のメソッドを複数スレッドで実行していると想定 public void func() { EagerSingleton instance = EagerSingleton.INSTANCE; // StoreStoreバリア -- 後続のストアより前にストアが行われる // ... instance.doProcess(); }
enum版も単純でよいですね。
public enum EnumSingleton { INSTANCE; // ... }
このコードをjadると以下のような感じになります。
INSTANCEフィールドは上記と同じstatic finalフィールドなのでStoreStoreバリアが発生しますので初期化安全性がありますね。
シングルトンというだけであれば、本質的には上記の通常のstatic final版の方がよいと思います。
public final class EnumSingleton extends Enum { public static final EnumSingleton INSTANCE; private static final EnumSingleton $VALUES[]; static { INSTANCE = new EnumSingleton("INSTANCE", 0); $VALUES = (new EnumSingleton[] { INSTANCE }); } public static EnumSingleton[] values() { return (EnumSingleton[])$VALUES.clone(); } public static EnumSingleton valueOf(String name) { return (EnumSingleton)Enum.valueOf(test2/EnumSingleton, name); } private EnumSingleton(String s, int i) { super(s, i); } }
これら二つは、finalフィールドなので同期化不要でスレッドセーフです。
まとめ
まとめというほどではないですが、遅延初期化するなら対策4のホルダーが好きかな、遅延初期化しないならやっぱりstatic finalフィールドが最強。まぁこういうのはチーム内で考えて標準化するとよいと思います。
*1:Volataile Load前にLoadStoreが挿入されるのは意味としては理解が難しいです。いずれにしてもvolatileフィールドの読み書きの前後にはバリア命令が挿入されるということが理解できればよいかと思います。