JavaProgrammingシニアJava開発者

**String.hashCode()**は、複数のスレッドによって同時に生成される**volatile**修飾子の内部ハッシュキャッシュを安全に省略する特定の原子性保証とは何ですか?

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

質問への回答

質問の歴史

JSR 133仕様(Java 5)以前、Javaメモリモデルには正式な発生前ルールが欠けており、無害なデータレースが危険でした。Stringは常にパフォーマンスに重要な不変クラスであり、HashMap操作で広く利用されてきました。初期のJDKバージョンでは、大きな文字列のハッシュを繰り返し再計算しないために遅延ハッシュキャッシュを導入しました。hashフィールドにvolatileを省略するという決定は、現代の並行性プリミティブより前の意図的な最適化であり、計算の冪等性とJava 5でJLSに追加された特定の原子性保証に依存していました。

問題

複数のスレッドが新たに作成されたStringhashCode()を同時に呼び出すと、全てのスレッドがhashフィールドの初期値0を観測する可能性があります。同期なしでは、これにより複数のスレッドが同時にハッシュ値を計算し、戻そうとするデータレースが生じます。課題は、どのスレッドも部分的に書き込まれた(破損した)ハッシュ値や不整合な状態を観測しないことを保証しつつ、各hashCode()呼び出し時にvolatile読み書きに関連するメモリバリアの過大なコストを回避することです。

解決策

解決策は、2つの基本的なJMMの特性に依存しています。まず、Java言語仕様(§17.7)は、32ビットのプリミティブ値(int)への書き込みが原子的であることを保証し、単語の破損を防ぎます。次に、Stringコンストラクタはそのfinal valueフィールドを通じて発生前の関係を確立し、バックアレイが参照を受け取った任意のスレッドに完全に見えることを保証します。ハッシュ計算はこの不変で安全に公開されたデータの純粋な関数であるため、キャッシュを埋める競争は無害です。スレッドが古い0を読み取った場合、それは単に同じ値を再計算します。キャッシュされた値を読み取った場合、それを使用します。原子的な書き込みは、値が完全に観測されるか、されないかのいずれかであり、決して破損することはありません。

public int hashCode() { int h = hash; // 非volatile読み取り:0またはキャッシュされた値が見える可能性 if (h == 0 && value.length > 0) { char val[] = value; for (int i = 0; i < value.length; i++) { h = 31 * h + val[i]; } hash = h; // 原子的な書き込み:32ビットの割り当ては分離できない } return h; }

現実の状況

私たちは、毎秒数百万のCSVレコードを処理する高スループットの取り込みサービスを設計していました。各レコードはConcurrentHashMapキャッシュのために複数のStringキーを生成しました。プロファイリングの結果、**hashCode()**計算が大きな文字列キーのためにCPU時間の15%を消費していることが明らかになりました。

解決策A:volatileハッシュフィールド。 私たちはカスタムStringラッパーでhashフィールドにvolatileを追加することを検討しました。利点にはすべてのコア間の即時の可視性と厳密な連続的整合性が含まれます。ただし、欠点は厳しく、JMHベンチマークは、各マップ操作のキャッシュ整合性トラフィックとメモリフェンスコストにより400%のスループット低下を示しました。

解決策B:synchronized hashCode()。 計算を同期させることをテストしました。利点は簡単さと絶対的な正確性でした。欠点は壊滅的な競合でした。32スレッドでは、スレッドがモニタの順番を待つため、待機時間は2ナノ秒から800ナノ秒に上昇しました。

解決策C:無害な競争(現在の実装)。 私たちは、非同期の冪等キャッシュを保持しました。利点は同期のオーバーヘッドがゼロであり、コア数に応じて完璧にスケーラブルでした。欠点は理論的なもので、スレッドが最初のアクセス中に競争した場合に時折冗長な計算が発生する可能性がありました。私たちは、ハッシュの再計算(キャッシュミス)のコストが、キャッシュ整合性プロトコル(volatile)や競合(synchronized)のコストと比較して無視できるものであったため、解決策Cを選びました。

結果: システムは、**hashCode()**が上位100のホットメソッドに現れず、1コアあたり毎秒250万回の操作を維持し、この不変データ構造のための無害なデータレースが正しいアーキテクチャのトレードオフであることを検証しました。

候補者が見落とすことが多い点

なぜvolatileが欠如しても、Stringを作成するスレッドとそのハッシュを計算するスレッド間の発生前の関係が損なわれないのですか?

発生前の関係は、実際にはハッシュフィールドではなく、Stringオブジェクト自体の安全な公開によって確立されます。Stringが構築されると、そのfinal valueフィールドはバックアレイの内容が参照を受け取った任意のスレッドに見えることを保証します。hashフィールドは単なるキャッシュであり、デフォルト値の0を観測することは単純に計算を引き起こす有効なプログラム状態です。JMMは不変のvalue配列が一貫していることを保証し、ハッシュはこの可視データから純粋に導出されるため、どのスレッドがそれを実行しても同じ結果が得られます。

volatileを使わずに64ビットのロングハッシュ値に同じ最適化を適用できますか?

できません。JMMは32ビットプリミティブ(intfloat)に対して原子性をすべてのアーキテクチャで保証するだけです。64ビットプリミティブ(longdouble)に関しては、指定が64ビットJVMや特定のアーキテクチャでvolatileまたは同期なしで単語の破損を許可します。理論的には、スレッドは1つの計算されたハッシュの上位32ビットと別の計算されたハッシュの下位32ビットを観測し、完全に間違った非ゼロのハッシュ値を生成し、HashMapバケットの配置を破損させる可能性があります。したがって、64ビットのハッシュをキャッシュするにはvolatileまたはAtomicLongが必要です。

これは、シングルトン初期化の壊れた「ダブルチェックロッキング」慣用句とはどのように異なりますか?

重要な違いは、安全な公開と冪等性にあります。壊れたダブルチェックロッキングでは、問題は、コンストラクタが完了していないオブジェクトへの非null参照を観測することにあります(参照の割り当てとコンストラクタの実行の再配置)。**String.hashCode()**では、Stringオブジェクト自体はすでに安全に公開され、完全に構築されており、hashフィールドは単に純粋なデータの遅延初期化キャッシュです。0(未初期化)を見ることは部分的な構築ではなく、有効な初期状態です。さらに、操作は冪等であり、同じ計算された値を複数のスレッドが書き込むと、1つのスレッドと同じ結果が得られますが、DCLは正確に1つのインスタンスの生成を必要とします。