ConcurrentHashMapのcomputeIfAbsentメソッドは、テーブル全体をロックするのではなく、ハッシュビンレベルでの細粒度のロッキングを使用して、値の原子的でスレッドセーフな計算を提供します。重要な再入可能性の危険が発生するのは、このメソッドに提供されたmappingFunctionが実行中に同じマップインスタンス内の同じキーに再帰的にアクセスしようとする場合であり、潜在的な循環依存関係を生み出します。
Java 8では、この再帰的アクセスによってデッドロックが発生しました。なぜなら、実装が計算中に特定のハッシュビンをロックし、再帰呼び出しが現在のスレッドによって既に保持されている同じロックを取得しようとしていたからです。Java 9以降、実装は計算中にReservationNodeプレースホルダーをビンに挿入してそれを「進行中」としてマークすることによってこの再帰を検出します。同じスレッドが同じキーを探索しているときにこのReservationNodeに遭遇した場合、メソッドは「再帰的更新」というメッセージを持つIllegalStateExceptionをスローします。このことにより、デッドロックを回避し、無効な再帰について即時のフィードバックを提供します。
このフォールファストメカニズムは、スレッド飢餓やライバシーの問題を防ぎ、デッドロックが壊滅的になる可能性のあるForkJoinPool共通プールや他の実行者コンテキスト内での問題を避けます。しかし、これには、開発者がキー間の循環依存関係を避けるために計算ロジックを慎重に構造化する必要があり、しばしばドメインレイヤーでの明示的なサイクル検出を必要とします。
私たちは、金融商品に対するデリバティブ計算をキャッシュして冗長なモンテカルロシミュレーションを避ける高スループットのプライシングエンジンでこの危険に直面しました。キャッシュは**ConcurrentHashMap<String, CompletableFuture<BigDecimal>>**を利用し、computeIfAbsentを使って同一のオプションプライシングリクエストを重複しないようにし、市場データティックごとに正確に一度だけ計算されることを保証しました。このパターンは、複数の同時リクエスト間で高価な計算をシェアする必要がある非同期データロードシナリオで一般的です。
問題は、データモデリングエラーにより、同じキャッシュ内の他のデリバティブを無意識のうちに参照してしまった複雑なデリバティブを計算しているときに現れました。具体的には、Instrument AのプライシングフォーミュラがInstrument Bを基盤として参照し、一方Instrument Bのフォーミュラが予期せずInstrument Aを再度参照しており、循環依存関係を作り出していました。この結果、AのcomputeIfAbsent呼び出しが、値の初期化フェーズ中に同じスレッド内で別のAのcomputeIfAbsent呼び出しを引き起こしました。
私たちが最初に考えた解決策は、粗粒のsynchronizedブロックにキャッシュアクセスをラップして、計算中に並行修正の可能性を排除することでした。このアプローチはデッドロックのリスクを取り除くことができますが、全体のマップにわたってすべてのプライシング計算を直列化し、実質的にスループットを単一スレッドのHashMapのそれにまで減少させ、リアルタイムトレーディングに必要なパフォーマンス特性を損なうことになります。
第二のアプローチは、操作の前にsupplyAsync()を使用して事前計算されたCompletableFutureインスタンスを作成するためにputIfAbsentを使用することでした。これにより、計算中にロックを保持することを避けられますが、キャッシュ内にキーがすでに存在している場合でも高価なプライシング計算が積極的に始まるため、冗長な計算に大幅なCPUリソースを無駄に友になり、キャッシュの目的を失うことになります。
第三の解決策は、現在のスレッドの呼び出しスタック内で「現在計算中のキー」を含む**ThreadLocal<Set<String>>**を維持することによって明示的なサイクル検出を実装しました。computeIfAbsent操作を開始する前に、システムはこのセット内のターゲットキーをチェックし、循環参照の場合はドメイン例外をスローします。これにより、ConcurrentHashMapのロックフリー同時実行性が保存され、無効な金融モデルに関する意味のあるビジネスコンテキストが提供されました。
私たちは第三の解決策を選択しました。なぜなら、それは循環的な金融モデルという根本原因に対処したからです。単に症状をマスクするのではなく、ConcurrentHashMapの同時実行性能特性を完全に保持しつつ、明示的な検証は無効な循環依存関係を形成する特定の金融商品を示す明確な監査証跡を提供しました。これにより、データチームはクラッシュを回避するのではなく、ソースデータエラーを修正することができます。
この実装により、運用のIllegalStateExceptionによるクラッシュが排除され、冗長なプライシング計算が約40%削減され、トレーディングプラットフォームのサブミリ秒レイテンシ要件が維持されました。明示的なサイクル検出は、コード内で静かに処理するのではなく、ソースで誤った金融商品階層を修正することを強制し、データ品質も向上させました。
なぜConcurrentHashMapはnullキーと値を拒否し、HashMapはそれを許可するのか?
ConcurrentHashMapは、並行原子的操作内で内部のセンチネル値としてnullを使用し、「キーが存在しない」と「計算中」を区別します。computeIfAbsentやmergeのようなメソッドは、原子的な更新中の不在を明示的に示すためにこのセンチネルに依存し、レースコンディションを発生させる追加のルックアップを必要としません。そのため、getメソッドが存在しないキーとnullにマップされたキーの両方に対してnullを返すため、null値を許可すると、並行修正中にマップ内のキーが本当に存在するかを判断することが不可能になります。これにより、複合操作の原子性保証が破られます。
Java 8+のビンレベルのロッキングはJava 7のセグメントベースの同時実行性とどのように異なるのか?
Java 7では、各セグメントを独立したReentrantLockで保護された固定の16セグメントの配列を使用しており、ハードウェアの可用性にかかわらず最大書き込み同時実行性を16スレッドに制限していました。Java 8以降では、個々のハッシュビンレベルでの細粒度ロッキングに代わり、各バケットの最初のノードでsynchronizedブロックを使用し、競合がない書き込みと読み取りのためにロックフリーのCAS操作を組み合わせています。このアーキテクチャにより、何千ものスレッドが異なるビンに同時に書き込むことができ、競合を避けることが可能になります。一方、リサイズ操作は、volatileな次のテーブルポインタを使用した進行的転送を実行し、マイグレーション中に読み取りを続けることができます。
computeIfAbsentはputIfAbsentよりもいつ優先されるべきか、またどのようなロッキングの影響を考慮する必要があるか?
computeIfAbsentは、値の作成が高価であり、キーが存在しない場合にのみ原子的に行わなければならない場合に不可欠です。なぜなら、必要に応じて実行されるFunctionを受け入れるからです。しかし、実装中に関数の実行の間、実際にハッシュビン全体をロックするため、長時間実行される計算はそのビンにハッシュされるすべてのキーに対するアクセスを直列化することになり、パフォーマンスのボトルネックを生じる可能性があります。putIfAbsentは、操作の前に値を事前計算する必要があり、高価な作成がキーの存在にかかわらず行われますが、ロックは挿入チェックの間だけ保持されるため、値の作成が安価または冪等である場合には好まれます。