GraalVMのNative ImageでReflectionを使う

はじめに

よくSpringのアプリケーションをNative Image化するときDynamic ProxyやReflectionをゴリゴリ使っているので、設定が大変みたいな話を聞いたことがあるのですが。
じゃあ、実際に使うためにはどんな設定が必要なんだろう。というのが気になったのでまとめます。
このブログでは以下のことを目指します。

  • Reflectionを使ったアプリケーションをNative Image化する際の設定方法を知る

GraalVMやNative Imageについては以前にまとめたブログを書いたので、興味があればそちらを参照していただくか、調べればより良い資料がたくさん出てくるので検索をしてみてください。

そもそも、なぜ設定が必要なのか?

GrarlVMはNative Image化の際にAOT(Ahead Of Time)コンパイルを行います。 AOTコンパイルを行なう際にGraalVMはメモリーフットプリントをなるべく削減するような最適化を行います。この最適化を行なうためには、すべてのクラスはコンパイル時に特定されてなければならず特定されない動的に読み込まれるようなクラスはデフォルトではイメージ内に含まれないようになっているようです。
この制約のため、Dynamic ProxyやReflectionを用いた動的なクラスの読み込みを必要とする実装に関しては事前にその対象となるクラスを設定ファイルに記述し、明示的にコンパイルの対象に含めてやる必要があります。
実際にやったことがあるわけでは無いので想像の域になってしまうのですが、おそらくSpringなどの場合はこの設定の対象となるクラスを特定して設定を記述するハードルが高いのだと思います。
ちなみに、SpringのアプリケーションをNative Image化をサポートするためのプロジェクトがあり、将来的には開発者が自分で設定を書くことなくNative Image化できるようになるようです。

設定をしてみる

環境

動作環境は以下です。

$ uname -srvmpio
Linux 5.3.0-51-generic #44~18.04.2-Ubuntu SMP Thu Apr 23 14:27:18 UTC 2020 x86_64 x86_64 x86_64 GNU/Linux

$ java -version
openjdk version "11.0.6" 2020-01-14
OpenJDK Runtime Environment GraalVM CE 19.3.1 (build 11.0.6+9-jvmci-19.3-b07)
OpenJDK 64-Bit Server VM GraalVM CE 19.3.1 (build 11.0.6+9-jvmci-19.3-b07, mixed mode, sharing)

# 動作確認で利用
$ docker -v
Docker version 19.03.8, build afacb8b7f0

Reflectionを設定なしで使ってみる

まず、Native Imageに含まれるSubstrate VMはReflectionの一部機能をサポートします。
コンパイル時の静的解析で検出可能なメソッドへのアクセスや、フィールドに関しては追加設定なしで、Native Image化できます。
例えば以下のようなコードは追加設定の必要がありません。

Service.java

public class Service {
    private void printHoge() {
        System.out.println("hoge");
    }
}

MainNotProblem.java

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

public class MainNoProblem {
    public static void main(String[] args) {

        Service service = new Service();

        Class<? extends Service> clazz = service.getClass();
        try {
            Method printHoge = clazz.getDeclaredMethod("printHoge");
            printHoge.setAccessible(true);
            printHoge.invoke(service, null);
        } catch (NoSuchMethodException | IllegalAccessException | InvocationTargetException e) {
            e.printStackTrace();
            throw new RuntimeException();
        }
    }
}

このコードは、Native イメージ化しても問題なく動きます。

$ javac MainNotProblem.java
$ native-image MainNotProblem
$ ./mainnoproblem
hoge

しかし、以下のように呼び出しメソットが動的に変わりうる実装の場合は話が変わってきます。

MainWithProblem

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

public class MainWithProblem {
    public static void main(String[] args) {
            callServiceMethod("printHoge");
    }

    static private void callServiceMethod(String methodName) {
        Service service = new Service();

        Class<? extends Service> clazz = service.getClass();
        try {
            Method printHoge = clazz.getDeclaredMethod(methodName);
            printHoge.setAccessible(true);
            printHoge.invoke(service, null);
        } catch (NoSuchMethodException | IllegalAccessException | InvocationTargetException e) {
            e.printStackTrace();
            throw new RuntimeException();
        }
    }

}

Native Image化をすると以下のようなワーニングが出力されます。

$ javac MainWithProblem.java
$ native-image MainWithProblem
Build on Server(pid: 15873, port: 43623)
[mainwithproblem:15873]    classlist:      98.56 ms
[mainwithproblem:15873]        (cap):     296.80 ms
[mainwithproblem:15873]        setup:     438.70 ms
[mainwithproblem:15873]   (typeflow):   3,715.13 ms
[mainwithproblem:15873]    (objects):   4,673.33 ms
[mainwithproblem:15873]   (features):     115.27 ms
[mainwithproblem:15873]     analysis:   8,679.57 ms
[mainwithproblem:15873]     (clinit):      89.11 ms
[mainwithproblem:15873]     universe:     214.90 ms
Warning: Reflection method java.lang.Class.getDeclaredMethod invoked at MainWithProblem.callServiceMethod(MainWithProblem.java:14)
Warning: Aborting stand-alone image build due to reflection use without configuration.
Warning: Use -H:+ReportExceptionStackTraces to print stacktrace of underlying exception
Build on Server(pid: 15873, port: 43623)
[mainwithproblem:15873]    classlist:      95.86 ms
[mainwithproblem:15873]        (cap):     283.66 ms
[mainwithproblem:15873]        setup:     429.13 ms
[mainwithproblem:15873]   (typeflow):   3,949.59 ms
[mainwithproblem:15873]    (objects):   4,872.56 ms
[mainwithproblem:15873]   (features):     118.84 ms
[mainwithproblem:15873]     analysis:   9,147.13 ms
[mainwithproblem:15873]     (clinit):      93.36 ms
[mainwithproblem:15873]     universe:     244.90 ms
[mainwithproblem:15873]      (parse):     310.87 ms
[mainwithproblem:15873]     (inline):     948.00 ms
[mainwithproblem:15873]    (compile):   2,238.19 ms
[mainwithproblem:15873]      compile:   3,753.78 ms
[mainwithproblem:15873]        image:     380.07 ms
[mainwithproblem:15873]        write:      70.03 ms
[mainwithproblem:15873]      [total]:  14,161.68 ms
Warning: Image 'mainwithproblem' is a fallback image that requires a JDK for execution (use --no-fallback to suppress fallback image generation). 

静的に読み込むクラスやメソッド名が特定できない場合は、Native Imageはfallback imageと呼ばれるJDKを必要とする代替イメージを作成します。
このバイナリをJavaのない環境で動かそうとすると以下のようなエラーが吐かれます。

Dockerfile

FROM ubuntu:latest

COPY src/mainwithproblem .

ENTRYPOINT ["./mainwithproblem"]
$ docker build -t native-image-with-ref .
$ docker run native-image-with-ref
Error: No bin/java and no environment variable JAVA_HOME

また、このfallback Imageを作成したくない場合はイメージ作成時に--no-fallbackオプションをつけイメージを作成します。
そうした場合イメージは実行時にNoSuchMethodExceptionを吐いて落ちるようになります。

$ native-image MainWithProblem --no-fallback
$ $ ./mainwithproblem 
java.lang.NoSuchMethodException: Service.printHoge()
    at java.lang.Class.getDeclaredMethod(DynamicHub.java:2475)
    at MainWithProblem.callServiceMethod(MainWithProblem.java:14)
    at MainWithProblem.main(MainWithProblem.java:6)
Exception in thread "main" java.lang.RuntimeException
    at MainWithProblem.callServiceMethod(MainWithProblem.java:19)
    at MainWithProblem.main(MainWithProblem.java:6)

設定を記述してみる

上記のMainWithProblem.javaのようなコードでスタンドアローンなNative Imageを作成する場合は動的に呼び出される可能性のあるクラスやメソッドのために、設定を記述してやる必要があります。
今回作成したサービスクラスに対して設定を記述すると以下のようになります。

reflection-config.json

[
  {
    "name": "Service",
    "methods": [
      {
        "name": "printHoge",
        "parameterTypes": []
      }
    ]
  }
]

nameの項目にはパッケージ名を含んだクラス名を記述します。また、Serviceが持つメソッドやその引数の定義をmethodsに記述しています。
今回は1つのメソッドを持つクラスでしたので上記の設定で終わりですが、もしクラスがフィールドを持つ場合はその設定も別途記述してやる必要があります。
設定のサンプルはこちらの「Manual configuration」の章にあるので参考にしてみてください。

それでは作成した設定を使って、Native Imageを作成してみます。
設定を読み込ませるには-H:ReflectionConfigurationFilesオプションを使います。

$ native-image MainWithProblem -H:ReflectionConfigurationFiles=/path/to/your/config/reflect-config.json
Build on Server(pid: 15873, port: 43623)
[mainwithproblem:15873]    classlist:     108.43 ms
[mainwithproblem:15873]        (cap):     297.78 ms
[mainwithproblem:15873]        setup:     438.91 ms
[mainwithproblem:15873]   (typeflow):   5,419.24 ms
[mainwithproblem:15873]    (objects):   7,770.77 ms
[mainwithproblem:15873]   (features):     113.38 ms
[mainwithproblem:15873]     analysis:  13,485.59 ms
[mainwithproblem:15873]     (clinit):     127.45 ms
[mainwithproblem:15873]     universe:     280.51 ms
[mainwithproblem:15873]      (parse):     411.53 ms
[mainwithproblem:15873]     (inline):   1,054.60 ms
[mainwithproblem:15873]    (compile):   2,214.91 ms
[mainwithproblem:15873]      compile:   3,892.63 ms
[mainwithproblem:15873]        image:     442.91 ms
[mainwithproblem:15873]        write:      77.34 ms
[mainwithproblem:15873]      [total]:  18,771.70 ms

$ ./mainwithproblem 
hoge

先ほどとは違いログにワーニングは吐かれなくなりました。
-H:ReflectionConfigurationFilesオプションは複数の設定ファイルを読み込むことができその場合は,で繋いで設定を読み込ませます。

作成した、イメージはJDKがない環境でも動作します。

$ docker build -t native-image-with-ref .
$ docker run native-image-with-ref
hoge

今回はreflectionを試しましたがDynamic Proxyをしたい場合は別の設定を記述する必要があるようなので、公式を参考にしてください。

参考資料