LoginSignup
7
0

More than 3 years have passed since last update.

【Java】LambdaMetafactoryでリフレクションによる関数呼び出しを直接呼び出しレベルまで高速化する

Last updated at Posted at 2020-12-15

この記事はMicroAd (マイクロアド) Advent Calendar 2020の16日目の記事です。

前書き

Javaのリフレクションによる関数呼び出しは直接呼び出しに比べて低速です。
例えば以下の記事中の検証では、5引数のコンストラクタを呼び出す場合、リフレクションによるConstructor呼び出しは直接呼び出しの1/5程度のスコアとなっています。

この記事では、Constructor/MethodからLambdaMetafactoryを用いてCallSiteを生成し、それを呼び出す形で呼び出しを高速化していきます。
環境はJava8です。

勉強しながら書いているような状況なので、間違った記述が有るかもしれません。
ご指摘いただければ幸いです。

なお、プロジェクト全体はGitHubに上げてあります。
後述するベンチマークは./gradlew jmhで起動できます。

LambdaMetafactoryの使い方

説明のため、コンストラクタとファクトリーメソッドを定義した以下のクラスを用います。

public class SampleClass {
    private final int arg;

    public SampleClass(int arg) {
        this.arg = arg;
    }

    public static SampleClass factory(int arg) {
        return new SampleClass(arg);
    }

    public static SampleClass sum(int arg1, int arg2, int arg3, int arg4, int arg5) {
        return new SampleClass(arg1 + arg2 + arg3 + arg4 + arg5);
    }

    public int getArg() {
        return arg;
    }
}

1引数のstaticメソッド/getterに適用する例

以下はLambdaMetafactoryを用いて引数1つのMethodに対して高速に呼び出せるFunctionを返すコードの例です。
引数をコンストラクタに、lookup.unreflectlookup.unreflectConstructorに変更すればコンストラクタでも同様に使うことができます。

import java.lang.invoke.*;
import java.lang.reflect.Method;
import java.util.function.Function;

public class LambdaMetaFactoryWrapper {
    public static <T, R> Function<T, R> toOptimizedFunction(Method method) throws Throwable {
        // lookupはセキュリティ上の懸念も有るため使い捨てる or publicLookup()を使うのが良さそう
        MethodHandles.Lookup lookup = MethodHandles.lookup();
        // Constructorの場合はlookup.unreflectConstructorを用いる
        MethodHandle methodHandle = lookup.unreflect(method);

        CallSite callSite = LambdaMetafactory.metafactory(
                lookup,
                // 下で指定しているインターフェースの呼び出しインターフェースの名前
                "apply",
                // site.target.invokeExact()した時に出てくるインターフェースのクラス
                MethodType.methodType(Function.class),
                // 引数情報、generic()することでintとIntegerのような指定が齟齬を起こさなくなる
                methodHandle.type().generic(),
                // 呼ばれる関数のMethodHandle
                methodHandle,
                // 引数情報
                methodHandle.type()
        );

        // uncheck castは必須
        @SuppressWarnings("unchecked")
        Function<T, R> function = (Function<T, R>) callSite.getTarget().invokeExact();

        return function;
    }
}

これは、例えば引数1つのstatic関数や、実行のためインスタンスを引数に取るgetterなどに適用できます。

使用例
import org.junit.jupiter.api.Test;

import java.lang.reflect.Method;
import java.util.function.Function;

import static org.junit.jupiter.api.Assertions.assertEquals;

public class LambdaMetaFactoryWrapperTest {
    // static関数に適用した例
    @Test
    void methodTest() throws Throwable {
        Method method = SampleClass.class.getDeclaredMethod("factory", int.class);

        Function<Integer, SampleClass> optimizedFunction = LambdaMetaFactoryWrapper.toOptimizedFunction(method);

        assertEquals(new SampleClass(1).getArg(), optimizedFunction.apply(1).getArg());
    }

    // getterに適用した例
    @Test
    void getterTest() throws Throwable {
        Method method = SampleClass.class.getDeclaredMethod("getArg");

        Function<SampleClass, Integer> optimizedFunction = LambdaMetaFactoryWrapper.toOptimizedFunction(method);

        SampleClass sampleClass = new SampleClass(1);
        assertEquals(sampleClass.getArg(), optimizedFunction.apply(sampleClass));
    }
}

以下、これらのコードを用いて説明をしていきます。

MethodHandles.Lookupの取得について

MethodHandles.Lookupは、今回説明する範囲ではprivatepackage privateといったアクセシビリティの管理のために用いられます。

例えばSampleClass内の関数がprivateになっている場合、SampleClass内でMethodHandles.lookup()して取得したMethodHandles.Lookupを用いなければ、この方法で関数を実行することはできません。
前後でMethod.setAccessibleしてもダメでした。
Java 9以降であれば回避方法も有りそうに見えましたが、Java8では利用できないAPIのようでした。

サンプルコード内のコメントで「lookupはセキュリティ上の懸念も有るため使い捨てる or publicLookup()を使うのが良さそう」と補足しましたが、MethodHandles.Lookupはコンテキスト内の情報を保持するため、フィールドとして保持するのはあまりよろしくないでしょう。
仮に保持するにしてもMethodHandles.publicLookup()publicな内容のみ取っておく程度が良いと思います。

LambdaMetafactoryを用いたCallSiteの生成について

CallSiteLambdaのようなもので、今回の高速化において呼び出される実体です。
LambdaMetafactoryからCallSiteを生成する関数としてはmetafactoryaltMetafactoryが有りますが、metafactoryを用いた生成の方が呼び出しが高速1なため、今回はmetafactoryを用いる形で解説します。

"apply"と生成するインターフェースについて

サンプルコードでは、以下のように"apply"という文字列を引数に指定しています。
これはその下で指定しているFunctionの呼び出し関数名です。

これが例えばToIntFunctionなどであれば"applyAsInt"という文字列を渡すことになります。

サンプルコードから抜粋
                // 下で指定しているインターフェースの呼び出しインターフェースの名前
                "apply",
                // site.target.invokeExact()した時に出てくるインターフェースのクラス
                MethodType.methodType(Function.class),

今回は生成するインターフェースとしてFunctionを指定していますが、これは自分で作成したインターフェースなどでも大丈夫です。

MethodTypeの指定について

サンプルコードでは、LambdaMetafactory.metafactoryの第4・第6引数にMethodTypeを渡しています。
これは関数の引数や戻り値の情報です。
第4引数にgeneric()しているのは、これをやらないと例えばintIntegerのような場合に代入できないことが有るためです。

サンプルコードから抜粋
                // 引数情報、generic()することでintとIntegerのような指定が齟齬を起こさなくなる
                methodHandle.type().generic(),
                // 呼ばれる関数のMethodHandle
                methodHandle,
                // 引数情報
                methodHandle.type()

第4引数にのみgeneric()して第6引数にしない理由は良く分かっていません。
見つけたコードが大体こうしていたのでそれをまねています。
幾つか試行錯誤した限りではこの形での指定が良さそうでした。

また、一時変数を使わずに2つの引数でそれぞれmethodHandleからtype()しているのは、実装を追った感じgeneric()に副作用が有るように見えたためです。

複数引数を要求する場合

次に、SampleClass.sumを題材に、複数引数を要求する場合について説明します。

SampleClass.sumは5引数を要求しますが、用意されている関数型インターフェースは取れて2引数までです。
そのため、ここでは5引数入力可能なインターフェースを自分で用意する形で行います。
なお、FunctionalInterface注釈は無くても動きます。

public interface Function5<P1, P2, P3, P4, P5, R> {
    R invoke(P1 p1, P2 p2, P3 p3, P4 p4, P5 p5);
}

生成方法や利用方法は先ほど説明した内容とほぼ同じです。

import java.lang.invoke.*;
import java.lang.reflect.Method;
import java.util.function.Function;

public class LambdaMetaFactoryWrapper {

    /* toOptimizedFunction関数、既に説明したため省略 */

    public static <P1, P2, P3, P4, P5, R> Function5<P1, P2, P3, P4, P5, R> toOptimizedFunction5(
            Method method) throws Throwable {
        MethodHandles.Lookup lookup = MethodHandles.lookup();
        MethodHandle methodHandle = lookup.unreflect(method);

        CallSite callSite = LambdaMetafactory.metafactory(
                lookup,
                "invoke", // Function5はinvokeで呼び出すため、引数も"invoke"に変更している
                MethodType.methodType(Function5.class),
                methodHandle.type().generic(),
                methodHandle,
                methodHandle.type()
        );

        //noinspection unchecked
        return (Function5<P1, P2, P3, P4, P5, R>) callSite.getTarget().invokeExact();
    }
}
使用例
import org.junit.jupiter.api.Test;

import java.lang.reflect.Method;
import java.util.function.Function;

import static org.junit.jupiter.api.Assertions.assertEquals;

public class LambdaMetaFactoryWrapperTest {

    /* 既に説明しているため省略 */

    // 5引数のstatic関数に適用した例
    @Test
    void function5Test() throws Throwable {
        Method method =
                SampleClass.class.getDeclaredMethod("sum", int.class, int.class, int.class, int.class, int.class);

        Function5<Integer, Integer, Integer, Integer, Integer, SampleClass> optimizedFunction =
                LambdaMetaFactoryWrapper.toOptimizedFunction5(method);

        assertEquals(15, optimizedFunction.invoke(1, 2, 3, 4, 5).getArg());
    }
}

ベンチマーク

最後に、5引数のstaticメソッド(SampleClass.sum)について、直接呼び出し、Method呼び出し、LambdaMetafactoryによる高速呼び出しの3種類をJMHによるベンチマークで比較します。

ベンチマークにはJMH Gradle Plugin(me.champeau.gradle.jmh)を利用します。
オプションは以下の通りです。

build.gradle.kts(抜粋)
jmh {
    failOnError = true
    isIncludeTests = false

    resultFormat = "CSV"
}

ベンチマークは以下のコードで行います。

import org.openjdk.jmh.annotations.Benchmark;
import org.openjdk.jmh.annotations.Scope;
import org.openjdk.jmh.annotations.State;

import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;

@State(Scope.Benchmark)
public class OptimizedFunction5Benchmark {
    private Method method;
    private Function5<Integer, Integer, Integer, Integer, Integer, SampleClass> function5;

    public OptimizedFunction5Benchmark() {
        try {
            method =
                    SampleClass.class.getDeclaredMethod("sum", int.class, int.class, int.class, int.class, int.class);
            function5 = LambdaMetaFactoryWrapper.toOptimizedFunction5(method);
        } catch (Throwable t) {
            System.out.println(t);
            method = null;
            function5 = null;
        }
    }

    @Benchmark
    public SampleClass directCall() {
        return SampleClass.sum(1, 2, 3, 4, 5);
    }

    @Benchmark
    public SampleClass methodCall() throws InvocationTargetException, IllegalAccessException {
        return (SampleClass) method.invoke(null, 1, 2, 3, 4, 5);
    }

    @Benchmark
    public SampleClass optimizedFunction5Call() {
        return function5.invoke(1, 2, 3, 4, 5);
    }
}

なお、冒頭で述べた通りこれらの内容はGitHubにアップロード済みで、ベンチマークは./gradlew jmhで起動できます。

結果と考察

以下は手元のRyzen7 3700XWindows環境での計測結果で、スコアは高いほど良いです。

結果としては、直接呼び出しが最も良く、紹介した手法はそれに次ぐスコアとなり、Methodを呼び出すよりも圧倒的に高速なことが分かります。

Benchmark                                            Mode  Cnt          Score        Error  Units
OptimizedFunction5Benchmark.directCall              thrpt   25  202126189.206 ± 286352.530  ops/s
OptimizedFunction5Benchmark.methodCall              thrpt   25   48196055.765 ± 176281.150  ops/s
OptimizedFunction5Benchmark.optimizedFunction5Call  thrpt   25  192184568.216 ± 345476.531  ops/s

終わりに

今回はConstructor/MethodからLambdaMetafactoryを用いてCallSiteを生成し、それを呼び出す形で呼び出しを高速化する方法を紹介しました。
自分の知識が足りないことも有って「何故上手く動かないのか」と詰まってばかりでしたが、どうにかこうにか動きそうな形にできて良かったです。

とは言え自分の知識不足から把握できない部分が多く、「これConstructor/Methodで良くないか……?」と感じる点も多く有り、「使いこなせる気がしない」というのが感想です。
圧倒的に高速なことは確かなので、自作ライブラリに組み込めたらいいなとは思いますが……。

何にせよもう少し研究を続けてみます。

参考にさせて頂いた記事

7
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
7
0