すべてのJavaプログラマは、明示的または暗黙的にリフレクションを使用してメソッドを呼び出します。 自分でやらなかったとしても、使用するライブラリまたはフレームワークはおそらくあなたのためにそれをしているでしょう。 この呼び出しが内部でどのように配置され、どのくらい高速であるかを見てみましょう。 OpenJDK 8で最新の更新を確認します。
Method.invokeメソッド自体で調査を開始する必要があります 。 そこで3つのことが行われます。
- メソッドへのアクセス権がチェックされます。
-
MethodAccessor
は、まだ作成されていない場合(リフレクションによってこのメソッドがまだ呼び出されていない場合)に作成され、記憶されます。 -
MethodAccessor.invoke
がMethodAccessor.invoke
ます。
アクセス制御は2つの部分で構成されています。 簡単なチェックにより、メソッドとそれを含むクラスの両方にpublic
修飾子があることが確認されます。 そうでない場合は、呼び出し元のクラスがこのメソッドにアクセスできることを確認します。 呼び出し元のクラスを見つけるには、プライベートメソッドReflection.getCallerClass()
ます。 ちなみに、一部の人々は自分のコードでそれを使用するのが好きです。 Java 9では、公開のStack-Walking APIが表示され、それに切り替えることは非常に合理的です。
事前にmethod.setAccessible(true)
呼び出すことにより、アクセスチェックをキャンセルできることが知られています。 このセッターは、チェックを無視するようにoverride
フラグを設定しoverride
。 メソッドがパブリックであることを知っていても、 setAccessible(true)
を設定すると、検証にかかる時間が少し節約されます。
いくつの異なるシナリオが時間を浪費するかを見てみましょう。 パブリックメソッドと非パブリックメソッドを持つ単純なクラスを作成しましょう。
public static class Person { private String name; Person(String name) { this.name = name; } public String getName1() { return name; } protected String getName2() { return name; } }
2つのフラグ( accessible
およびnonpublic
パラメーター化されたJMHテストを記述します。 これが準備になります。
Method method; Person p; @Setup public void setup() throws Exception { method = Person.class.getDeclaredMethod(nonpublic ? "getName2" : "getName1"); method.setAccessible(accessible); p = new Person(String.valueOf(Math.random())); }
そして、ベンチマーク自体:
@Benchmark public String reflect() throws Exception { return (String) method.invoke(p); }
私はこれらの結果を見ます(3つのフォーク、5x500msのウォームアップ、10x500msの測定):
(アクセス可能) | (非公開) | 時間 |
---|---|---|
本当 | 本当 | 5.062±0.056 ns / op |
本当 | 偽 | 5.042±0.032 ns / op |
偽 | 本当 | 6.078±0.039 ns / op |
偽 | 偽 | 5.835±0.028 ns / op |
実際、 setAccessible(true)
実行されると、最速になります。 この場合、メソッドがパブリックであるかどうかに違いはありません。 setAccessible(false)
場合、両方のテストが遅くなり、非パブリックメソッドはパブリックメソッドよりも若干遅くなります。 しかし、その差はより大きくなると予想していました。 ここでは、 Reflection.getCallerClass()
がJITコンパイラの組み込み関数であることが主に役立ちます。ほとんどの場合、コンパイル中に定数に置き換えられます。 getCallerClass()
が返されることを意味し、知っています。 さらに、チェックは本質的には、呼び出されたクラスと呼び出し元クラスのパッケージを比較することになります。 パッケージが異なる場合、クラス階層はまだチェックされます。
次に何が起こりますか? 次に、 MethodAccessor
オブジェクトを作成する必要があります。 ところで、 Person.class.getMethod("getName")
は常にMethod
オブジェクトの新しいインスタンスを返すという事実にもかかわらず、 Person.class.getMethod("getName")
内で使用されるMethodAccessor
はルートフィールドを介して再利用されます。 ただし、 getMethod
自体getMethod
呼び出しよりも大幅に遅いため、メソッドを複数回呼び出す予定がある場合は、 Method
オブジェクトを保存することをお勧めします。
MethodAccessor
はReflectionFactoryによって作成されます。 ここに、グローバルJVM設定によって制御される2つのシナリオがあります。
- オプション
-Dsun.reflect.noInflation=true
れている場合(デフォルトではオフ)、ヘルパークラスがすぐに生成され、ターゲットメソッドが実行されます。 - それ以外の場合、
NativeMethodAccessorImpl
が配置されるDelegatingMethodAccessorImpl
ラッパーが作成されます。 次に、このメソッドが何回呼び出されたかを検討します。 呼び出しの数が-Dsun.reflect.inflationThreshold
指定されたしきい値(デフォルトは15)を超えると、アクセサーは「肥大化」します。最初のシナリオのように、補助クラスが生成されます。 しきい値に達していない場合、呼び出しは正直にJNIを通過します。 C ++側の実装は簡単ですが 、JNIのオーバーヘッドは非常に高くなります。
-Dsun.reflect.noInflation=true
を有効にし、JNIのみを使用する場合(このために大きなしきい値-Dsun.reflect.inflationThreshold=100000000
を設定する)、テストで何が起こるかを見てみましょう。
(アクセス可能) | (非公開) | デフォルト | インフレ率 | JNIのみ |
---|---|---|---|---|
本当 | 本当 | 5,062±0,056 | 4.935±0.375 | 195,960±1,873 |
本当 | 偽 | 5.042±0.032 | 4.914±0.329 | 194,722±1,151 |
偽 | 本当 | 6.078±0.039 | 5.638±0.050 | 196.196±0.910 |
偽 | 偽 | 5.835±0.028 | 5.520±0.042 | 194.626±0.918 |
以降、すべての結果は操作あたりナノ秒になります。 予想どおり、JNIは大幅に遅いため、このようなモードをオンにすることは不当です。 不思議なことに、noInflationモードは少し高速でした。 これは、 DelegatingMethodAccessorImpl
がないため、1つの間接アドレス指定の必要性がなくなります。 デフォルトでは、呼び出しはMethod → DelegatingMethodAccessorImpl → GeneratedMethodAccessorXYZ
を経由し、このオプションを使用すると、チェーンはMethod → GeneratedMethodAccessorXYZ
短縮されます。 Method → DelegatingMethodAccessorImpl
の呼び出しMethod → DelegatingMethodAccessorImpl
単相であり、仮想化するのは簡単ですが、間接的なアドレス指定は依然として残っています。
仮想化といえば。 実際のプログラムでは状況が表示されないため、ベンチマークが悪いことに注意してください。 ベンチマークでは、リフレクションを介して1つのメソッドのみを呼び出します。つまり、生成されたアクセサーは1つだけであり、これも簡単に仮想化され、インラインです。 実際のアプリケーションでは、これは起こりません。多くのアクセサーがあります。 この状況をシミュレートするために、 setup
メソッドでオプションでタイププロファイルをポイズニングしましょう。
if(polymorph) { Method method2 = Person.class.getMethod("toString"); Method method3 = Person.class.getMethod("hashCode"); for(int i=0; i<3000; i++) { method2.invoke(p); method3.invoke(p); } }
パフォーマンスを測定するコードを変更しなかったことに注意してください。 この前に、一見役に立たない数千の呼び出しを行いました。 ただし、これらの無用な呼び出しは少し画像を台無しにします。JITは多くのオプションがあることを認識しており、唯一の可能なオプションを代用することはできず、今では正直な仮想呼び出しを行っています。 結果は次のようになります(poly-メソッド呼び出しをポリモーフィックに変換するオプションは、JNIに影響しません):
(acc) | (非公開) | デフォルト | デフォルト/ポリ | インフレ率 | インフレ/ポリ | JNIのみ |
---|---|---|---|---|---|---|
本当 | 本当 | 5,062±0,056 | 6.848±0.031 | 4.935±0.375 | 6.509±0.032 | 195,960±1,873 |
本当 | 偽 | 5.042±0.032 | 6.847±0.035 | 4.914±0.329 | 6.490±0.037 | 194,722±1,151 |
偽 | 本当 | 6.078±0.039 | 7.855±0.040 | 5.638±0.050 | 7.661±0.049 | 196.196±0.910 |
偽 | 偽 | 5.835±0.028 | 7.568±0.046 | 5.520±0.042 | 7.111±0.058 | 194.626±0.918 |
ご覧のように、仮想コールにより、ハードウェアに約1.5〜1.8 nsが追加されます。これは、アクセスチェック以上のものです。 マイクロベンチマーク内の仮想マシンの動作は、実際のアプリケーションの動作とは大幅に異なる可能性があり、可能であれば現実に近い状態を再現できることに留意することが重要です。 もちろん、ここではすべてが現実からはほど遠いものです。少なくとも、ガーベッジがないため、プロセッサーのL1キャッシュとガーベッジコレクションに必要なオブジェクトはすべて発生しません。
-Dsun.reflect.noInflation=true
物事が速くなると言うクールな人もいます。 わずか0.3 nsと仮定しますが、それでもです。 はい、さらに最初の15コールが加速します。 はい、そしてワーキングセットがわずかに減少しました。プロセッサキャッシュを保存します-確実なプラス! プロダクションにオプションを追加して修復します! これは必要ありません。 ベンチマークでは、1つのシナリオをテストしましたが、実際には他のシナリオがあります。 たとえば、一部のコードは多くの異なるメソッドを1回呼び出すことがあります。 このオプションを使用すると、最初の呼び出しですぐにアクセサーが生成されます。 費用はいくらですか? アクセサーはどのくらい生成しますか?
これを評価するために、リフレクションを通じてプライベートフィールドMethod.methodAccessor
(以前にMethod.root
をクリアした)をクリアし、アクセサの初期化を再度強制することができます。 リフレクションによるフィールドの記録は最適化されているため、テストの速度はそれほど低下しません。 そのような結果が得られます。 一番上の行-以前に取得した結果(多態性、アクセス可能)、比較用:
(テスト) | デフォルト | インフレ率 | ジニ |
---|---|---|---|
呼び出す | 6.848±0.031 | 6.509±0.032 | 195,960±1,873 |
リセット+呼び出し | 227.133±9.159 | 100,195.746±2060.810 | 236.900±2.042 |
ご覧のとおり、アクセサがリセットされると、デフォルトでは、JNIを使用したバージョンよりもパフォーマンスがわずかに低下します。 しかし、JNIを完全に拒否すると、メソッドを開始するのに100マイクロ秒かかります。 単一のメソッド呼び出し(JNI経由でも)と比較して、実行時にクラスを生成およびロードすることは、もちろん非常に遅くなります。 したがって、デフォルトの動作「JNIを15回試行してからクラスを生成する」は非常に合理的です。
一般に、アプリケーションを高速化する魔法のオプションはないことを忘れないでください。 存在する場合、デフォルトで有効になります。 人々からそれを隠すことのポイントは何ですか? アプリケーションを特に高速化するオプションがあるかもしれませんが、「-XX:+ MakeJavaFasterをハックすれば、すべてが飛ぶ」などのアドバイスを信じないでください。
これらの生成されたアクセサーはどのように見えますか? バイトコードは、かなり単純な低レベルAPI ClassFileAssemblerを使用してMethodAccessorGeneratorクラスで生成されます。これは、削除されたASMライブラリーにいくらか似ています。 クラスには sun.reflect.GeneratedMethodAccessorXYZ
という形式の名前が付けられます 。ここで、 XYZ
はグローバル同期カウンターであり、スタックトレースとデバッガーで確認できます。
生成されたクラスはメモリ内にのみ存在しますが、 ClassDefiner.defineClassメソッドに型の行を追加することで簡単にディスクにダンプできます
try { Files.write(Paths.get(name+".class"), bytes); } catch(Exception ex) {}
その後、逆コンパイラでクラスを見ることができます。 getName1()
メソッドの場合、次のコードが生成されました(FernFlowerデコンパイラーおよび変数の手動の名前変更):
public class GeneratedMethodAccessor1 extends MethodAccessorImpl { public GeneratedMethodAccessor1() {} public Object invoke(Object target, Object[] args) throws InvocationTargetException { if(target == null) { throw new NullPointerException(); } else { Person person; try { person = (Person)target; if(args != null && args.length != 0) { throw new IllegalArgumentException(); } } catch (NullPointerException | ClassCastException ex) { throw new IllegalArgumentException(ex.toString()); } try { return person.getName1(); } catch (Throwable ex) { throw new InvocationTargetException(ex); } } } }
あなたがしなければならない余分な事に注意してください。 必要な型の空でないオブジェクトが与えられ、引数のリストの代わりに空の引数のリストまたはnull
が渡されたことを確認する必要があります(誰もが知っているわけではありませんが、引数なしでリフレクションメソッドを呼び出す場合、空の配列の代わりにnull
を渡すことができnull
)。 同時に、コントラクトを注意深く観察する必要があります。オブジェクトの代わりにnull
渡された場合、 NullPointerException
スローします。 別のクラスのオブジェクトを渡した場合、 IllegalArgumentException
。 person.getName1()
実行中に例外が発生した場合、 InvocationTargetException
が発生します。 そして、このメソッドには引数がありません。 そして、もしそうなら? たとえば、このようなメソッドを呼び出します(変更の場合、現在は静的であり、 void
を返しvoid
)。
class Test { public static void test(String s, int x) {} }
現在、大幅に多くのコードがあります。
public class GeneratedMethodAccessor1 extends MethodAccessorImpl { public GeneratedMethodAccessor1() {} public Object invoke(Object target, Object[] args) throws InvocationTargetException { String s; int x; try { if(args.length != 2) { throw new IllegalArgumentException(); } s = (String)args[0]; Object arg = args[1]; if(arg instanceof Byte) { x = ((Byte)arg).byteValue(); } else if(arg instanceof Character) { x = ((Character)arg).charValue(); } else if(arg instanceof Short) { x = ((Short)arg).shortValue(); } else { if(!(arg instanceof Integer)) { throw new IllegalArgumentException(); } x = ((Integer)arg).intValue(); } } catch (NullPointerException | ClassCastException ex) { throw new IllegalArgumentException(ex.toString()); } try { Test.test(s, x); return null; } catch (Throwable ex) { throw new InvocationTargetException(ex); } } }
int
代わりに、 Byte
、 Short
、 Character
またはInteger
を渡す権利があり、これらはすべて変換する必要があることに注意してください。 これは、変換が行われている場所です。 このようなブロックは、拡張変換が可能な各プリミティブ引数ごとに追加されます。 また、キャッチがNullPointerException
によってキャッチされる理由も明らかです。これは、アンボックス中に発生する可能性があり、その後IllegalArgumentException
もIllegalArgumentException
必要があります。 しかし、メソッドは静的であるという事実のため、 target
パラメーターの内容は気にしません。 さて、このメソッドはvoid
返すため、 return null
行がありました。 このすべての魔法は、 MethodAccessorGenerator.emitInvokeにきちんと描かれています 。
これがメソッド呼び出しの仕組みです。 コンストラクターの呼び出しも同様に配置されます。 また、このコードはオブジェクトの逆シリアル化のために部分的に再利用されます。 JVMの観点からアクセサーが既に存在する場合、自分で作成するコードとそれほど変わらないため、反射はすぐに動作し始めます。
結論として、Java 7以降、 java.lang.invoke APIが登場しました。これにより、メソッドを動的に呼び出すこともできますが、まったく異なる方法で動作します。