質問の背景
Java 7でのJSR 292によるinvokedynamicの導入は、JVM上で動的言語実装をサポートするためのMethodHandle APIをもたらしました。課題は、MethodHandle.invokeが引数タイプと戻り値タイプの任意の組み合わせを受け入れる必要があるため、数千のオーバーロードを宣言せずに実装を行うことでした。JVMの設計者たちは、java.lang.invokeパッケージ内の**@PolymorphicSignature**アノテーションによって内部的にマークされたポリモーフィックシグネチャメソッドの概念を導入することでこれを解決しました。
問題点
標準のJavaメソッド呼び出しは、コンパイラーがメソッドの宣言されたシグネチャと正確に一致する特定のメソッド記述子を参照するinvokevirtual(または類似の)命令を生成する必要があります。もしMethodHandle.invokeが**Object...**引数を受け取ると宣言されている場合、すべての呼び出しサイトはボクシングと配列の割り当てを必要とし、パフォーマンス目標を达成できなくなります。逆に、すべての可能なシグネチャの組み合わせに対してオーバーロードを宣言することは不可能であり、Classファイルが無限に膨張します。
解決策
JVMは、@PolymorphicSignatureで注釈されたメソッドを特別に扱います。コンパイラーがそのようなメソッドへの呼び出しに出会うと、宣言されたシグネチャを無視し、呼び出しサイトでの引数と戻り値の型の消去型と正確に一致するメソッド記述子を持つinvokevirtual命令を生成します。これにより、MethodHandle.invokeExactはソースコードでは**(Object)Objectとして受け入れるように見えますが、特定の呼び出しサイトでは(String)int**にコンパイルされます。JVMは、この呼び出しをターゲットメソッドのエントリーポイントに直接リンクし、アダプターのオーバーヘッドを排除します。
import java.lang.invoke.MethodHandle; import java.lang.invoke.MethodHandles; import java.lang.invoke.MethodType; public class PolymorphicExample { public static void main(String[] args) throws Throwable { MethodHandle handle = MethodHandles.lookup() .findVirtual(String.class, "length", MethodType.methodType(int.class)); // コンパイラーはメソッド記述子 (String)int を持つinvokevirtualを生成します // invokeExactはバイトコードでは (Object)Object として宣言されていますが int result = (int) handle.invokeExact("hello"); System.out.println(result); // 出力: 5 } }
問題の説明
金融ティックデータのための高スループットイベント処理フレームワークを構築する際、私たちは入ってくるメッセージを登録されたハンドラーにディスパッチする必要がありましたが、その際、リフレクションのような柔軟性を持ちながらゼロアロケーションのオーバーヘッドが求められました。各ハンドラーメソッドは異なるシグネチャを持ち—いくつかはlongタイムスタンプを受け入れ、他はBigDecimalの価格を受け入れる—ため、ボクシングなしで一般的なディスパッチを行うのは困難でした。
考えられた異なる解決策
ダイナミックバイトコード生成は、ASMまたはByteBuddyを使用して登録時に各ハンドラーシグネチャのためのプロキシクラスを生成することを含んでいました。このアプローチはウォームアップ後に近いネイティブパフォーマンスを提供しましたが、かなりのMetaspaceを消費し、クラスの読み込みやJITコンパイル中にアプリケーションの起動遅延を数秒増加させました。また、生成されたコードのデバッグというメンテナンスの複雑さを加えました。
メソッドハンドルを用いたリフレクションでは、標準のMethod.invokeに続いてunreflectを使用してMethodHandleを取得しました。これは実装が簡単でしたが、プリミティブ引数のボクシングコストがかかり、リフレクティブレイヤーを通じたHotSpotのインライン化を妨げました。パフォーマンステストでは、直接呼び出しと比較して10〜15倍遅いディスパッチが示され、レイテンシー要件を違反しました。
ポリモーフィックシグネチャの活用は、invokeExactを呼び出す前に引数を正確な期待される型にキャストする必要がありました。これにより、コンパイラーは各呼び出しサイトのシグネチャ特定のinvokevirtual命令を生成でき、MethodHandleを型付けされたファンクションポインタとして効果的に扱うことができました。トレードオフは、コンパイル時の型の厳密性であり、ハンドラシグネチャを登録時に検証する必要があり、シグネチャが一致しない場合はコードがコンパイルされませんでした。
選択された解決策とその理由
私たちは、ポリモーフィックシグネチャアプローチと登録時の検証レイヤーの組み合わせを選択しました。LambdaMetafactoryとinvokedynamicを使用して正確なMethodHandleシグネチャと一致する軽量のアダプターラムダを生成することにより、型安全を維持しながら直接呼び出しパフォーマンスを達成しました。JVMは、実際のハンドラーメソッドへのMethodHandleを介してインライン化でき、ディスパッチのオーバーヘッドを完全に排除しました。
結果
このシステムは、サブマイクロ秒のレイテンシーで毎秒250万イベントを処理し、手書きのディスパッチコードのパフォーマンスに匹敵しました。リフレクションベースのプロトタイプと比較してGCの負荷は98%減少し、プリミティブ引数が呼び出しパス中にボクシングを必要としなくなりました。この解決策は、型エラーが実行時ではなくコンパイル時にキャッチされるため、保守可能なままでした。
なぜMethodHandle.invoke()は型変換を許可し、invokeExact()は厳密なシグネチャの一致を必要とするのか、両方がポリモーフィックシグネチャを持っているにもかかわらず?
両方のメソッドには**@PolymorphicSignatureアノテーションが付いていますが、invokeExactはJVMレベルで厳密なシグネチャチェックを行います。コンパイラーがinvokeExactのためのinvokevirtual命令を生成する際には、呼び出しサイトでの正確な消去型を使用します。JVMは、これらの型がターゲットMethodTypeと正確に一致するかどうかを検証します。対照的に、invoke(Exactなし)は、呼び出しサイトの型をターゲット型に適応させるためのロジックを含んでおり、MethodHandle.asTypeアダプターを使用してボクシング、アンボクシング、およびプリミティブ変換を行います。この適応は呼び出しサイトではなくMethodHandle**実装内で発生するため、invokeはより柔軟ですが、アダプターチェーンオーバーヘッドのために遅くなる可能性があります。
ポリモーフィックシグネチャメソッドが任意のメソッド記述子を許可する場合、JVMは型安全違反をどのように防ぐのか?
JVMは、ソースレベルで型安全を強制するためにJavaコンパイラーを頼りとしています。@PolymorphicSignatureは、MethodHandleやVarHandleのようなjava.baseモジュールのクラスに制限されているため、ユーザーコードは新たなポリモーフィックメソッドを宣言することができません。コンパイラーは、呼び出しサイトで期待されるシグネチャに対して引数の型を確認できる場合にのみポリモーフィック呼び出しを許可します。invokeExactのために、コンパイラーは、生成された記述子がプログラマーが意図したものと一致することを保証するために暗黙のキャストを挿入します。JVMは、コンパイラーがこの検証を行ったと信頼しているため、呼び出し中のランタイム記述子チェックをスキップし、コンパイル時制約を通じて安全性を維持しつつゼロオーバーヘッドを達成します。
なぜポリモーフィックシグネチャメソッドはスタックトレースやデバッグではObject型に消去されているように見えるが、特定のプリミティブ型で実行されるのか?
javacコンパイラーは、これらのメソッドのclassファイルに**@PolymorphicSignature属性を出力します。JVMがそのようなメソッドへの呼び出しを解決するときは、呼び出しサイトの定数プールエントリから記述子を置き換えます。これは、実際のバイトコード実行が特定の型(int、longなど)を使用することを意味しますが、Classオブジェクトのメソッドのメタデータは反射の目的で宣言されたシグネチャ(通常は(Object...)Object**)を保持します。したがって、スタックトレースは消去された形を見ることになるのです。なぜなら、Throwable.fillInStackTraceがメソッドのメタデータからの記号的記述子を使用するためであり、実際の呼び出し中に使用される動的記述子ではなくなります。この違いは、デバッガーで正確なパラメータ型を見たいと期待する開発者を混乱させます。