JavaProgrammingシニアJava開発者

プラットフォームスレッドから仮想スレッドに移行する際の基本的な危険は、スレッドのピン留めを引き起こすモニター競合と同期ブロックに関してどこにありますか?

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

質問への回答

Project Loom の仮想スレッドは、ForkJoinPool から引き出されたキャリアスレッドの上に構築された継続として機能します。仮想スレッドが synchronized ブロックに遭遇したり、ネイティブコードを実行したりすると、基盤となるキャリアスレッドがピン留めされ、ブロッキングI/O操作中にスケジューラが仮想スレッドをアンマウントすることを防ぎます。これにより、同時実行度がキャリアプールのサイズ(通常はCPUコア数に等しい)に制限され、競合する仮想スレッドが固定キャリアプールを独占することで、負荷の下でスループットが崩壊する可能性があります。

生活からの状況

ある金融サービス会社は、従来のTomcat スレッドごとのリクエストモデル(500のプラットフォームスレッドに制限)から、仮想スレッドを使用したJettyへのレガシー注文処理ゲートウェイの移行を行い、50,000の同時WebSocket接続を処理できることを期待しました。展開直後、仮想スレッドを導入したにもかかわらず、レイテンシは数秒に達し、市場のオープンボラティリティ中にスループットはわずか800 TPSにとどまりました。スレッドダンプは、すべての24のキャリアスレッドが BLOCKED 状態で synchronized ブロック内に固まっていることを示しており、I/Oのために待機している数千の仮想スレッドは先に進むことができませんでした。

最初に検討された解決策は、-Djdk.virtualThreadScheduler.parallelism を使用して ForkJoinPool の並列性を1000に増やすことでした。これにより、ピン留めされたワークロードを吸収するためのキャリアスレッドが増加し、実質的に大きなプラットフォームスレッドプールの動作に戻ることができます。しかし、このアプローチは、過剰なOSリソースを消費し、仮想スレッドの仮想化によって約束されたメモリ効率の利点を無効にすることによって、根本的なアーキテクチャの欠陥を単にマスクするだけです。

2つ目の解決策は、共有レート制限キャッシュを保護するすべての synchronized ブロックを ReentrantLock を使用するようにリファクタリングすることでした。内在的なモニターとは異なり、ReentrantLock は仮想スレッドスケジューラと統合されており、競合やブロッキング操作中にキャリアをピン留めせずにアンマウントを許可します。このアプローチは仮想スレッドの軽量性を保つが、コードベース全体の監査とロックの中断セマンティクスの注意深い取り扱いを必要とします。

3つ目の解決策は、同時ハッシュマップキャッシュを ConcurrentHashMap の計算メソッドや楽観的読み取りのための StampedLock のような純粋なロックフリーのデータ構造に置き換えることを提案しました。これにより、多くの読み取りパスでのブロッキングが排除されますが、データベース接続チェックアウトシーケンスのように、相互排他が必要な状態のある外部リソースへの排他的アクセスを必要とするシナリオには対応できません。

チームは、プロファイリングによってピン留めのホットスポットとして特定された50の重要な synchronized セクションを ReentrantLock にターゲットとした移行を優先しました。この選択は、競合中にスケジューラが仮想スレッドをアンマウントできるようにすることで根本的な原因に直接対処し、基盤となるアプリケーションビジネスロジックを変更することなく、メモリフットプリントを増加させることなく、実現しました。

リファクタリングと再展開の後、システムは目標の50,000同時接続を達成し、安定した100ms未満のp99レイテンシを実現しました。キャリアスレッドプールは、CPUコアに一致する24のデフォルトサイズのままであり、仮想スレッドが本当のスケーラビリティを提供するのは、コードが内在的な同期を通じてキャリアをピン留めするのを避けるときだけであることを示しています。

// 前: キャリアスレッドをピン留めする synchronized (rateLimiter) { // ここでブロックされると仮想スレッドはアンマウントできない externalApi.call(); } // 後: アンマウントを許可する rateLimiter.lock(); try { // 仮想スレッドがアンマウントし、キャリアを解放 externalApi.call(); } finally { rateLimiter.unlock(); }

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

なぜピン留めが特にsynchronizedブロックやネイティブメソッドで発生し、ReentrantLockがアンマウントを許可するのでしょうか?

ピン留めは、JVMが内在的モニター (synchronized) をスレッドスタックベースのモニターレコードと、物理OSスレッドの実行コンテキストに本質的に結びついた C++ レベルのVM内部構造を使用して実装しているために発生します。仮想スレッドがsynchronizedブロックに入ると、JVMはモニターステートを破損させたり、ネイティブレベルでのハプンズビフォー保証を違反したりせずに別のキャリアに継続を安全に移行することができません。対照的に、ReentrantLockAbstractQueuedSynchronizer の上にJavaで純粋に実装されており、VarHandleLockSupport.park プリミティブを使用しており、仮想スレッドスケジューラが介入することで、ネイティブスレッド状態への依存なしにキャリア全体で安全にアンマウントおよび再マウントを可能にします。

キャリアスレッドのピン留めは、ForkJoinPoolのワークスティーリングとどのように相互作用して、潜在的な飢餓シナリオを作成するのか?

通常の操作では、ForkJoinPool はタスクがCPUバウンドまたはノンブロッキングであると仮定しています。ワーカースレッドがブロックすると、プールの並列性の限界まで追加のワーカーを生成またはアクティブ化することで補償します。しかし、ピン留めされた仮想スレッドは、自身のキャリアをブロックしたまま、プールの補償メカニズムに効果的に信号を送ることはできません。したがって、20の仮想スレッドが同時に20のキャリアをピン留めすると(例:synchronizedブロックに入る)、スケジューラに待機中の数千の準備ができた仮想スレッドを実行するためのキャリアは残りません。これにより、優先順位の逆転が生じ、利用可能なタスクがあるにもかかわらず、ブロックされていない作業が進むことができず、動的にプールサイズを縮小し、壊滅的になります。

スレッドローカル変数の積極的な使用は、仮想スレッド環境でキャリアスレッドのピン留めを引き起こす可能性がありますか?

ThreadLocal 変数は、仮想スレッドの実装がマウントおよびアンマウント操作中にキャリア間でスレッドローカルマップを移行するため、ピン留めを引き起こしません。しかし、候補者はしばしば、ThreadLocal が特定のメモリ管理の大惨事を引き起こすことを見落とします。数百万の短命の仮想スレッドがスレッドローカルに触れると、それぞれのキャリアスレッドは、これまでホストしてきた各仮想スレッドごとに ThreadLocalMap にエントリを蓄積します。これらのマップは、キー(仮想スレッド)が明示的に削除されたり、ガベージコレクションされたりしない限りクリーンアップされないため、長時間実行されるキャリアスレッドで無制限のメモリ成長が発生します。これは、ピン留めに関するものとは関係なく、仮想スレッドの大規模な展開に対して同様に致命的なメモリリークを構成し、適切なクリーンアップのために ScopedValue(JEP 446)への移行が必要です。