再確認されたロック:賢いが壊れている

高く評価されているJavaスタイルの要素からJavaWorldのページ(Javaヒント67を参照)まで、多くの善意のあるJavaの達人は、ダブルチェックロック(DCL)イディオムの使用を推奨しています。問題は1つだけです。この巧妙に見えるイディオムは、機能しない可能性があります。

再確認されたロックは、コードに危険を及ぼす可能性があります。

今週の JavaWorldは、ダブルチェックされたロックイディオムの危険性に焦点を当てています。この一見無害に見えるショートカットがどのようにコードに大混乱をもたらす可能性があるかについてもっと読む:
  • 「警告!マルチプロセッサの世界でのスレッド化」、Allen Holub
  • ロックの再確認:賢いが壊れている」とブライアン・ゲッツ
  • ダブルチェックロックの詳細については、AllenHolubのプログラミング理論と実践のディスカッションにアクセスしてください。

DCLとは何ですか?

DCLイディオムは、レイジー初期化をサポートするように設計されています。レイジー初期化は、クラスが所有オブジェクトの初期化を実際に必要になるまで延期したときに発生します。

クラスSomeClass {プライベートリソースリソース= null; public Resource getResource(){if(resource == null)resource = new Resource(); リソースを返します。}}

なぜ初期化を延期したいのですか?おそらく、の作成Resourceはコストのかかる操作であり、のユーザーは特定の実行でSomeClass実際に呼び出すことはありませんgetResource()。その場合、Resource完全に作成することを回避できます。とにかく、構築時にSomeClassオブジェクトを作成する必要がなければ、オブジェクトをより速く作成できますResource。ユーザーが実際に結果を必要とするまで一部の初期化操作を遅らせると、プログラムの起動を高速化できます。

SomeClassマルチスレッドアプリケーションで使用しようとするとどうなりますか?次に、競合状態が発生します。2つのスレッドが同時にテストを実行して、resourceがnullかどうかを確認し、その結果、resource2回初期化する可能性があります。マルチスレッド環境では、であることを宣言getResource()する必要がありますsynchronized

残念ながら、同期されたメソッドは、通常の同期されていないメソッドよりもはるかに遅く(100倍も遅く)実行されます。遅延初期化の動機の1つは効率ですが、プログラムの起動を高速化するには、プログラムの起動後の実行時間を遅くする必要があるようです。それは大きなトレードオフのようには聞こえません。

DCLは、両方の長所を提供することを目的としています。DCLを使用すると、getResource()メソッドは次のようになります。

クラスSomeClass {プライベートリソースリソース= null; public Resource getResource(){if(resource == null){synchronized {if(resource == null)resource = new Resource(); }}リソースを返す; }}

への最初の呼び出しの後getResource()resourceはすでに初期化されています。これにより、最も一般的なコードパスでの同期ヒットが回避されます。DCLresourceは、同期ブロック内を2回チェックすることにより、競合状態を回避します。これにより、1つのスレッドのみが初期化を試みますresource。DCLは巧妙な最適化のように見えますが、機能しません。

Javaメモリモデルに対応

より正確には、DCLが機能することは保証されていません。その理由を理解するには、JVMとそれが実行されているコンピューター環境との関係を調べる必要があります。特に、Java言語仕様の第17章で定義されている、Bill Joy、Guy Steele、James Gosling、Gilad Bracha(Addison-Wesley、2000)によるJavaメモリモデル(JMM)を確認する必要があります。 Javaは、スレッドとメモリ間の相互作用を処理します。

他のほとんどの言語とは異なり、Javaは、すべてのJavaプラットフォームで保持されることが期待される正式なメモリモデルを通じて基盤となるハードウェアとの関係を定義し、Javaの「WriteOnce、RunAnywhere」の約束を可能にします。比較すると、CやC ++のような他の言語には正式なメモリモデルがありません。このような言語では、プログラムは、プログラムが実行されるハードウェアプラットフォームのメモリモデルを継承します。

同期(シングルスレッド)環境で実行している場合、プログラムのメモリとの相互作用は非常に単純であるか、少なくともそのように見えます。プログラムはアイテムをメモリ位置に格納し、次にそれらのメモリ位置が調べられたときにそれらがまだそこにあることを期待します。

実際には、真実はまったく異なりますが、コンパイラ、JVM、およびハードウェアによって維持される複雑な錯覚は、それを私たちから隠します。プログラムは、プログラムコードで指定された順序で順番に実行されると考えていますが、常に実行されるとは限りません。コンパイラー、プロセッサー、およびキャッシュは、計算結果に影響を与えない限り、プログラムとデータに関してあらゆる種類の自由を自由に利用できます。たとえば、コンパイラは、プログラムが提案する明白な解釈とは異なる順序で命令を生成し、変数をメモリではなくレジスタに格納できます。プロセッサは、命令を並列または順不同で実行する場合があります。キャッシュは、書き込みがメインメモリにコミットする順序を変える場合があります。 JMMは、これらのさまざまな並べ替えと最適化のすべてが受け入れられると述べています。環境が維持されている限りas-if-serialセマンティクス-つまり、命令が厳密にシーケンシャルな環境で実行された場合と同じ結果が得られる限り。

コンパイラ、プロセッサ、およびキャッシュは、より高いパフォーマンスを実現するために、プログラム操作のシーケンスを再配置します。近年、コンピューティングのパフォーマンスが大幅に向上しています。プロセッサのクロックレートの向上はパフォーマンスの向上に大きく貢献していますが、並列処理の向上(パイプラインおよびスーパースカラー実行ユニット、動的命令スケジューリングと投機的実行、高度なマルチレベルメモリキャッシュの形で)も大きな要因となっています。同時に、コンパイラーはプログラマーをこれらの複雑さから保護しなければならないため、コンパイラーを作成するタスクははるかに複雑になっています。

シングルスレッドプログラムを作成する場合、これらのさまざまな命令またはメモリ操作の並べ替えの影響を確認することはできません。ただし、マルチスレッドプログラムの場合、状況はまったく異なります。あるスレッドは、別のスレッドが書き込んだメモリ位置を読み取ることができます。スレッドAが特定の順序でいくつかの変数を変更すると、同期がない場合、スレッドBはそれらを同じ順序で表示しないか、まったく表示しない可能性があります。これは、コンパイラが命令を並べ替えるか、変数をレジスタに一時的に格納し、後でメモリに書き込んだために発生する可能性があります。または、プロセッサが命令を並列に、または指定されたコンパイラとは異なる順序で実行したため。または、命令がメモリの異なる領域にあったため、キャッシュは、対応するメインメモリの場所を、それらが書き込まれた順序とは異なる順序で更新しました。状況がどうであれ、同期を使用してスレッドがメモリの一貫したビューを持つことを明示的に保証しない限り、マルチスレッドプログラムは本質的に予測可能性が低くなります。

同期とはどういう意味ですか?

Javaは、各スレッドを、独自のローカルメモリを備えた独自のプロセッサで実行されているかのように扱い、それぞれが共有メインメモリと通信して同期します。シングルプロセッサシステムでも、メモリキャッシュの影響と、変数を格納するためのプロセッサレジスタの使用により、このモデルは理にかなっています。スレッドがローカルメモリ内の場所を変更すると、その変更は最終的にメインメモリにも表示されます。JMMは、JVMがローカルメモリとメインメモリ間でデータを転送する必要がある場合のルールを定義します。 Javaアーキテクトは、過度に制限されたメモリモデルがプログラムのパフォーマンスを著しく損なうことに気づきました。彼らは、スレッドが予測可能な方法で相互作用することを可能にする保証を提供しながら、プログラムが最新のコンピューターハードウェア上でうまく機能することを可能にするメモリモデルを作成しようとしました。

スレッド間の相互作用を予測どおりにレンダリングするためのJavaの主要なツールはsynchronizedキーワードです。多くのプログラマーは、一度に複数のスレッドによるクリティカルセクションの実行を防ぐためにsynchronized、相互排除セマフォ(ミューテックス)を適用するという観点から厳密に考えています。残念ながら、その直感はsynchronized意味を完全には説明していません。

synchronizeddoのセマンティクスには、セマフォのステータスに基づく実行の相互排除が含まれていますが、同期スレッドとメインメモリとの相互作用に関するルールも含まれています。特に、ロックの取得または解放は、メモリバリア(スレッドのローカルメモリとメインメモリ間の強制同期)をトリガーします。 (Alphaなどの一部のプロセッサには、メモリバリアを実行するための明示的なマシン命令があります。)スレッドがsynchronizedブロックを出ると、書き込みバリアを実行します。スレッドは、そのブロックで変更された変数をメインメモリにフラッシュしてから解放する必要があります。ロック。同様に、synchronized ブロックでは、読み取りバリアを実行します。これは、ローカルメモリが無効化されているかのようであり、ブロック内で参照される変数をメインメモリからフェッチする必要があります。

同期を適切に使用すると、あるスレッドが別のスレッドの影響を予測可能な方法で確認できることが保証されます。スレッドAとBが同じオブジェクトで同期する場合にのみ、JMMは、スレッドBがスレッドAによって行われた変更を認識し、synchronizedブロック内のスレッドAによって行われた変更がスレッドBにアトミックに表示されることを保証します(ブロック全体が実行されるか、さらに、JMMはsynchronized、同じオブジェクトで同期するブロックが、プログラムで実行するのと同じ順序で実行されるように見えることを保証します。

では、DCLの何が問題になっていますか?

DCL relies on an unsynchronized use of the resource field. That appears to be harmless, but it is not. To see why, imagine that thread A is inside the synchronized block, executing the statement resource = new Resource(); while thread B is just entering getResource(). Consider the effect on memory of this initialization. Memory for the new Resource object will be allocated; the constructor for Resource will be called, initializing the member fields of the new object; and the field resource of SomeClass will be assigned a reference to the newly created object.

However, since thread B is not executing inside a synchronized block, it may see these memory operations in a different order than the one thread A executes. It could be the case that B sees these events in the following order (and the compiler is also free to reorder the instructions like this): allocate memory, assign reference to resource, call constructor. Suppose thread B comes along after the memory has been allocated and the resource field is set, but before the constructor is called. It sees that resource is not null, skips the synchronized block, and returns a reference to a partially constructed Resource! Needless to say, the result is neither expected nor desired.

When presented with this example, many people are skeptical at first. Many highly intelligent programmers have tried to fix DCL so that it does work, but none of these supposedly fixed versions work either. It should be noted that DCL might, in fact, work on some versions of some JVMs -- as few JVMs actually implement the JMM properly. However, you don't want the correctness of your programs to rely on implementation details -- especially errors -- specific to the particular version of the particular JVM you use.

Other concurrency hazards are embedded in DCL -- and in any unsynchronized reference to memory written by another thread, even harmless-looking reads. Suppose thread A has completed initializing the Resource and exits the synchronized block as thread B enters getResource(). Now the Resource is fully initialized, and thread A flushes its local memory out to main memory. The resource's fields may reference other objects stored in memory through its member fields, which will also be flushed out. While thread B may see a valid reference to the newly created Resource, because it didn't perform a read barrier, it could still see stale values of resource's member fields.

Volatile doesn't mean what you think, either

A commonly suggested nonfix is to declare the resource field of SomeClass as volatile. However, while the JMM prevents writes to volatile variables from being reordered with respect to one another and ensures that they are flushed to main memory immediately, it still permits reads and writes of volatile variables to be reordered with respect to nonvolatile reads and writes. That means -- unless all Resource fields are volatile as well -- thread B can still perceive the constructor's effect as happening after resource is set to reference the newly created Resource.

Alternatives to DCL

DCLイディオムを修正する最も効果的な方法は、それを回避することです。もちろん、これを回避する最も簡単な方法は、同期を使用することです。あるスレッドによって書き込まれた変数が別のスレッドによって読み取られるときは常に、同期を使用して、変更が他のスレッドに予測可能な方法で表示されることを保証する必要があります。

DCLの問題を回避するための別のオプションは、遅延初期化を削除し、代わりに熱心な初期化を使用することです。resource最初に使用されるまでの初期化を遅らせるのではなく、構築時に初期化します。クラスのClassオブジェクトで同期するクラスローダーは、クラスの初期化時に静的初期化ブロックを実行します。つまり、静的初期化子の効果は、クラスがロードされるとすぐにすべてのスレッドに自動的に表示されます。