現代的な高速リフレクション
本記事はJava Advent Calendar 2024の9日目です。
最近のJavaには、動的にメソッドやフィールドを操作するための方法がいくつか存在します。本記事では、それぞれの特徴や利点をベンチマークを利用しながら実行速度に焦点を絞って解説していきたいと思います。
リフレクションの対象はインスタント化、フィールドアクセス、メソッド呼び出し等多岐に渡りますが、今回は下記のようなgetメソッドを呼び出す例で比較します。
public class Example {
public String get() {
return "called";
}
}
1. Core Reflection
概要
みんな大好き、太古の昔から存在するリフレクションは、java.lang.reflect
パッケージを利用して、実行時にクラスやメンバー(メソッド、フィールド、コンストラクタ)の情報を取得し、それらを操作する仕組みです。ライブラリやフレームワークの汎用性を担保するために使用されることが多いと思います。
特徴
- 汎用性: クラス、メソッド、フィールド、コンストラクタ、アノテーションや型変数にアクセス可。
- 型安全性: 実行時の型チェックで、型安全性は全くない。
- パフォーマンス: セキュリティチェックが毎回実行されコストが高く、最適化されない。
- Native Image: 対応しやすい、NativeImage側も色々提供してくれているので半自動にしやすい。
使用例
public static void main(String[] args) throws Exception {
Class<?> clazz = Example.class;
Method method = clazz.getMethod("get");
Example instance = new Example();
String result = (String) method.invoke(instance);
System.out.println(result); // "called"
}
問題点
🐆「お前に足りないものは、それは————情熱、思想、理念、頭脳、気品、優雅さ、勤勉さ! そしてェ何よりもォ———— 速 さ が 足 り な い !!」
2. MethodHandle
概要
Java7で導入されたMethodHandle は、java.lang.invoke
パッケージで提供されるAPIで、メソッドやコンストラクタを効率的に呼び出すために使用されます。リフレクションに比べてパフォーマンスが高く、型安全性も気持ち向上しています。なお、Java18以降ではJEP416によりjava.lang.reflect
のリフレクションは内部でMethodHandle
等を使用するように変更されているので実質これ。
特徴
- 汎用性: メソッド、コンストラクタ、フィールド、または類似の低レベル操作。
- 型安全性: コンパイル時に型チェックを行う(後述)。型安全に呼び出せるわけではない。😵
-
柔軟性: メソッドチェーン(例:
bindTo
)による引数の動的操作等。 - パフォーマンス: セキュリティチェックは初回のみ、JITによる最適化が可能。
- Native Image: 対応しやすい、(実行時初期化ではない)最適化verなら自動認識。
使用例
public static void main(String[] args) throws Throwable {
MethodType methodType = MethodType.methodType(String.class);
MethodHandle handle = MethodHandles.lookup().findVirtual(Example.class, "get", methodType);
Example instance = new Example();
String result = (String) handle.invoke(instance);
System.out.println(result); // "called"
}
最適化
ただし、上記のコードではJITコンパイラによる最適化が行なわれないため、通常のリフレクションと似たりよったりな速度で実行されます。JITコンパイラはstatic final
なフィールドに定義されているMethodHandle
を最適化が可能であると判断し[1]、メソッドの直接呼び出しと遜色ない速度で実行することができます。以下がJITによる最適化が可能なコードです。
// 🤖 「・・・ヌルポ」
private MethodHandle SlowMH1;
// 🤖 「インスタンスゴトニカワルカモシレナイ、コイツハサイテキカシナイデオコウ」
private final MethodHandle SlowMH2;
// 🤖 「ナカミカワルカモシレナイ、コイツハサイテキカシナイデオコウ」
private static MethodHandle SlowMH3;
// 🤖 「ナカミガモウカワルコトハナイ、コイツハアンシンシテサイテキカデキル」
private static final MethodHandle FastMH;
static {
try {
MethodType methodType = MethodType.methodType(String.class);
FastMH = MethodHandles.lookup().findVirtual(Example.class, "get", methodType);
} catch (Exception e) {
throw new RuntimeException(e);
}
}
// 🤖 「パブリックスタティックヴォイドメイン」
public static void main(String[] args) throws Throwable {
Example instance = new Example();
String result = (String) FastMH.invoke(instance);
System.out.println(result); // "called"
}
コンパイル時の型チェック
しれっと書いていますが、重要な部分です。
String result = (String) FastMH.invoke(instance);
ぱっと見るとメソッドの呼び出し結果をキャストしてるだけに見えますが、MethodHandle
のinvoke系メソッドにおいては最適化のために必要な情報として利用され、生成するバイトコードに違いが生じます[2]。invokeメソッドの返り値に従ってObject
のまま返したりすると最適化が効かなくなり、invokeExactメソッドに至っては実行時エラーとなります。
GETSTATIC test/Sample.FastMH : Ljava/lang/invoke/MethodHandle;
ALOAD 1
INVOKEVIRTUAL java/lang/invoke/MethodHandle.invoke(Ltest/Example;)Ljava/lang/String;
ASTORE 2
INVOKEVIRTUALオペコードの部分で、Example
型を引数として受け取りString
型を返す呼び出しであると明示されていることが読み取れます。ですので、通常であれば存在するはずの可変長引数用の配列生成や返り値のキャストをするオペコードは生成されていません。
問題点
高速な実行を保証しようとすると、全てのMethodHandle
をstatic final
なフィールドで定義しておく必要があります。しかし、任意の型・メソッドに対して事前にフィールド定義しておくことは不可能です。予めわかっている必要な部分だけ定義することはできますが、それならリフレクションなど使わずに直接呼び出せばいいだけのこと。では、どうするか?🤔
3. LambdaMetafactory
概要
LambdaMetafactoryは、Java8で導入されたラムダ式を実装するためのAPIです。動的に関数型インターフェースを作成する際に利用されます。この機能を利用して対象のメソッド呼び出しを関数型インターフェイス(の実装)として動的に生成することでJITの恩恵を受けることができます。
特徴
- 汎用性: 関数型インターフェースに特化しており、汎用的な操作には不向き。
- 型安全性: 実行時に型チェック。関数自体は型安全に呼び出せるが、関数の導出は動的なので😵
- パフォーマンス: セキュリティチェックは初回のみ、JITによる最適化が可能。
- Native Image: 非対応、方法があったら教えて😢 try-catchでリフレクションにfallbackさせる。
使用例
public static void main(String[] args) throws Throwable {
MethodHandles.Lookup lookup = MethodHandles.lookup();
MethodType methodType = MethodType.methodType(String.class);
MethodHandle handle = lookup.findVirtual(Example.class, "get", methodType);
Function<Example, String> lambda = (Function<Example, String>) LambdaMetafactory
.metafactory(lookup, "apply", MethodType.methodType(Function.class), handle.type().generic(), handle, handle.type())
.dynamicInvoker()
.invokeExact();
Example instance = new Example();
String result = lambda.apply(instance);
System.out.println(result); // "called"
}
問題点
引数の数に合わせて対応する関数型を変更するので、任意のメソッドに対応させるとなるとConsumerXとFunctionXの関数インターフェイス群を用意してシグネイチャで場合わけをする必要があります。各種型への対応はMethodType#genericやMethodType#wrapで面倒を見てくれるので、Getter、Setterの呼び出しをするくらいの使い方なら、手間なく対応できるでしょう。
// setterの例
public class Example {
public void set(String value) {
}
}
MethodHandles.Lookup lookup = MethodHandles.lookup();
MethodType methodType = MethodType.methodType(void.class, String.class);
MethodHandle handle = lookup.findVirtual(Example.class, "set", methodType);
BiConsumer<Example, String> lambda = (BiConsumer<Example, String>) LambdaMetafactory
.metafactory(lookup, "accept", MethodType.methodType(BiConsumer.class), handle.type().generic().changeReturnType(void.class), handle, handle.type())
.dynamicInvoker()
.invokeExact();
ベンチマーク
通常の呼び出し方法(DirectCall)と比較すると、最適化が有効になったMethodHandleとLambdaMetafactoryはJITのおかげで非常に高速に実行されていることがわかります。
🐆「俺が遅い?俺がスロウリィ!?」
比較表
特徴 | Reflection | MethodHandle | LambdaMetafactory |
---|---|---|---|
用途 | 汎用的 | 低レベルの操作 | 関数型インターフェース経由で操作 |
パフォーマンス | 遅い | 高速 | 非常に高速 |
柔軟性 | 高い | 中程度 | 低い(関数型に縛られる) |
NativeImage | 可 | 可 | 不可 |
利用・抽象化しているライブラリ
以下に動的操作のために各種リフレクションを活用しているライブラリをいくつか挙げます。
Core Reflection
-
なんちゃらフレームワーク
利用者、むしろ使ってない場合はそこを特徴としてドヤる程度には、だいたい何かしら使っている。使わない場合はアノテーションプロセッサでなにかを生成させたり。 -
jOOR
抽象化、Fluent APIを提供している。
MethodHandle
-
Core Reflection
利用者、ただしJava18~(違)
探したけど、特におもしろそうなプロダクトは見つからなかった😢
LambdaMetafactory
-
Jackson Blackbird
利用者、JSON MapperのJacksonで各種Reflection処理を高速化させる後付モジュール。Afterburnerの後継。 -
Sinobu
利用者、Bean-likeなオブジェクトのプロパティ操作を高速化。高凝縮な便利機能詰め合わせ欲張りセット。 -
Lambda Factory
抽象化、ReadMeの実装に関する考察はとても有益。
まとめ
Javaで動的にメソッドやフィールドを操作する方法は用途や性能の観点からさまざまな選択肢があります。本記事では、特に リフレクション、MethodHandle、LambdaMetafactory の3つを取り上げ、それぞれの特徴や使用例、パフォーマンスの違いについて取り上げました。
リフレクションは最も汎用性が高い反面、パフォーマンス面では最適化が難しく、動的操作の頻度が高いコードには適していません。一方で、MethodHandleやLambdaMetafactoryは、適切に使用することで非常に高速に実行されるため、速さこそ有能なのが文化の基本法則な諸兄姉におかれましてはこちらを選ぶと色々楽しめると思います。
GraalVMのNative Imageなどを利用する場合、動的操作に対応する手間が必要なため、リフレクションやMethodHandleが比較的扱いやすい一方で、LambdaMetafactoryは未対応である点に注意が必要です。
-
正確には『定数として信頼できるうるもの』なので
static final
でなくとも定数として扱ってくれるパターンはあるが、どのみちフィールドの定義が必要になってくるので最終的な使用感に差異はない。 ↩︎ -
PolymorphicSignatureアノテーションが付けられたメソッドは、コンパイラによってシグネチャ・ポリモーフィズム・メソッドと呼ばれる特殊な扱いがされ、メソッド定義に関係なくシグネイチャが呼び出し元(CallSite)によって決定されます。 ↩︎
Discussion