Updates on Class Initialization in GraalVM Native Image Generation

このエントリは以下のエントリをベースにしています。
This entry is based on the following original one written by Christian Wimmer (VM and compiler researcher at Oracle Labs. Project lead for GraalVM native image generation (Substrate VM)).
https://medium.com/graalvm/updates-on-class-initialization-in-graalvm-native-image-generation-c61faca461f7

tl;dr: GraalVM 19.0以降、ネイティブイメージのアプリケーションクラスはデフォルトで実行時に初期化され、イメージのビルド時ではなくなりました。クラスの初期化動作は、 –initialize-at-build-time = … および –initialize-at-run-time = … というオプションで設定できます。これらのオプションには、クラス名、パッケージ名、 およびパッケージのプレフィックスをカンマ区切りで指定します。クラス初期化の問題をデバッグして理解するために、GraalVM 19.2で -H:+TraceClassInitialization というオプションを導入しました。これは、イメージビルド中にクラス初期化をトリガーしたスタックトレースを収集し、エラーメッセージでスタックトレースを出力するというものです。

GraalVM native-imageツールを使うと、JavaアプリケーションをAhead of Time (AOT) コンパイルして、実行可能ファイルや共有ライブラリを生成できます。Javaコードは従来実行時のJust-in-time (JIT) コンパイルをしていたのですが、AOTコンパイルには2個のアドバンテージがあります。一つはすでに事前にコードをコンパイルして効率的なマシンコードにしているため、起動時間が改善すること、もう一つは、実行時にコードをロードし最適化する基盤を含める必要がないため、Javaアプリケーションのメモリフットプリントを削減します。

ネイティブイメージの生成により、新しい最適化の可能性が開かれます。アプリケーションの一部をイメージビルド時に初期化して、アプリケーションの起動の都度同じ初期化コードを繰り返し実行しなくてすみます。Feature APIを使用すると、到達可能なクラス、メソッド、およびフィールドを見つける静的なpoints-to分析の前またはその間にアプリケーションコードを実行できます。 ビルド時に作成されたオブジェクトは、実行時にいわゆるイメージヒープで使用できます。

Interface Feature
https://www.graalvm.org/sdk/javadoc/org/graalvm/nativeimage/hosted/Feature.html

クラスイニシャライザには、クラスが使用される前、つまりクラスの最初の割り当て、staticメソッド呼び出し、またはstaticフィールドへのアクセスの前に実行されるコードが含まれています。静的な状態を一度初期化するのに便利な場所です。クラスイニシャライザは、Javaコードの静的ブロック(static { … } )として明示的に記述できます。 ただし、staticなフィールドの初期化はすべて(フィールドがfinalかどうかに関係なく)暗黙的にクラスイニシャライザに変換されます。例えば、staticなフィールドを

static final Date TIME = new Date();

として宣言した場合、割り当て部分

TIME = new Date()

はクラスイニシャライザの中にあります。

ほとんどのクラス初期化コードは、外部入力に依存しません。このようなコードをイメージのビルド時に実行すると、アプリケーションに対して大きな影響を与えずにアプリケーション起動時間を短縮できます。したがって、GraalVM Native Imageの元の設計では、イメージビルド中にデフォルトですべてのクラスが初期化され、開発者は必要に応じて動作を上書きできました。昨年のブログエントリでこの動作を説明しました。

Understanding Class Initialization in GraalVM Native Image Generation
https://medium.com/graalvm/understanding-class-initialization-in-graalvm-native-image-generation-d765b7e4d6ed

しかし、このオリジナルの設計は結果としてよろしくない挙動がすぐにあらわになったため、ネイティブイメージを試すだけの場合であっても、クラス初期化を適切に構成する必要がありました。したがって、GraalVM 19.0リリースでは、デフォルトを反転しました。すべてのアプリケーションクラスは、デフォルトで実行時に初期化されます。クラスの初期化の構成は最適化の問題(イメージのビルド時にクラスを初期化すると起動が速くなります)になり、正確性の問題ではなくなりました。

クラスの初期化を以下の2個のコマンドラインオプションを使って構成します。

  • –initialize-at-build-time
  • –initialize-at-run-time

両オプションは、完全修飾されたクラス名、パッケージ名、パッケージプリフィックスのカンマ区切りのリストを取ります。このオプションをコマンドラインで複数回使えますが、順序が重要です。例えば、

--initialize-at-build-time=my.library --initialize-at-run-time=my.library.package.MyClass

を使い、実行時に初期化されるmy.library.package.MyClassを除き、イメージビルド時に my.library で始まる全てのパッケージの全てのクラスを初期化できます。これらのコマンドラインオプションはnative-image.propertiesファイルでライブラリに同梱できます。

Simplifying native-image generation with Maven plugin and embeddable configuration
https://medium.com/graalvm/simplifying-native-image-generation-with-maven-plugin-and-embeddable-configuration-d5b283b92f57
https://logico-jp.io/2019/03/25/simplifying-native-image-generation-with-maven-plugin-and-embeddable-configuration/

クラスがイメージビルド時に初期化するようマークされている場合、全てのスーパークラスもまた、暗黙のうちにイメージビルド時に初期化するようマークされます。クラスが実行時に初期化するようマークされている場合、全てのスーパークラスもまた実行時に初期化するようマークされます。.

多くのクラスイニシャライザはシンプルです。例えば、プレーンなJavaのenum型のクラスイニシャライザは、外部からの入力に依存しないenum変数インスタンスのみ割り当てます。イメージビルド時にこのようなクラスイニシャライザを実行しても、(起動時間が速くなること以外に)目に見える副作用は起こりません。ネイティブイメージジェネレータは、明示的なコマンドラインの設定にかかわらず、各クラスイニシャライザを分析し、イメージビルド時にこのようなクラスを自動的に初期化します。さらに、JDKの全てのクラスはデフォルトでイメージビルド時に初期化されます。JDKが正しく動作するよう、このルールに例外のリストがメンテナンスされています。

Example

前回のエントリの例の一つをもう一度見てみましょう。アプリケーションの起動時間をstatic finalなフィールドにキャッシュし、起動時間と現在の時間を出力します。この例を実行するには、環境変数JAVA_HOMEがGraalVM 19.2リリースを指している必要があります。Community EditionとEnterprise Editionの両方で使えます。GraalVMリリースをダウンロードした後、

gu install native-image

を使ってnative-imageツールをインストールする必要があることに注意してください。

Native Images
https://www.graalvm.org/docs/getting-started/#native-images

package org.graalvm.example;
import java.util.Date;
class HelloStartupTime {
public static void main(String args[]) {
System.out.println("Startup: " + Startup.TIME);
System.out.println("Now: " + new Date());
}
}
class Startup {
static final Date TIME = new Date();
}

標準のJava VMでこの例をコンパイル、実行すると、2個のタイムスタンプが表示されますが、両者は同じもしくは非常に近い時間です。

$JAVA_HOME/bin/javac org/graalvm/example/HelloStartupTime.java
$JAVA_HOME/bin/java org.graalvm.example.HelloStartupTime
Startup: Mon Aug 26 09:48:22 PDT 2019
Now:     Mon Aug 26 09:48:22 PDT 2019

このアプリケーションのネイティブイメージは追加のコマンドラインオプションがなくても正しく動作します。

$JAVA_HOME/bin/native-image org.graalvm.example.HelloStartupTime hellostartup
...
[hellostartup:23841]      [total]:  12,170.72 ms
> ./hellostartup
Startup: Mon Aug 26 09:50:27 PDT 2019
Now:     Mon Aug 26 09:50:27 PDT 2019

全てのアプリケーションのクラスのクラスイニシャライザは、Startupクラスを含めて、実行時に呼び出されます。そのため、同じバイナリで1時間後に呼び出したとしても起動時間は正しい値を示します。

./hellostartup
Startup: Mon Aug 26 10:52:42 PDT 2019
Now:     Mon Aug 26 10:52:42 PDT 2019

アプリケーションの起動時間を改善したいので、イメージ生成時にクラスを初期化することにしましょう。そのため、org.graalvm.exampleというプリフィックスを持つ全てのクラスをイメージビルド時に初期化するように指定します。

$JAVA_HOME/bin/native-image --initialize-at-build-time=org.graalvm.example org.graalvm.example.HelloStartupTime hellostartup_wrong
...
[hellostartup_wrong:23841]      [total]:  11,895.76 ms
> ./hellostartup_wrong
Startup: Mon Aug 26 09:57:29 PDT 2019
Now:     Mon Aug 26 09:59:26 PDT 2019

ご覧の通り、起動時間と現在時刻が大きくずれています。実際、起動時間は実行可能ファイルで固定されています。1時間後にもう一度実行すると、同じ起動時間が出力されます。

./hellostartup_wrong
Startup: Mon Aug 26 09:57:29 PDT 2019
Now:     Mon Aug 26 10:53:12 PDT 2019

再びアプリケーションが正しく動作するよう、手作業でStartupクラスをイメージビルド時の初期化の対象外にして、そのかわりに実行時に初期化する必要があります。

$JAVA_HOME/bin/native-image --initialize-at-build-time=org.graalvm.example --initialize-at-run-time=org.graalvm.example.Startup org.graalvm.example.HelloStartupTime hellostartup_corrected
...
[hellostartup_corrected:23841]      [total]:  11,489.73 ms
> ./hellostartup_corrected
Startup: Mon Aug 26 10:02:35 PDT 2019
Now:     Mon Aug 26 10:02:35 PDT 2019

Tracing Class Initialization

Javaでクラスが初期化される理由の理解とトレースは簡単ではありません。クラスイニシャライザは、他の多くのクラスの再帰的な初期化を呼び出すことができます。最初に、完全なスーパークラス階層を初期化し、続いてクラスが実装する一部の(すべてではない)インターフェースを初期化します。デフォルトメソッドを定義するインターフェースのみが初期化され、デフォルトメソッドのないインターフェースは、インターフェースメソッドが呼び出されても初期化されないまま残ります。続いて、実際のクラスイニシャライザが呼び出されます。他のクラスの静的要素にアクセスするか、他のクラスのインスタンスを割り当てると、それらのクラスの初期化も再帰的に呼び出されます。クラスの初期化は周期的であり(クラスイニシャライザは間接的にイニシャライザ自体に依存する可能性があります)、クラスの初期化は複数のスレッドで同時に開始できるため、状況はさらに複雑です。

結果として、Featureを使ったり、–initialize-at-build-timeを使って一部のクラスを初期化したりすることにより、イメージビルド時の初期化コード実行は、予期せぬ別のクラスの初期化を呼び出す可能性があります。このようなクラスが実行時に初期化するようマークされている場合、native-imageツールが解決できない衝突になってしまい、結果としてイメージ生成に失敗します。

これは、Startup.TIMEの値をメインクラスでもCACHED_TIMEにキャッシュするよう今回のサンプルを少々変更すると起こります。

package org.graalvm.example;
import java.util.Date;
class HelloCachedTime {
static final Date CACHED_TIME = Startup.TIME;
public static void main(String args[]) {
System.out.println("Startup: " + CACHED_TIME);
System.out.println("Now: " + new Date());
}
}
class Startup {
static final Date TIME = new Date();
}

以下のエラーでネイティブイメージの生成に失敗します。

$JAVA_HOME/bin/native-image --initialize-at-build-time=org.graalvm.example --initialize-at-run-time=org.graalvm.example.Startup org.graalvm.example.HelloCachedTime hellocached
...
Error: Classes that should be initialized at run time got initialized during image building:
 org.graalvm.example.Startup the class was requested to be initialized at build time (from the command line).  To see why org.graalvm.example.Startup got initialized use -H:+TraceClassInitialization

この簡単な例で、HelloCachedTimeのクラスイニシャライザが問題を引き起こしていることが簡単にわかります。–initialize-at-build-time=org.graalvm.exampleのためにイメージビルド時にクラスイニシャライザが動作し、静的フィールドのStartup.TIMEを読んでいるためにStartupクラスの初期化が呼び出されます。しかし大きなアプリケーションでは、こうした問題のデバッグは困難です。そのため、GraalVM 19.2ではクラス初期化の理由をトレースする新しいオプション -H:+TraceClassInitialization を追加しました。このオプションを追加すると、エラーメッセージでHelloCachedTimeクラスを問題の原因として明示的に告発するようになります。

$JAVA_HOME/bin/native-image -H:+TraceClassInitialization --initialize-at-build-time=org.graalvm.example --initialize-at-run-time=org.graalvm.example.Startup org.graalvm.example.HelloCachedTime hellocached
...
Error: Classes that should be initialized at run time got initialized during image building:
 org.graalvm.example.Startup the class was requested to be initialized at build time (from the command line). org.graalvm.example.HelloCachedTime caused initialization of this class with the following trace:

クラスイニシャライザは直接相互に呼び出すため、スタックトレースは表示されませんが、このあと、役立つスタックトレースも含まれるより大きなアプリケーションの例をご覧いただきます。上記のサンプルでの問題を解決するためには、次のオプションを付加する必要があります。

--initialize-at-run-time=org.graalvm.example.HelloCachedTime

Example: Tracing the Class Initialization of Netty

効率的なネットワークI/OのためのNettyフレームワークは、多くの最新のJavaアプリケーションの基盤で、多くのマイクロサービスフレームワークで使用されています。少し前に、ネイティブイメージでNettyを使用する方法をご紹介しましたが、多くの必要な置換および構成ファイルがNettyに含まれました。Nettyに含まれる1つの構成ファイル(native-image.properties)で、パッケージプレフィックスio.nettyを持つすべてのクラスがイメージビルド時に初期化されることを指定します。ただし、クラスイニシャライザは外部状態またはイメージヒープに配置できないオブジェクトの割り当てに依存するため、実行時に初期化する必要があるクラスを除きます。

Netty
https://netty.io/
Instant Netty Startup using GraalVM Native Image Generation
https://medium.com/graalvm/instant-netty-startup-using-graalvm-native-image-generation-ed6f14ff7692
native-image.properties
https://github.com/netty/netty/blob/88c2a4cab5add947df50884a6e787f8dff3d85f7/codec-http/src/main/resources/META-INF/native-image/io.netty/codec-http/native-image.properties#L15

シンプルなNetty “Hello, world” がリポジトリにあります。mvn clean packageを実行すると、アプリケーションとNettyの必要要素が入った自己完結型jar(self-contained jar)ファイルができあがります。

Instant Netty startup using GraalVM’s Native Image Generation
https://github.com/cstancu/netty-native-demo

全ての必要なオプションがすでに構成ファイルに含まれているため、ネイティブイメージの生成は

$JAVA_HOME/bin/native-image -jar target/netty-svm-httpserver-full.jar

を呼ぶだけです。

最近、実行時にもう1つのクラスを初期化する必要があることがわかりました。PooledByteBufAllocatorクラスのクラスイニシャライザは、使用可能なプロセッサの数と最大ヒープサイズを照会します。

io.netty.buffer.PooledByteBufAllocator.java
https://github.com/netty/netty/blob/88c2a4cab5add947df50884a6e787f8dff3d85f7/buffer/src/main/java/io/netty/buffer/PooledByteBufAllocator.java#L98

このクラスイニシャライザをイメージビルド時に実行すると、イメージビルド時のプロセッサー数とイメージ生成プロセスのヒープサイズが保持されます。そのため、

--initialize-at-run-time = io.netty.buffer.PooledByteBufAllocator

を使用して、実行時にこのクラスを初期化する必要があるのですが、残念ながら、そのパラメーターを追加するだけではイメージのビルド時にエラーが発生します。

$JAVA_HOME/bin/native-image -jar target/netty-svm-httpserver-full.jar --initialize-at-run-time=io.netty.buffer.PooledByteBufAllocator
...
Error: com.oracle.graal.pointsto.constraints.UnsupportedFeatureException: No instances of io.netty.buffer.PooledByteBufAllocator are allowed in the image heap as this class should be initialized at image runtime. To see how this object got instantiated use -H:+TraceClassInitialization.
Detailed message:
Trace:
    at parsing io.netty.channel.DefaultChannelConfig.<init>(DefaultChannelConfig.java:58)
Call path from entry point to io.netty.channel.DefaultChannelConfig.<init>(Channel, RecvByteBufAllocator):
    at io.netty.channel.DefaultChannelConfig.<init>(DefaultChannelConfig.java:74)
    at io.netty.channel.DefaultChannelConfig.<init>(DefaultChannelConfig.java:71)
    at io.netty.bootstrap.FailedChannel.<init>(FailedChannel.java:30)
    at io.netty.bootstrap.AbstractBootstrap.initAndRegister(AbstractBootstrap.java:319)
    at io.netty.bootstrap.AbstractBootstrap.doBind(AbstractBootstrap.java:271)
    at io.netty.bootstrap.AbstractBootstrap.bind(AbstractBootstrap.java:267)
    at io.netty.bootstrap.AbstractBootstrap.bind(AbstractBootstrap.java:245)
    at netty.svm.httpserver.HttpHelloWorldServer.main(HttpHelloWorldServer.java:60)
    at com.oracle.svm.core.JavaMainWrapper.runCore(JavaMainWrapper.java:151)
    at com.oracle.svm.core.JavaMainWrapper.run(JavaMainWrapper.java:186)
    at com.oracle.svm.core.code.IsolateEnterStub.JavaMainWrapper_run_5087f5482cc9a6abc971913ece43acb471d2631b(generated:0)

エラーメッセージによると、PooledByteBufAllocatorクラスがイメージのビルド時にインスタンス化され、インスタンスがDefaultChannelConfigクラスのコンストラクタから到達可能です。クラスをインスタンス化とは、イメージビルド時にクラスを初期化することも意味しています。これは、実行時にクラスを初期化する明示的なオプションと競合します。しかし、エラーメッセージはインスタンス化が起こったことを伝えるのみで、まだインスタンス化が行われた理由を教えてくれません。以下の新しいオプションがこのギャップを埋めます。

-H:+TraceClassInitialization
$JAVA_HOME/bin/native-image -jar target/netty-svm-httpserver-full.jar -H:+TraceClassInitialization --initialize-at-run-time=io.netty.buffer.PooledByteBufAllocator
...
Error: com.oracle.graal.pointsto.constraints.UnsupportedFeatureException: No instances of io.netty.buffer.PooledByteBufAllocator are allowed in the image heap as this class should be initialized at image runtime. Object has been initialized by the io.netty.buffer.ByteBufAllocator class initializer with a trace:
    at io.netty.buffer.PooledByteBufAllocator.<init>(PooledByteBufAllocator.java:187)
    at io.netty.buffer.PooledByteBufAllocator.<clinit>(PooledByteBufAllocator.java:168)
    at io.netty.buffer.ByteBufUtil.<clinit>(ByteBufUtil.java:84)
    at io.netty.buffer.ByteBufAllocator.<clinit>(ByteBufAllocator.java:24)
.  To fix the issue mark io.netty.buffer.PooledByteBufAllocator for build-time initialization with --initialize-at-build-time=io.netty.buffer.PooledByteBufAllocator or use the the information from the trace to find the culprit and --initialize-at-run-time=<culprit> to prevent its instantiation.
Detailed message:
Trace:
    at parsing io.netty.channel.DefaultChannelConfig.<init>(DefaultChannelConfig.java:58)
Call path from entry point to io.netty.channel.DefaultChannelConfig.<init>(Channel, RecvByteBufAllocator):
    at io.netty.channel.DefaultChannelConfig.<init>(DefaultChannelConfig.java:74)
    at io.netty.channel.DefaultChannelConfig.<init>(DefaultChannelConfig.java:71)
    at io.netty.bootstrap.FailedChannel.<init>(FailedChannel.java:30)
    at io.netty.bootstrap.AbstractBootstrap.initAndRegister(AbstractBootstrap.java:319)
    at io.netty.bootstrap.AbstractBootstrap.doBind(AbstractBootstrap.java:271)
    at io.netty.bootstrap.AbstractBootstrap.bind(AbstractBootstrap.java:267)
    at io.netty.bootstrap.AbstractBootstrap.bind(AbstractBootstrap.java:245)
    at netty.svm.httpserver.HttpHelloWorldServer.main(HttpHelloWorldServer.java:60)
    at com.oracle.svm.core.JavaMainWrapper.runCore(JavaMainWrapper.java:151)
    at com.oracle.svm.core.JavaMainWrapper.run(JavaMainWrapper.java:186)
    at com.oracle.svm.core.code.IsolateEnterStub.JavaMainWrapper_run_5087f5482cc9a6abc971913ece43acb471d2631b(generated:0)

このエラーメッセージは、オブジェクトがByteBufAllocatorのクラスイニシャライザによって割り当てられたことを示しています。スタックトレースは、ByteBufAllocatorクラスのクラスイニシャライザが、ByteBufUtilクラスのクラスイニシャライザを呼び出してから、PooledByteBufAllocatorクラスのクラスイニシャライザを呼び出していることを示しています。したがって、以下のオプションを付けて、1つのクラスだけでなく、3つのクラスを実行時初期化をするように登録する必要があります。

--initialize-at-run-time=io.netty.buffer.PooledByteBufAllocator,io.netty.buffer.ByteBufUtil,io.netty.buffer.ByteBufAllocator

このオプションを使用すれば、イメージの構築が再び成功し、意図したクラスの初期化動作につながります。

Implementation Details of the Tracing

Javaでは、クラスの初期化をトレースし、上記のようにスタックトレースを生成する標準的な方法を提供していません。ネイティブイメージツールはJava HotSpot VM上で実行される単なるJavaアプリケーションであるため、トレースを格納するようにVMを変更するのは実行可能なオプションではありません。その代わりに、バイトコードインストルメンテーション(bytecode instrumentation)を使用し、すべてのアプリケーションクラスのクラスイニシャライザを測定して、スタックトレースに格納します。これにより、特にイメージ生成中に多くのアプリケーションオブジェクトが割り当てられる場合、顕著な時間とメモリオーバーヘッドが加わるため、TraceClassInitializationオプションはデフォルトではオンになりません。

Summary

デフォルトのクラス初期化動作の変更は煩わしいものでしたが、必要と思われたため、GraalVM 19.0リリースの少し前に変更しました。現在では、デフォルトでユーザーにとってすぐに使用できる優れたエクスペリエンスを提供しますが、Nettyなどのフレームワークではnative-image.propertiesファイルでコマンドラインオプションを渡すことで起動時間を改善できます。GraalVM 19.2の新しいオプション -H:+TraceClassInitialization で、イメージビルド時に一部のクラスを初期化し、一部のクラスが実行時に初期化する場合に発生する問題のデバッグが簡単になります。クラスの初期化は、今年のJVM Language Summitの講演でも重要なトピックでした。セッションの内容は以下からご覧いただけます。

コメントを残す

以下に詳細を記入するか、アイコンをクリックしてログインしてください。

WordPress.com ロゴ

WordPress.com アカウントを使ってコメントしています。 ログアウト /  変更 )

Facebook の写真

Facebook アカウントを使ってコメントしています。 ログアウト /  変更 )

%s と連携中