JavaProgrammingシニアJavaバックエンド開発者

なぜその後のJavaメモリモデルの改訂は、ダブルチェックロッキングのイディオムを保証するためにvolatileセマンティクスを義務付けたのか?

Hintsage AIアシスタントで面接を突破

質問への回答

歴史

Java 5以前は、JavaメモリモデルJMM)が弱いメモリ可視性の保証を持っており、多くの人気のある並行性イディオムが安全ではありませんでした。ダブルチェックロッキングパターンは、遅延初期化のための性能最適化として1990年代後半に登場しましたが、命令の再順序化に関する致命的な欠陥がありました。JSR-133は、可視性の問題を解決するために必要な取得-解放メモリ順序を提供するために、2004年にvolatileキーワードのセマンティクスを再定義しました。

問題

volatileがない場合、JVMおよび基盤となるCPUアーキテクチャは、変数への参照の割り当てがコンストラクタの実行完了前に発生するように命令を再順序化することを許可されます。これにより、他のスレッドがデフォルトまたは未初期化の値を持つオブジェクトへの非null参照を観察できるウィンドウが生じ、予測不可能な動作やNullPointerExceptionを引き起こします。この並行性の危険は、特定のタイミング条件とハードウェアメモリモデルの下でのみ現れるため、テスト中に再現するのが難しいです。

解決策

インスタンスフィールドをvolatileとして宣言することで、コンストラクタ内の書き込みと他のスレッドによるその後の読み取りの間に発生する関係を確立するメモリバリアが挿入されます。これにより、コンパイラとプロセッサは、コンストラクタ内の前の書き込みとvolatileフィールドへの書き込みを再順序化できなくなり、オブジェクトが完全に構築される前にその参照が可視化されることを保証します。このパターンは、初期化後にロックなしで参照を確認できるようにし、スレッドセーフで高性能を提供します。

public class ConnectionPool { private static volatile ConnectionPool instance; private ConnectionPool() { // 重い初期化 } public static ConnectionPool getInstance() { if (instance == null) { synchronized (ConnectionPool.class) { if (instance == null) { instance = new ConnectionPool(); } } } return instance; } }

実生活の状況

高スループットのマイクロサービスは、PostgreSQLクラスターへのJDBC接続を管理するためにシングルトンConnectionPoolを必要としました。ピークトラフィック時に、サービスが初めて起動したとき、何千ものスレッドが同時にgetInstance()を呼び出し、ロック競合を最小限に抑えたスレッドセーフな初期化戦略を必要としました。初期化シーケンスには、TCPソケットの確立、直接バイトバッファの割り当て、およびスキーマ検証クエリの実行が含まれ、自己スケーリングシナリオでは急速なインスタンス化が非常に高価でした。

急速な初期化

急速な初期化は、静的初期化子ブロックでプールを作成することを含みました。このアプローチは、クラスロードメカニクスによってスレッドセーフであることを保証し、synchronizedブロックの必要性を完全に排除しました。しかし、接続の確立にはTCPハンドシェイクと資格情報交換に3秒がかかり、自己スケーリングイベント中のコールドスタート時間のサービスレベル合意を違反しました。

同期メソッド

同期メソッドは、getInstance()メソッドをsynchronizedキーワードでラップしました。これにより、すべてのアクセスが直列化されてレースコンディションが修正されましたが、負荷の下で著しい性能低下を引き起こしました。プロファイリングでは、初期化後に、完全に構築されたプールの不変性にもかかわらず、スレッドが監視ロックを取得するのに無駄なサイクルを費やし、呼び出しごとに約18ミリ秒の待機時間が追加されることが明らかになりました。

volatileによるダブルチェックロッキング

volatileによるダブルチェックロッキングが最適なアプローチとして選択されました。このソリューションは、nullをチェックするための非同期パスを使用し、その後、重要なセクションのためのsynchronizedブロックが続き、複数のインスタンス化を防ぐために内部で二度目のnullチェックが行われます。volatile修飾子により、完全に初期化されたプールの状態が公開時にすべてのCPUコアで即座に可視化され、起動後のロックオーバーヘッドゼロの遅延初期化が実現されました。

選択された解決策は、ブロッキングなしでの成功した遅延初期化を実現し、サービスが初期プール作成後に1秒あたり50,000リクエストを処理し、応答時間がサブミリ秒でした。実装により、起動時のレース条件を排除し、定常状態の操作中にロックなしでアクセスできるようになり、高並行性シナリオで以前に発生したNullPointerExceptionインスタンスを防ぎました。モニタリングにより、JVMがシングルトンが確立された後に明示的な同期なしで64コア全体のメモリ可視性を正しく処理することが確認されました。

候補者が見逃しがちなこと

なぜダブルチェックロッキングパターンには、一度の同期チェックではなく、2つの異なるnullチェックが必要なのか?

最初のチェックは、インスタンスが既に存在する場合の一般的なケースのために、synchronizedブロックの外で動作し、高速でロックフリーのパスを提供します。synchronizedブロック内の二度目のチェックは、インスタンスが未初期化のままの場合、複数のスレッドが同時に最初のnullチェックを通過できるため、重要です。この二度目の確認がなければ、各スレッドは順番にロックを取得して別々のインスタンスを作成し、シングルトンの特性に違反します。内部のチェックにより、重要なセクションに入る最初のスレッドだけが構築を実行し、後続のスレッドはすでに初期化されたインスタンスを発見し、作成をスキップします。

Javaメモリモデルは、volatileの書き込みと同期ブロックの退出の可視性の保証をどのように区別しますか?

両方の構造は発生前の関係を確立しますが、異なる粒度と性能特性で動作します。synchronizedブロックの退出は、スレッドの作業メモリ内のすべての変更された変数をメインメモリにフラッシュし、グローバルメモリバリアとして機能します。対照的に、volatileの書き込みは、特定の変数の周囲の命令との再順序化を防ぎ、その書き込みが即座に可視化されることを保証します。Java 5以前は、volatileにはこれらの保証がなかったため、安全な公開には不十分でした。現代のJMMは、volatileの書き込みを**C++**の解放操作と同様に扱い、読み取りを取得操作として扱い、完全なモニターロッキングのコストなしに狙った可視性を提供します。

不変オブジェクトは、ダブルチェックロッキングパターンでのvolatileの必要性を排除できますか?

いいえ、finalフィールドはコンストラクタが完了した後にのみ不変性を保証し、参照自体の公開中にはそうではありません。volatileがないと、命令の再順序化により、参照がコンストラクタの実行が完了する前にメインメモリに書き込まれる可能性があり、別のスレッドが部分的に構築されたオブジェクトへの非null参照を観察できるようになります。finalフィールドは構築後に値が変更できないことを保証しますが、参照が早期に逃がされた場合にデフォルトまたは未初期化の値の可視性を防ぐことはできません。安全な公開には、構築と可視性の間の発生前の関係を確保するために、volatileまたはsynchronizedが必要です。オブジェクトの内部の不変性にかかわらず。