ClassLoaderの同期履歴は、スレッドセーフなクラスローディングを義務付ける元々のJVM仕様に遡りますが、当初はClassLoaderインスタンスモニタに対して粗いロックしか提供されていませんでした。Java 7以前、すべてのloadClass()呼び出しはthisで同期していたため、アプリケーションサーバーのようなマルチスレッド環境での同時クラスローディングにおいてグローバルボトルネックを作成していました。Java 7では、registerAsParallelCapable() APIが導入され、ローダーが細粒度のロックスキームを選択できるようになり、スループットが大幅に改善されました。
基本的な問題は、親子委譲の再帰的な性質と同期メソッドから生じます。子のClassLoaderが**loadClass()をオーバーライドして自分のインスタンスで同期すると、そのロックを保持したままparent.loadClass()**を呼び出し、親のロックを獲得します。OSGiバンドルのような双方向パッケージインポートを持つ複雑な階層や、循環的な可視性要件を持つプラグインアーキテクチャでは、これは古典的なロック順序のサイクルを形成します。ここで、スレッドAはChild-Aを保持し、Parentを待ち、スレッドBはParentを保持し、Child-Aを待ちます。
この解決策は、ローダーインスタンスからロードされる特定のクラス名に同期を移します。registerAsParallelCapable()がClassLoaderの静的初期化子で呼び出されると、JVMは並列処理可能なローダーのConcurrentHashMapを維持し、ロックをローダーオブジェクトではなく、クラス名のインターネット化された文字列に対して行います。これにより、同じローダー内の異なるスレッドによる異なるクラスの同時ロードが可能になります。ただし、これは新たな危険を引き起こします。もしLoader-Aがクラス名「X」でロックし、依存性のためにLoader-Bに委任し、同時にLoader-Bがクラス名「Y」にロックし、Loader-Aに「X」の委任を行うと、スレッドは異なるローダーネームスペースを横断する異なるクラス名での循環的待機に陥り、標準的なモニタ分析では見えないデッドロックが発生します。
高頻度取引プラットフォームは、各アルゴリズムJARが市場データクラスの共有親を参照するモジュラー戦略エンジンを実装しました。市場がオープンする際、500のスレッドが同時に戦略を起動し、親ローダーのモニタに大規模な競合を引き起こし、取引機会を逃しました。
解決策 1: デフォルトの同期
最初の実装は、継承されたsynchronized loadClassメソッドに依存していました。** happens-beforeの一貫性を保証しながら、このアプローチはすべてのクラスローディングを単一のモニタ経由で直列化しました。パフォーマンスプロファイリングでは、95%のスレッドが親ClassLoader**のロックを待ってブロックされており、重要なスタートアップウィンドウ中に有効スループットがシングルスレッドレベルに減少していることが明らかになりました。
解決策 2: 同期なしのカスタムローディング
開発者は同期を完全に排除しようとし、不変のJAR内容が冪等なローディングを保証したと仮定しました。これにより、同一の定義に対して同じローダー内に複数の異なるClassオブジェクトが存在し、LinkageErrorや「戦略は戦略にキャストできません」という暗号的なClassCastExceptionメッセージが発生しました。これは、競合するスレッドによって重複したクラス定義がロードされることによるものです。
解決策 3: 並列処理可能な登録
チームはカスタムClassLoaderサブクラスにregisterAsParallelCapable()を実装し、並列ロック機構を保持するためにloadClass()ではなくfindClass()を厳密にオーバーライドしました。これにより、親委譲チェーンを維持しながら異なるクラス名の同時解決が可能となりました。この解決策は、兄弟ローダー間の循環パッケージ依存関係を排除するために、プラグイン階層の再構成を必要としました。結果として、スタートアップレイテンシは120秒から8秒に短縮され、6ヶ月の本番取引中にClassLoaderデッドロックが検出されることはありませんでした。
なぜloadClass()をオーバーライドする代わりにfindClass()をオーバーライドすると、並列処理可能な最適化が静かに無効になるのか?
並列処理機構は、JDKによって提供されるloadClass(String name, boolean resolve)テンプレートメソッド内に細粒度のロックを埋め込んでいます。サブクラスがloadClass(String)をオーバーライドすると、クラスローダーの内部parallelLockMapを介して特定のクラス名のロックを取得する内部ロジックをバイパスしてしまいます。サブクラスは意図せずに無同期アクセスに戻り、重複したクラス定義の競合を引き起こすか、または手動でthisで同期する必要があり、グローバルボトルネックを再導入します。正しいパターンは、キャッシュチェックと親委譲のために**super.loadClass()に委譲し、カスタムバイト配列からクラスへの変換ロジックをfindClass()**に限定することです。これによって、すでに確立された名前特定のロックコンテキストの中で実行されます。
どうやってServiceLoaderパターンが並列処理可能なClassLoadersでもデッドロックを引き起こす可能性があるのか?
Parent ClassLoader内で動作しているServiceLoaderがChild-Aにあるサービス実装をインスタンス化しようとすると、暗黙的にChild-A.loadClass()が呼び出されます。その実装クラスが静的初期化(<clinit>)をトリガーし、親からユーティリティクラスをロードし、別のスレッドが異なるサービス実装をChild-Aからロードするために親ロックを保持して待っている場合、循環的待機が形成されます。スレッド1は「Logger」のための親のクラス名ロックを保持して「ServiceImpl」を待ち、スレッド2は最初のServiceLoader呼び出しによりChild-Aの「ServiceImpl」のロックを保持して親の「Logger」のロックを待ちます。この初期化中のクロスローダークラスローディングがデッドロックチェーンを生成し、標準的なスレッドダンプ分析ツールはClassLoaderインスタンスモニタではなく、内部の名前ベースのロックを監視するため、識別するのが困難です。
「defineClass window」レース条件とは何であり、なぜ並列処理能力がそれを防がないのか?
並列処理能力は、同じクラス名のloadClass操作が直列化されることを保証しますが、defineClass()自体は別個のネイティブ操作であり、レース条件に脆弱です。カスタムローダーが標準のfindLoadedClassチェックの外で外部キャッシュやバイトコード変換を実装する場合、例えば、loadClassに干渉するJavaエージェント内で、2つのスレッドが同時に「未ロード」と検証をパスし、同じバイナリ名のためにdefineClass(byte[], ...)を呼び出す可能性があります。2番目のスレッドはLinkageError: attempted duplicate class definitionを受け取ります。これは、SystemDictionaryのチェックと挿入がJVMレベルでアトミックであるのに対し、カスタムプレチェックとdefineClass呼び出しの間のウィンドウが並列処理可能な名前ロックによって保護されていないために発生します。