MethodHandle primer

原文はこちら。
The original article was written by Jorn Vernee (Principal Member Of Technical Staff at Oracle).
https://jornvernee.github.io/methodhandles/2024/01/19/methodhandle-primer.html

java.lang.invokeパッケージのメソッド・ハンドルAPIは、強力なリフレクション、コード生成APIで、JITも幅広くサポートしています。このブログ記事ではこのAPIの概要を説明します。APIの最も一般的に使用される部分について説明しますが、これは包括的なガイドではありません。この記事は、より多くのことを学び始めるための出発点となることを意図しています。読者の皆さんも、自分でサンプルを試してみてください。

  1. What is a MethodHandle?
    1. Access checks
    2. Exception handling
    3. Signature polymorphism
    4. Handling the receiver
    5. The invokeExact method
    6. The invokeWithArguments method
  2. Method handle inlining
  3. Method handle combinators
    1. MethodHandles::insertArguments
    2. MethodHandles::filterArguments
    3. MethodHandles::collectArguments
    4. MethodHandles::permuteArguments
  4. Appendix A: MutableCallSite
  5. Appendix B: VarHandle

What is a MethodHandle?

java.lang.invoke.MethodHandle は、固定のJavaのターゲット・メソッドをラップするオブジェクトです。その名の通り、Javaメソッドの「ハンドル」です。ターゲット・メソッドを、メソッド・ハンドル・オブジェクトを使って呼び出せます。

多くの場合、関数インターフェースを使用して呼び出し可能なコード、例えば、java.util.functionパッケージのインターフェースの1つへの参照を表現できます。しかし、ジェネリックスや関数型インターフェースに頼って、様々な型を持つ可変数の引数を取るメソッドを表現することはできません。最も良いのは、引数を配列に集めるvarargsメソッドを使うことですが、これにはvarargs配列の生成のオーバーヘッド、プリミティブ値のボックス化とアンボックス化のオーバーヘッド、配列からの値の格納と読み込みのオーバーヘッドが発生します。また、値を返すメソッドと何も返さないメソッドの両方を表現することもできません。Voidを使った回避策もありますが、それでもメソッドが何らかの値(通常はnull)を返す必要があります。

interface GenericFunction<R> {
    R apply(Object... args);
}

static int m1(int x, int y) {
    return x + y;
}

static void m2() {}

static void main(String[] __) {
    GenericFunction<Integer> m2Ref = args -> { return m1((int) args[0], (int) args[1]); };
    int result = m2Ref.apply(1, 2); // arguments boxed into Object[]
    // result has to be unboxed

    // have to explicitly return 'null'
    GenericFunction<Void> m1Ref = args -> { m2(); return null; };
}

一方メソッド・ハンドルは、ボックス化とアンボックス化に関して同じ制限がありません(これについては後で詳しく説明します)。

実際には、メソッド・ハンドルは実行時にコードを生成する、パラメータの数と型が事前にわからないAPIの結果を表すのに最も有用です。例えば、メソッド・ハンドルを外部関数とメモリアクセス(FFM)APIでネイティブ関数への参照を実装するために使用します。このために使用される java.lang.foreign.Linker::downcallHandleメソッドは、FunctionDescriptorを受け取り、C関数を呼び出すために使用できるMethodHandleインスタンスを返します。リンカAPIは、任意のC関数へのアクセスを提供する必要があるため、さまざまなアリティ(引数の個数)、さまざまなパラメータと戻り値の型を持つ関数への参照を表すために使用できる型が必要です。同時に、これにより、varargsやプリミティブ値のボックス化・アンボックス化のオーバーヘッドを避けることができます。MethodHandleはそのような場合に最適な選択です。

特定のJavaメソッドのMethodHandleを作成する最も簡単な方法の1つは、メソッド・ハンドルを検索することです。まず、MethodHandles.Lookupオブジェクトを作成する必要があります。これは、MethodHandles::lookup ファクトリーメソッドを使用して実行できます。次に、LookupfindXXX メソッドの 1 つを使用して既存のJavaメソッドを検索します。

MethodHandles::lookup
https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/lang/invoke/MethodHandles.html#lookup()

import java.lang.invoke.*;

public class Main {
    public static void main(String[] args) throws Throwable {
        MethodHandles.Lookup lookup = MethodHandles.lookup();
        MethodType typeOfTarget = MethodType.methodType(void.class);
        MethodHandle targetMh = lookup.findStatic(Main.class, "target", typeOfTarget);

        targetMh.invoke(); // prints 'invoking target'
    }

    public static void target() {
        System.out.println("invoking target");
    }
}

上記の例では、staticなtargetメソッドを宣言し、findStaticで検索できるようにしています。targetメソッドを有しているクラスとその名前、そしてjava.lang.invoke.MethodTypeで表される型をfindStaticに渡します。これにより、targetメソッドのMethodHandleが返されます。invokeメソッドの呼び出しで呼び出しができます。また、invokeメソッドはThrowableを投げることに注意してください。そのため、mainメソッドのthrows句にて宣言して対応しています。

Lookupの他のfindXXXメソッドを使って、他の種類のメソッドを検索できます。findVirtualメソッドは invokevirtualバイトコードに対応し、virtual method(仮想メソッド)として知られるインスタンス(つまり非静的)メソッドを検索するために使用できます。 findConstructorメソッドはコンストラクタを検索するために使用できます。フィールドの読み書きにはfind(Static)Getterfind(Static)Setterもありますが、これらはgetXメソッドやsetXメソッドを検索するのではなく、フィールドを直接取得または設定するnotional methodを検索することに注意してください。フィールドを取得または設定するメソッドがその場で生成されるようなものです。これらはputfieldgetfieldputstaticgetstaticバイトコードに対応します。これらはほんの一例です。

MethodHandlejava.lang.reflect.Method.Methodと非常によく似た概念であることにお気づきでしょうか。しかし、このAPIはより最小限のものです。というのも、メソッド・ハンドルはターゲットメソッドのアクセス修飾子や、アノテーションのような他のものを反映しないためです。メソッド・ハンドルは、型を持っていることと、それを呼び出すことができることの2つだけです。MethodHandleの残りのメソッドはいわゆるcombinators(コンビネータ)と呼ばれるもので、クライアントはこのメソッド・ハンドルに基づいて他のメソッド・ハンドルを作成できます。コンビネータについては後の章で説明します。

最も単純な使用例では、j.l.r.Methodと同様に、メソッド・ハンドルを使ってJavaのターゲットメソッドをリフレクションで呼び出すことができます。しかし、2つのAPIにはいくつかの重要な違いもあります。

Access checks

j.l.r.Methodinvokeメソッドは、呼び出されるときにアクセスチェックを行います。これは、@CallerSensitiveアノテーションを使って示してもいます。この有益なアノテーションは、このメソッドが呼び出されたときに呼び出し元を検査することを示しています。実際には、invokeメソッドは呼び出し元のクラスを見つけるために少々スタック・ウォークします(ただし、このスタック・ウォークはJITによって最適化される可能性があります)。そして、呼び出し元がターゲットメソッドを呼び出すために必要なアクセス権限を持っているかどうかをチェックします。

一方メソッド・ハンドルは、呼び出されたときにアクセスチェックを行いません(したがって、呼び出し元を意識する必要はありません)。代わりに、メソッド・ハンドルを検索するときにアクセスチェックをします。MethodHandles::lookupメソッドは呼び出し元を区別する点に注意が必要です。lookupを呼び出すと、呼び出し元のクラスをlookupクラスとしてキャプチャします。このlookupクラスを、メソッド・ハンドルのルックアップ実行時に使用して、ターゲット・メソッドを呼び出すために必要なアクセス権限を持っているかどうかをチェックします。これにより、クライアントはメソッド・ハンドルの作成時に1回のアクセス・チェックを行い、その後はメソッド・ハンドルを呼び出す際のアクセス・チェックを回避してパフォーマンスを向上させることができます。

💡これはつまり、メソッド・ハンドル オブジェクトを持っていれば、それを呼び出すことができる、ということです。したがって、メソッドハンドルはcapability(ケイパビリティ、能力)であると言えます。メソッド・ハンドル オブジェクトを共有すれば、通常はターゲットのメソッドにアクセスできない他のコードとこのケイパビリティを共有し、対象のメソッドを呼び出せるようにすることができます。これが、メソッドハンドルを使いたくなる理由のひとつです。

Exception handling

j.l.r.Methodinvokeメソッドは、配下のJavaメソッドがスローした例外をInvocationTargetExceptionにラップしますが、MethodHandleinvokeメソッドは、ラップせずに例外を直接伝播します。invokeメソッドがThrowableを投げる理由もここにあります。これは、ターゲットメソッドから伝搬される必要がある可能性のあるthrowableを考慮しています。

invokeThrowableをスローするということは、このThrowableを何らかの方法で処理する必要があるため、面倒に思えるかもしれません。しかし、ターゲットメソッドが検査例外を宣言していない場合、invokeが検査例外を投げることはないと考えることができます。従ってほとんどの場合、catchブロックで非検査例外をスローする try/catchブロックを使い、invoke呼び出しを囲むことができます。

MethodHandle mh = ...
try {
    mh.invoke();
} catch (Throwable t) {
    throw new RuntimeException("Should not happen", t);
}

💡invokeがスローされた例外をラップしないということは、スローされた例外を伝播するためにラップを解除する心配をしなくても、メソッド・ハンドルを使ってメソッドをきれいに実装できるということでもあります。これもメソッド・ハンドルを使いたくなる理由のひとつです。

Signature polymorphism

j.l.r.Methodinvokeメソッドは以下のように宣言されています(詳細を省いています)。

@CallerSensitive
public Object invoke(Object obj, Object... args)
    throws IllegalAccessException, InvocationTargetException

最初のパラメータは、仮想メソッド呼び出しのレシーバー・インスタンスを表します。staticメソッドの場合は無視されます。

一見したところ、MethodHandleinvokeメソッドは非常によく似ています。

public final native @PolymorphicSignature Object invoke(Object... args) throws Throwable;

However, the return type and parameter type of this declaration are completely fictional. As the @PolymorphicSignature annotation implies, this method is signature polymorphic.

ただし、この宣言の戻り値型とパラメータ型は完全に架空のものです。@PolymorphicSignatureアノテーションが示すように、このメソッドはシグネチャ・ポリモーフィック・メソッド(以後、sig-polyメソッド)です。

Signature polymorphism
https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/lang/invoke/MethodHandle.html#sigpoly
https://docs.oracle.com/javase/jp/21/docs/api/java.base/java/lang/invoke/MethodHandle.html#sigpoly

このメソッドは非常に特殊で、Java言語仕様でも特殊なケースに分類されています。シグネチャ・ポリモーフィズム・メソッドとは、メソッドの型がメソッド宣言によってではなく、メソッド呼び出しサイト(メソッドが呼び出されるコード内の場所)によって決定されるメソッドのことです。つまり、invokeメソッドを呼び出すコードが呼び出し時に渡す引数の型が何であれ、それがメソッドが持つパラメータの型になります。invokeメソッドは何度も呼び出すことができ、さまざまな数や型の引数を渡すことができるため、これはinvokeメソッドが本質的に多くの異なる型を持っていることも意味します。実際、invokeメソッドは、通常のJavaメソッド宣言で宣言可能な任意の型を取ることができます。シグネチャはこのようにポリモーフィック(多相的)です。同じことが戻り値の型にも当てはまります。invokeメソッドの戻り値をどの型にキャストしても、それがメソッドの戻り値型になります。

j.l.r.Method::invokeメソッドを呼び出すには、新しいObject[]を作成し、そこにすべての引数の値を格納する必要があります(プリミティブ値はボクシングする必要があります)。そして、Object[]は呼び出しに渡され、返されたObjectの値がプリミティブ値の場合は、再度アンボクシングする必要があります。一方、シグネチャ・ポリモーフィズム・メソッドでは、このようなオーバーヘッドをすべて回避できます。その代わり、すべての引数値はそのままシグネチャ・ポリモーフィズム・メソッドに渡され、シグネチャ・ポリモーフィズム・メソッドから返されます。

この動作はクラスファイルでも再定義されています。MethodHandle::invokeメソッドを指すクラスファイル参照に付加されているメソッド型は、引数値と戻り値の正確な(静的な)型であり、パラメータ型と戻り値型として使用されます。たとえば、次のようなコードがあるとします:

MethodHandle mh = ...
String x = ...
int y = ...
long result = (long) mh.invoke(x, y);

このinvokeへの呼び出しに対してjavacが生成するinvokevirtual命令は、メソッドタイプ記述子 (Ljava/lang/String;I)J を指します。これはinvokeメソッドの宣言のメソッドタイプ記述子 ([Ljava/lang/Object;)Ljava/lang/Object; とは異なります。

JDKに付属しているjavapツールを使って、javacがこの呼び出しサイトにどのような型を割り当てているかを確認することもできます。生成されたクラスファイルをjavap -c <file>で逆アセンブルすると、型記述子(Ljava/lang/String;I)Jを持つMethodHandle::invokeメソッドのinvokevirtual命令の存在を確認できます。

46: invokevirtual #44  // Method java/lang/invoke/MethodHandle.invoke:(Ljava/lang/String;I)J

このことの詳細は、Java言語仕様に記載があります(例えば15.12.3)。

15.12.3. Compile-Time Step 3: Is the Chosen Method Appropriate?
https://docs.oracle.com/javase/specs/jls/se21/html/jls-15.html#jls-15.12.3

sig-polyメソッドを動作させるために、VMがsig-polyメソッドがリンクされるたびにコードを生成します。これは、sig-polyメソッドが引き受ける各タイプに対して1回ずつ行われます。このリンクプロセスでは、正しい呼び出しセマンティクスを使用してメソッドハンドル呼び出しを実際のターゲットメソッドにリンクするために、マシンコードと通常Javaバイトコードの両方を生成します(例えば、staticメソッドかvirtualメソッドかなど、ターゲットメソッドによって異なります)。別の考え方として、sig-polyメソッドにはさまざまな実装方法がある、と言えるでしょう。ただし、これらの実装は実際には、MethodHandleインスタンスで記述された実際のターゲットメソッドに呼び出しを転送するちょっとしたトランポリンのようなものです。

💡sig-polyメソッドは引数をそのまま受け入れ、Object[] にボックス化する必要がないことも、メソッドハンドルを使用するもう 1 つの理由です。実行可能なコードの一部を表す型が必要なのに、引数の正確な型や数がわからない場合は、メソッドハンドルが適しています。メソッドハンドルは、引数や戻り値をボックス化することに伴う非効率性を排除し、汎用的な呼び出し API を提供します。

Handling the receiver

レシーバーは仮想メソッドのthis引数です。j.l.r.Methodはレシーバーを明示的なObject型の先頭パラメータとして処理します。

Object invoke(Object obj, Object... args)

staticメソッドを呼び出す場合、obj引数は単に無視されます。通常、objパラメータの代わりにnullを渡します。

一方、メソッドハンドルでは、レシーバパラメータはパラメータリストの先頭に追加される単なる別のパラメータです。したがって、virtualメソッドを調べると以下のようになっています。

public static void main(String[] args) throws Throwable {
    MethodHandle targetMh = MethodHandles.lookup().findVirtual(Main.class, "target",
            MethodType.methodType(void.class));

    targetMh.invoke(new Main()); // prints 'invoking target'
}

public void target() { // NOT static
    System.out.println("invoking target");
}

メソッドハンドルを呼び出す際には、レシーバーのインスタンス(new Main())を渡す必要があります。しかし、What is a MethodHandleの最初の例のように、staticメソッドハンドルを検索する際には、先頭のパラメータは追加されません。

特に、virtualメソッドを検索する際に使用されるメソッドの種類に注目してください。

MethodType.methodType(void.class)

💡virtualメソッドを検索する際に使用されるメソッドタイプには、レシーバータイプは含まれません。ただし、返されたメソッドハンドルのタイプにはレシーバータイプが含まれています。virtualメソッドを検索する際、レシーバータイプはホルダークラス(この例では Main.class)によって暗示的に示されます。これは、virtualメソッドを検索する際に覚えておくべき重要なことです。

The invokeExact method

sig-polyメソッドへの呼び出しは引数がそのまま渡されるため、例えば、invokeへの呼び出しで生成されるバイトコードは、ターゲットメソッドのパラメータの型がint型の場合、int型引数を自動的にlong型に変換することはありません。しかし、invokeメソッドの実装が自動的にその処理を実行します。コールサイトで使用される invokeメソッドの型は、メソッドハンドルの型と一致する必要はありません。invokeメソッドの実装は、すべての引数と戻り値をターゲットメソッドが要求する型に変換します。

対してinvokeExactメソッドは、自動的に引数変換をしません。そのかわり、invokeExact の実装でコールサイトで使われる型がメソッドハンドルインスタンスの型と完全に一致していることを確認します。もし一致していない場合、WrongMethodTypeExceptionがスローされます。型の完全一致が必要とは、文字通り一致する必要があります。メソッドハンドルインスタンスがObject型のパラメータを持つ場合に静的型がStringの値を渡すと、WrongMethodTypeExceptionが発生します。コールサイトで正しい型を得るには、引数値をObjectにキャストする必要があります。

static void foo(Object o) {}

public static void main(String[] ___) throws Throwable {
    MethodHandle fooMh = MethodHandles.lookup().findStatic(Main.class, "foo",
            MethodType.methodType(void.class, Object.class));
    
    fooMh.invokeExact("Hello, world!");
}

このコードは以下の例外をスローします。

java.lang.invoke.WrongMethodTypeException: handle's method type (Object)void but found (String)void

呼び出しが成功するためには、Objectに引数をキャストする必要があります。そうすることで、 コールサイトとメソッドハンドルインスタンスの型がと完全に一致します。

fooMh.invokeExact((Object) "Hello, world!");

戻り値についても同様です。メソッドハンドルインスタンスの戻り値がvoid型でない場合、invokeExactを使用する際には、戻り値を正しい型にキャストする必要があります。場合によっては、使用されない値であっても代入する必要があります。

static int foo() { return 42; }

public static void main(String[] ___) throws Throwable {
    MethodHandle fooMh = MethodHandles.lookup().findStatic(Main.class, "foo",
            MethodType.methodType(int.class));
    
    int x = (int) fooMh.invokeExact(); // result is not used
}

では、なぜ invoke ではなく invokeExact を使うのでしょうか? 実は、引数を正しい型に自動的に変換するにはコストがかかるからです。

invoke の実装では、MethodHandle::asTypeメソッドが呼び出されます。asTypeは、メソッドハンドルコンビネーターの第 1 の例です。asType メソッドは、MethodTypeを引数として受け取り、必要な型変換を行った上で、指定された型の新しいメソッドハンドルを返し、変換された引数をオリジナルのメソッドハンドルに転送します。また、asTypeは必要に応じて手動で呼び出すこともできます。

static void foo(Object o) {}

public static void main(String[] ___) throws Throwable {
    MethodHandle fooMh = MethodHandles.lookup().findStatic(Main.class, "foo",
            MethodType.methodType(void.class, Object.class));

    // from (Object) -> void to (String) -> void
    fooMh = fooMh.asType(MethodType.methodType(void.class, String.class));

    fooMh.invokeExact("Hello, world!"); // this now works
}

これを実現するために、asTypeの実装は、すべてのパラメータと戻り値の型変換を実装する合成クラスとメソッドを生成します。その結果は、新しいメソッドハンドルでラップされ、メソッドハンドルを返します。asTypeの実装が、次のような小さなラッパーメソッドを生成したかのようです。

static void foo(String str) {
    foo((Object) str);
}

メソッドハンドルに対してinvokeを呼び出すと、実装はasTypeを呼び出して、メソッドハンドルの型をコールサイトが要求する型に変換します。メソッドを呼び出すだけなのに、合成クラスとメソッドを生成するのはコストがかかることは容易に理解できるでしょう。しかし、悪いことばかりではありません。各メソッドハンドルインスタンスには 1 要素のキャッシュがあり、asTypeの結果はそこに格納されます。asTypeへの次の呼び出しで同じメソッドタイプが再び要求された場合、キャッシュされたメソッドハンドルがそのまま返されます。 つまり、実際には、異なるコールサイトタイプで同じメソッドハンドルインスタンスが複数回呼び出される場合にのみ、invokeの呼び出しにコストがかかるということです。

invokeの自動型変換は非常に便利ではありますが、パフォーマンス上の落とし穴にもなり得ます。しかしinvokeExactを使用すれば、型適応が一切行われないため、この落とし穴を100%確実に回避できます。したがって、パフォーマンスの一般的な目安として、不正確な呼び出しは避け、invokeExactinvokeより優先するようにしましょう。

💡メソッドハンドルを使用する際、invokeExactへの呼び出し用にstaticラッパー関数を作成すると便利な場合があります。

static final MethodHandle FOO_MH = ...

public static void main(String[] ___) throws Throwable {
    fooWrapper("Hello, world!");
}

public static void fooWrapper(Object o) {
    FOO_MH.invokeExact(o);
}

ここで、fooWrapperメソッドは、FOO_MHメソッドハンドルとまったく同じメソッド型です。しかし、それをstaticメソッドでラップすることで、代わりにクライアントが呼び出すことができるsig-polyではないメソッドを提供します。つまり、例えば上記の例のmainメソッドでは、文字列引数を明示的に Object にキャストする必要がなく、同時にメソッドハンドルと呼び出しサイト間の型不一致も回避できるということです。厳格だが柔軟なinvokeExact APIをfooWrapperが「洗練」しています。

The invokeWithArguments method

j.l.r.Method::invokeObjectの可変長配列を受け入れるため、javacが自動的にObject[]に変換する引数のリストを渡すだけでなく、引数の値を保持するObject[]を手動で作成し、それを直接j.l.r.Method::invokeに渡すこともできます。

Method m = ...
m.invoke(null, 1, 2, 3); // 1.) Ok
Object[] args = { 1, 2, 3 };
m.invoke(null, args); // 2.) also OK

ただし、このテクニックはsig-polyメソッドには使用できません。sig-polyメソッドの型はコールサイトから派生するため、メソッドハンドルを呼び出す際にObject[]を渡しても、コールサイトの型は引数の型としてObject[] のままです。この配列は、引数がそのまま sig-poly メソッドに渡されるため、自動的に引数のリストに展開されることはありません。

したがって、Object[]または引数のリストをメソッドハンドルに渡したい場合は、invokeWithArgumentsを使用する必要があります。このメソッドは、配列またはリストをスカラー引数の値に変換し、その値をメソッドハンドルに渡します。

static void foo(int x, int y, int z) {}

public static void main(String[] __) throws Throwable {
    MethodHandle fooMh = MethodHandles.lookup().findStatic(Main.class, "foo",
            MethodType.methodType(void.class, int.class, int.class, int.class));
    
    fooMh.invoke(1, 2, 3); // OK
    Object[] args = { 1, 2, 3 };
    // fooMh.invoke(args); // BAD
    // WrongMethodTypeException: cannot convert MethodHandle(int,int,int)void to (Object[])void
    fooMh.invokeWithArguments(args); // Ok
}

ただし、invokeWithArgumentsも内部で型の変換を行っていることに留意してください。また、MethodHandle::invokeと同様のパフォーマンス上の注意点があります。

Method handle inlining

ここでは、C2 JITコンパイラがメソッドハンドル呼び出しに適用するインライン最適化について説明します。これは難しいトピックですが、メソッドハンドルを効果的に使用するためには、少なくともある程度の理解が必要です。メソッドハンドルの使用方法によってパフォーマンスに大きな違いが生じる可能性があり、そのほとんどの場合、不正確な呼び出し(上記のinvokeExactのセクションを参照)またはメソッドハンドルのインライン化不足が原因です。

通常の Java メソッド呼び出しの場合、呼び出し先がどのターゲットメソッドを指しているかは分かっています。

public static void foo() {}

public static void main(String[] args) {
    foo();
}

上記の例では、fooへの参照が直接クラスファイルに埋め込まれています。つまり、JITコンパイラはどのメソッドが呼び出されたかを正確に把握し、そのメソッドをインライン化することで、その他の最適化が可能です。

しかし、メソッドハンドルについては、ターゲットとなるメソッドを記述する情報がメソッドハンドルのインスタンスに埋め込まれます。そのため、メソッドハンドルが呼び出される際には、まずメソッドハンドルインスタンスからターゲットとなるメソッドを読み込むトランポリンを経由し、そのターゲットに呼び出しを転送します。この間接的な処理により、通常、ターゲットとなるメソッドのインライン化ができません。結局のところ、メソッドハンドルへの呼び出しのレシーバーは、任意のメソッドハンドルインスタンスであり、任意のターゲットとなるメソッドを指す可能性があるからです。

しかし、メソッドハンドルが定数である場合、JITコンパイラは定数メソッドハンドルインスタンスを検査することで、JITコンパイル時にターゲットメソッドを決定し、ターゲットメソッドへの通常の呼び出しであるかのように扱うことができます。したがってインライン化が再び有効になります。

MethodHandleなどの値が定数であるかどうかを判断するには、同じコードの呼び出しごとに値が異なる可能性があるかどうかを判断するのが最も簡単です。

static final MethodHandle MH_FOO;

static {
    try {
        MH_FOO = MethodHandles.lookup().findStatic(Main.class, "foo",
                MethodType.methodType(void.class));
    } catch (ReflectiveOperationException e) {
        throw new ExceptionInInitializerError(e);
    }
}

static void foo() {}

static void m() throws Throwable {
    MH_FOO.invokeExact();
}

ここでは、invokeExactの呼び出しのレシーバーは、static finalフィールドのMH_FOOから直接ロードされます。static finalフィールドは、リフレクションを使用しても変更できません。そのため、JITは MH_FOOフィールドからのロードを定数畳み込みして、invokeExactのレシーバーを定数にすることができます。JITはレシーバーのターゲットメソッドを検査し、invokeExactへの呼び出しをターゲットメソッド fooへの呼び出しであるかのように処理します。

invokeExactの呼び出しのインライン化が行われているかどうかは、JIT コンパイラのデバッグに関するエントリの第3章で説明しているテクニックを使って確認できます。

3. Printing inlining traces – Notes on debugging HotSpot’s JIT compilation
https://jornvernee.github.io/hotspot/jit/2023/08/18/debugging-jit.html#3-printing-inlining-traces

これらのテクニックを適用して、上記のプログラムでメソッドmのインライン化トレースを取得すると、以下のインライン化トレースが得られます。

@ 3   java.lang.invoke.LambdaForm$MH/0x00000236d7001400::invokeExact_MT (22 bytes)   force inline by annotation
  @ 10   java.lang.invoke.Invokers::checkExactType (17 bytes)   force inline by annotation
    @ 1   java.lang.invoke.MethodHandle::type (5 bytes)   accessor
  @ 14   java.lang.invoke.Invokers::checkCustomized (23 bytes)   force inline by annotation
    @ 1   java.lang.invoke.MethodHandleImpl::isCompileConstant (2 bytes)   (intrinsic)
  @ 18   java.lang.invoke.LambdaForm$DMH/0x00000236d7001800::invokeStatic (20 bytes)   force inline by annotation
    @ 8   java.lang.invoke.DirectMethodHandle::internalMemberName (8 bytes)   force inline by annotation
    @ 16   Main::foo (1 bytes)   inline (hot)

ここでは、最終的にMain::fooメソッドがインライン化されていることがわかります。

次に、より複雑な例を見てみましょう。

...

static void m1() throws Throwable {
    m2(MH_FOO);
}

static void m2(MethodHandle mh) throws Throwable {
    mh.invokeExact();
}

ここで、invokeExactを呼び出すレシーバーインスタンスは、m2メソッド内では定数ではありません。結局のところ、m2に渡される引数は任意のメソッドハンドルインスタンスになり得ます。m2がJITコンパイルされた場合、invokeExact呼び出しのインライン化は実行されません。

しかしメソッドm1では、定数MH_FOOを引数としてm2に渡しています。そのため、メソッドm1がJIT コンパイルされ、そのJITコンパイルでm2がインライン化されていた場合、invokeExactのレシーバーは定数となり、invokeExact 呼び出しのインライン化が可能となります。

最後に、インスタンスフィールドを見てみましょう。

class Widget {
    final MethodHandle mh_foo;

    ...

    void invoke() throws Throwable {
        mh_foo.invokeExact();
    }
}

ここで、mh_fooWidgetのインスタンスのフィールドです。 invokeがJIT コンパイルされた場合、thisインスタンスからmh_fooの値を読み込むためのロードが発生します。thisインスタンスはWidgetの任意のインスタンスになり得るため、このロードは定数畳み込みできません。そのため、invokeExactのレシーバーも定数ではなく、インライン展開も発生しません。

static final Widget W = new Widget(MH_FOO);

static void m() throws Throwable {
    W.invoke();
}

メソッドmにおいて、invokeのレシーバーはstatic finalフィールドWからロードされるため、定数です。そのため、mがJITコンパイルされ、invokeがインライン化されている場合、thisインスタンスは定数とみなされます。しかし、この場合でも、finalフィールドは「信頼されていない」ため、Widgetmh_fooフィールドのロードは定数畳み込みされません。例えば、リフレクションによって変更される可能性が考えられるからです。その結果、JITはmh_fooフィールドのロードを定数畳み込みしない可能性があり、invokeExactのレシーバーは定数ではなく、コールはインライン化されません。

インライン化トレースでは、インライン化に失敗した MethodHandle::invokeBasic への呼び出しという形で、この現象を確認できます。

@ 18   java.lang.invoke.MethodHandle::invokeBasic()V (0 bytes)   failed to inline: receiver not constant

ただし、このルールにはいくつかの例外があります。java.baseパッケージ内の特定のクラスには、信頼されたfinalフィールドがあります。さらに、レコードのフィールドも信頼されています。JIT コンパイラは、包含するインスタンスもまた定数の場合、これらのフィールドを定数畳み込みできます。例えば、以下はWidget をレコードに変換する例です。

record Widget(MethodHandle mh_foo) {
    void invoke() throws Throwable {
        mh_foo.invokeExact();
    }
}

また、定数のWidgetインスタンスに対してinvokeを呼び出すと、JITコンパイラはmh_fooフィールドの読み込みを省略し、invokeExact呼び出しのインライン化を行うことができます。

Method handle combinators

メソッドハンドル・コンビネーターは、メソッドハンドルAPIの大部分を占めています。これらのほとんどは、MethodHandlesクラスのstaticメソッドとして存在しますが、にはMethodHandleクラスのインスタンスメソッドとして存在するものもあります。

メソッドハンドル・コンビネーターはコード生成APIです。各メソッドハンドルインスタンスは LambdaForm と呼ばれる小さなプログラムを持ちます。このLambdaFormにはメソッドハンドルが実施することが記述されています。ほとんどの場合、このLambdaFormはバイトコードとしてレンダリングされ、直接実行されます。メソッドハンドルコンビネーターAPIを使って、MethodHandleインスタンスにラップされたこれらの小さなプログラムを作成できます。

前述の通り、MethodHandle::asTypeはそうしたコンビネーターです。その他の注目すべき例を見ていきましょう。

The MethodHandles::insertArguments combinator

MethodHandles::insertArguments はおそらく皆様が通常利用する最も簡単なコンビネーターのひとつです。これを使うと、クライアントが1個以上の固定引数値を指定のメソッドハンドルに渡す、新たなメソッドハンドルを作成できます。

static void foo(int x, int y, int z) {
    System.out.println(STR."x=\{x} y=\{y} z=\{z}");
}

public static void main(String[] __) throws Throwable {
    MethodHandle fooMh = MethodHandles.lookup().findStatic(Main.class, "foo",
        MethodType.methodType(void.class, int.class, int.class, int.class));
    
    // at parameter index 1, insert argument value '2'
    MethodHandle mh = MethodHandles.insertArguments(fooMh, 1, 2);

    mh.invokeExact(1, 3); // prints 'x=1 y=2 z=3'
}

ここでは、ダウンストリームのメソッドハンドル fooMh のパラメータリストのインデックス 1 に固定引数値 2 を挿入します。その結果、2 つの int 引数のみを取る新しいメソッドハンドルが生成されます。これは、foo のラッパーメソッドを次のように記述することと等価です。

static void fooPrime(int x, int z) {
    foo(x, 2, z);
}

The MethodHandles::filterArguments combinator

次に、MethodHandles::filterArgumentsは、ターゲットのメソッドハンドルに結果を渡す前に、フィルタメソッドを呼び出してその引数の一つ以上を事前処理するメソッドハンドルを作成します。

static void foo(int x, int y, int z) {
    System.out.println(STR."x=\{x} y=\{y} z=\{z}");
}

static int bar(int x) {
    return x + 1;
}

public static void main(String[] __) throws Throwable {
    MethodHandle fooMh = MethodHandles.lookup().findStatic(Main.class, "foo",
        MethodType.methodType(void.class, int.class, int.class, int.class));
    MethodHandle barMh = MethodHandles.lookup().findStatic(Main.class, "bar",
        MethodType.methodType(int.class, int.class));
    
    // applies filter 'barMh' to argument at index 1, and passes the result to 'fooMh'
    MethodHandle mh = MethodHandles.filterArguments(fooMh, 1, barMh);

    mh.invokeExact(1, 2, 3); // prints 'x=1 y=3 y=3'
}

これは以下のJavaコードと等価です。

static void fooPrime(int x, int y, int z) {
    foo(x, bar(y), z);
}

注目すべき点は、filterArgumentsで使用するフィルタはパラメータを 1 つだけ持ち、フィルタリングされたインデックスでダウンストリームのメソッドハンドルが受け入れ可能な型の値を返す必要があるということです。

The MethodHandles::collectArguments combinator

MethodHandles::collectArgumentsは複数の引数値を1個の値に集約してダウンストリームのメソッドハンドルに渡すことができるため、制限はそれほど多くありません。

static void foo(int x, int y, int z) {
    System.out.println(STR."x=\{x} y=\{y} z=\{z}");
}

static int bar(int x, int y) {
    return x + y;
}

public static void main(String[] __) throws Throwable {
    MethodHandle fooMh = MethodHandles.lookup().findStatic(Main.class, "foo",
        MethodType.methodType(void.class, int.class, int.class, int.class));
    MethodHandle barMh = MethodHandles.lookup().findStatic(Main.class, "bar",
        MethodType.methodType(int.class, int.class, int.class));
    
    // applies filter 'barMh' to argument at index 1, and passes the result to 'fooMh'
    MethodHandle mh = MethodHandles.collectArguments(fooMh, 1, barMh);

    mh.invokeExact(1, 2, 2, 3); // prints 'x=1 y=4 y=3'
}

これは以下のJavaコードと等価です。

static void fooPrime(int a0, int a1, int a2, int a3) {
    foo(a0, bar(a1, a2), a3);
}

あるいは、コレクターがゼロ個の引数も許容できます。この場合、サプライヤーと同様に引数値をその場で生成します。

static void foo(int x, int y, int z) {
    System.out.println(STR."x=\{x} y=\{y} z=\{z}");
}

static int bar() {
    return ThreadLocalRandom.current().nextInt();
}

public static void main(String[] __) throws Throwable {
    MethodHandle fooMh = MethodHandles.lookup().findStatic(Main.class, "foo",
        MethodType.methodType(void.class, int.class, int.class, int.class));
    MethodHandle barMh = MethodHandles.lookup().findStatic(Main.class, "bar",
        MethodType.methodType(int.class));
    
    // applies filter 'barMh' to argument at index 1, and passes the result to 'fooMh'
    MethodHandle mh = MethodHandles.collectArguments(fooMh, 1, barMh);

    mh.invokeExact(1, 3); // prints 'x=1 y=<random numer> y=3'
}

これはまだ非常に単純ですが、collectArgumentsvoidを返すコレクターとの併用もできます。 コレクターを併用すると、コレクターは本質的に、ゼロ個以上の入力に基づいてダウンストリームのハンドルの引数を計算するのではなく、ダウンストリームのメソッドハンドルの前に実行される付随的な効果として動作します。

voidを返すコレクターの場合、collectArgumentsに指定するインデックスは、コレクターのパラメータリストを挿入したいダウンストリームハンドルのパラメータリスト内の場所を示しています。このインデックスは、ダウンストリームハンドルの特定のパラメータを指すのではなく、パラメータ間のスペースを指すものと考えることができます。例えば、インデックス 0 は、コレクターのパラメータが、ダウンストリームハンドルの最初のパラメータの前に表示されるべきであることを示します。例えば以下のような感じです。

static void foo(String s, Object o, int i) {
}

static void bar(long l, double d) {
}

public static void main(String[] __) throws Throwable {
    MethodHandle fooMh = MethodHandles.lookup().findStatic(Main.class, "foo",
        MethodType.methodType(void.class, String.class, Object.class, int.class));
    MethodHandle barMh = MethodHandles.lookup().findStatic(Main.class, "bar",
        MethodType.methodType(void.class, long.class, double.class));

    MethodHandle mh = MethodHandles.collectArguments(fooMh, 0, barMh);
    System.out.println(mh.type()); // prints '(long,double,String,Object,int)void'
}

ここで、mhの型は(long,double,String,Object,int)voidです。

もちろん、引数を受け取らず何も返さないコレクタを用意することもできます。collectArgumentsは、おそらく最も柔軟性の高いコンビネータでしょう。

The MethodHandles::permuteArguments combinator

MethodHandles::permuteArguments を使い、メソッドハンドルのパラメータの順序を変更でき、また特定の引数値を複製し、メソッドハンドルの複数のパラメータにブロードキャストすることもできます。

static void foo(int a0, int a1, int a2, int a3) {
    System.out.println(STR."a0=\{a0} a1=\{a1} a2=\{a2} a3=\{a3}");
}

public static void main(String[] __) throws Throwable {
    MethodHandle fooMh = MethodHandles.lookup().findStatic(Main.class, "foo",
        MethodType.methodType(void.class, int.class, int.class, int.class, int.class));

    MethodType newType = MethodType.methodType(void.class, int.class, int.class, int.class);
    int[] reorder = { 1, 0, 2, 2 };
    MethodHandle mh = MethodHandles.permuteArguments(fooMh, newType, reorder);

    mh.invokeExact(1, 2, 3); // prints 'a0=2 a1=1 a2=3 a3=3'
}

これは以下のJavaコードと等価です。

static void fooPrime(int a0, int a1, int a2) {
    foo(a1, a0, a2, a2);
}

permuteArgumentsは、仮想ラッパーメソッドの型を表すメソッド型と、新しい型の各パラメータがターゲットメソッドハンドル(fooMh)のパラメータにどのように接続されるかが記述されたreorder array(順序変更配列、引数の順序を変更した配列)を受け取ります。

reorder arrayによると、fooMhの最初のパラメータはインデックス1の引数を受け取り、fooMhの2番目のパラメータはインデックス0の引数を受け取り、fooMhの3番目と4番目のパラメータはどちらもインデックス2の引数を受け取ります。

permuteArgumentsの引数重複機能により、ダウンストリームのメソッドハンドルで引数値を複数回使用できるようになります。ダウンストリームのメソッドハンドルで必要のない引数値を削除するために、ダウンストリームのハンドルよりも引数が多い新しいメソッドタイプと、すべてのパラメータインデックスを使用しないreorder arrayを指定することもできます。

static void foo(int a0, int a1) {
    System.out.println(STR."a0=\{a0} a1=\{a1}");
}

public static void main(String[] __) throws Throwable {
    MethodHandle fooMh = MethodHandles.lookup().findStatic(Main.class, "foo",
        MethodType.methodType(void.class, int.class, int.class));

    MethodType newType = MethodType.methodType(void.class, int.class, int.class, int.class);
    int[] reorder = { 0, 1 };
    MethodHandle mh = MethodHandles.permuteArguments(fooMh, newType, reorder);

    mh.invokeExact(1, 2, 3); // prints 'a0=1 a1=2'
}

しかしながら、そのユースケースではMethodHandles::dropArgumentsコンビネーターのほうが便利な可能性があります。

Other combinators

MethodHandlesクラスとMethodHandleクラスには、他にも多くのコンビネータが存在しますが、ここではすべてを紹介することはできません。ここでは、一般的に使用されるコンバイネータのいくつかを紹介しながら、コンビネータがどのように機能し、どのように推論できるかの基本的な概要を示してきましたが、最終的には、すべてのコンビネータを学ぶ最善の方法は、実際に自分で試してみることです。

💡ご覧いただいたように、メソッドハンドルコンビネータにより、ちょっとしたコードスニペットの生成が簡単になります。コンビネータは、本質的には、Javaコードをプログラムで記述するためのAPIです。メソッドハンドルを使用したいもう1つの理由がコンビネータです。

Appendix A: MutableCallSite

MutableCallSiteは、java.lang.invokeパッケージの注目すべきクラスです。基本的には、targetのメソッドハンドルを保持するだけのものです。しかし、特別な点として、targetフィールドが変更可能であっても、(包含するMutableCallSiteインスタンスも定数である限り)JIT はこのフィールドからのロードを定数畳み込みできることがあります。

つまり、メソッドハンドルのインライン最適化を利用しながら、別のメソッドハンドルに交換することも可能な「ほぼ一定」のメソッドハンドルを実質的に持つことができるということです。これを実現するために、JITはコンパイル済みコードに、変更可能なコールサイトの状態に対する「依存関係」を記録し、コールサイトのターゲットが変更されると、コンパイル済みコードは破棄されます。これは強力なツールですが、コードがインタプリタで実行され、JIT によって再度コンパイルされることになるため、控えめに使用する必要があります。

Appendix B: VarHandle

varハンドルは、メモリアクセスに特に関連したメソッドハンドルの束と考えることができます。 単一の invokeメソッドの代わりに、異なるメモリ順序セマンティクスでメモリアクセスを実装するさまざまな get*/set*メソッドがあります。

メソッドハンドルと同様に、varハンドルにも同じパフォーマンス上の注意点が適用されます。get*/set* 呼び出しのレシーバーVarHandleインスタンスは定数である必要があり、呼び出しは正確でなければなりません。ただし、メソッドハンドルには専用のinvokeExactメソッドがあるのに対し、varハンドルにはない点に注意が必要です。varハンドルの正確な呼び出し動作は、VarHandle::withInvokeExactBehavior を呼び出して明示的に有効にする必要があります。このメソッドは、get*/set*がコールサイトのタイプが対応するvarハンドルのアクセスモードのタイプが一致することを確認する新しいvarハンドルを返します。

コメントを残す

このサイトはスパムを低減するために Akismet を使っています。コメントデータの処理方法の詳細はこちらをご覧ください