HN 日本語サマリー

← 一覧へ戻る
プログラミング

Javaの新しいAOTキャッシュ機能はまだ未熟な状態である

Java's new AOT cache feature is in a rough state (xpipe.io)

15 pointsby theanonymousone3 コメント

要約

Javaの新しいAhead-of-Time (AOT) キャッシュ機能は、アプリケーションの起動時間とウォームアップ時間を短縮することを目的としていますが、実運用では多くの問題に直面しています。この機能は、トレーニング実行中にJVMが記録したクラスロードやメソッド呼び出しなどの情報を利用して、起動時の初期化作業をスキップします。しかし、CPU機能の非互換性によるJVMクラッシュや、クラスリンキングの不安定さ、ドキュメント不足など、多くの課題が存在し、実用には注意が必要です。

全文翻訳

Javaの新しいAOTキャッシュ機能はまだ未熟な状態である

はじめに

AOT (Ahead-of-Time) キャッシュは、Javaアプリケーションの起動時間とウォームアップ時間を改善するためのJDKの最新のアプローチです。その考え方はシンプルです。アプリケーションのトレーニング実行を行い、その間にJVMはアプリケーションが行うこと、例えばどのクラスをロード・リンクするか、どのメソッドを呼び出すかなどを記録します。後でアプリケーションが起動されると、JVMはすでに完了している初期化作業の多くをスキップし、すぐにロードおよびリンクされた状態ですべてのクラスを利用できるようになります。

理論上、これはアプリケーションを変更することなく、起動パフォーマンスを無料で向上させるものです。実際には、XPipeで行っているような、ユーザーに配布する実世界のアプリケーションで使用すると、多くの落とし穴があることを私たちは学びました。最良の場合、AOTキャッシュは互換性の問題で適用されず、何も得られません。最悪の場合、注意しないと、JVMが予期せずクラッシュします。つまり、無料の起動ブーストですが、特定の条件下でのみ、そして無料のクラッシュが含まれています。

この投稿が、この機能を展開する際に私たちが経験したのと同じ問題にあなたが遭遇するのを防ぐことを願っています。最後に、私たちが長年かけて学んだことすべてに基づいて、アプリケーションで使用しているビルドスクリプトを見つけることができます。この投稿にはパフォーマンス測定や比較は含まれていないことに注意してください。それは全く別の話です。この投稿は、カバーしている内容にしてはすでに長すぎます。

コンセプト

JDKのAOTコンセプトに慣れている場合は、このセクションをスキップできます。そうでない場合は、AOT最適化のトピックに関連するすべての部分の簡単な概要を知ることができます。

タイムライン

AOTキャッシュ自体は、導入以来、各JDKリリースで拡張されており、各ステップはJEP (JDK Enhancement Proposal) で指定されています。

クラスデータ共有 (CDS)

AOTキャッシュはゼロから構築されるわけではありません。これは、JDK 5以来存在するHotSpotの機能であるクラスデータ共有 (CDS) の自然な進化です。CDSは、JVMが起動時にメモリに直接マッピングできるアーカイブファイルに解析済みクラスメタデータを格納します。これは、JDK 10でのアプリケーションクラスデータ共有、JDK 12以降のすべてのJDKに同梱されているデフォルトのCDSアーカイブ (JDKクラス用)、およびJDK 13での動的CDSアーカイブで時間とともに拡張されました。AOTキャッシュは同じインフラストラクチャを使用しているため、AOTキャッシュを扱う際に、多くの場所で「共有アーカイブ」や cds ログタグなどのCDS用語を目にすることになります。

AOTクラスリンキング

AOTキャッシュとAOTクラスリンキングはしばしば互換的に使用されますが、同じものではありません。AOTキャッシュはファイルであり、さまざまな種類のデータを含めることができます。ロードされたクラスデータ (CDSから継承された部分)、メソッドプロファイル、キャッシュされたJavaオブジェクト、一部のネイティブコード、および事前リンクされたクラスです。AOTクラスリンキングは、これらの最適化の1つにすぎません。AOTキャッシュを作成するときに自動的にオンになります (-XX:+AOTClassLinking)。クラスをすでにリンクされた状態で格納するため、JVMは起動時にその作業をスキップできます。クラスリンキングを有効にしたいのは、そうしないと起動パフォーマンスの向上が得られないからです。

GraalVMネイティブイメージ

AOTキャッシュをGraalVMネイティブイメージと混同しないでください。ネイティブイメージは、アプリケーション全体を事前にコンパイルして、JVMなしで実行されるスタンドアロンのネイティブ実行可能ファイルにします。そのためには、クローズドワールド仮説を採用する必要があります。つまり、実行時に実行可能なすべてのコードはビルド時に知られている必要があり、リフレクション、動的なクラスロード、リソースなどの動的な機能は明示的に設定する必要があります。その見返りとして、非常に高速な起動と低いメモリ使用量が得られます。

AOTキャッシュは異なるアプローチを取ります。アプリケーションは、JITコンパイラを備えた通常のJVM上で引き続き実行され、Javaの完全な動的機能を持っています。そのため、アプリケーションを変更したり設定したりする必要はありません。キャッシュは、JVMが anyway に行う作業を高速化するだけです。使用できない場合は、JVMは通常どおり実行時にすべての作業を行うようにフォールバックします。したがって、起動時間の改善はネイティブイメージよりも小さいですが、その制限に対処する必要もありません。GraalVMネイティブイメージの問題をトラブルシューティングしたことがあるなら、それらはおそらくおなじみでしょう。

CPUの非互換性

理論上、AOTキャッシュのアイデア全体は、クラスデータのみに影響し、ネイティブコンパイルを実行しないようなものに聞こえます。なぜなら、もしそうなら、トレーニングシステムと実際のシステムの個々のCPU機能が異なると、あらゆる種類の問題に遭遇するからです。CPUアーキテクチャが同じであってもです。GraalVMネイティブイメージを使用して、CPU機能が不足しているシステムでクラッシュしたことがある人は、おそらくこれを、そしてGraalVMビルド引数 -march=compatibility を知っているでしょう。

実際には、AOTキャッシュとクラスリンキングは小さなネイティブコード部分を生成することが判明しました。トレーニングシステムがAVXのようなものをサポートしており、実行システムがサポートしていない場合(またはAVX-512のサポートがないなど、JDKの-XX:UseAVX=3レベルに対応する場合)に、この結果に遭遇します。なぜなら、その場合、JVMは次のようなエラーでクラッシュするからです。

A fatal error has been detected by the Java Runtime Environment:

SIGILL (0x4) at pc=0x00007fcc85ae2613, pid=4054, tid=4147

JRE version: OpenJDK Runtime Environment Zulu25.32+21-CA (25.0.2+10) (build 25.0.2+10-LTS)
Java VM: OpenJDK 64-Bit Server VM Zulu25.32+21-CA (25.0.2+10-LTS, mixed mode, sharing, tiered, compressed oops, compact obj headers, g1 gc, linux-amd64)

Problematic frame: 
v ~AdapterBlob 0x00007fcc85ae2613

または

#
# A fatal error has been detected by the Java Runtime Environment:
#
#  EXCEPTION_ILLEGAL_INSTRUCTION (0xc000001d) at pc=0x000001d00d9f6c2b, pid=39700, tid=37844
#
# JRE version: OpenJDK Runtime Environment Zulu25.30+17-CA (25.0.1+8) (build 25.0.1+8-LTS)
# Java VM: OpenJDK 64-Bit Server VM Zulu25.30+17-CA (25.0.1+8-LTS, mixed mode, sharing, tiered, compressed oops, compact obj headers, g1 gc, windows-amd64)
# Problematic frame: 
# v  ~AdapterBlob 0x000001d00d9f6c2b

これは、例えば、AVXのCPU機能サポートレベルが異なる第13世代Intel Core CPUでトレーニングされたAOTキャッシュを持つアプリケーションを、第12世代Intel Core CPUを搭載したターゲットシステムで実行できないことを意味します。もし、新しい世代のCPUしか持っておらず、古い世代のハードウェアをまだ実行している顧客にアプリを配布する場合、基本的なテストさえ行っていないと思われる顧客から不満が出るでしょう。

アダプターキャッシュ

判明したところによると、これはJDK 25でJDK-8350209: Preserve adapters in AOT cacheで追加されたアダプターキャッシュを生成します。アダプターは、解釈実行とコンパイル済みJava呼び出しを切り替える小さなマシンコードの断片です(詳細な説明については、HotSpotソースを参照してください)。これらは、アプリケーションが後で別のシステムで実行される可能性を考慮せずに、キャッシュをビルドしたマシンのCPU機能のために生成されます。

キャッシュされたアダプターで使用されているCPU機能が不足しているシステムがそれらのいずれかを呼び出すとすぐに、JVMはクラッシュします。これはデスクトップアプリケーションがユーザーに配布される場合だけでなく、バックエンドサーバーでも問題となります。AOTキャッシュは、CIパイプラインやコンテナイメージビルドで作成され、ビルドマシンとはCPUが異なるマシンにデプロイされることがよくあります。例えば、クラスターに異なるインスタンスタイプが含まれている場合や、クラウドプロバイダーがワークロードを別のハードウェアに移動した場合などです。

どうしてこれがAOTキャッシュのデフォルトの動作なのでしょうか?

アダプターキャッシュの無効化

AOTアダプターキャッシュは、AOTキャッシュ生成実行時に引数 -XX:+UnlockDiagnosticVMOptions -XX:-AOTAdapterCaching を渡すことで無効にできることが判明しました。このオプションはどこにも説明されておらず、ドキュメント化もされていません。JDKソースを読む必要があります。さらに混乱するのは、このオプションがデフォルトで false として宣言されていることです(JDKソースを参照)。しかし、キャッシュにAOTリンククラスが含まれている場合、AOTキャッシュ生成の実装によって自動的にオンになります(JDK 25ではこちらを参照)。これは、アセンブリ実行中に値が true {ergonomic} と表示される -XX:+PrintFlagsFinal で確認できます。したがって、デフォルトケースを除いて、デフォルトでは無効になっています。

これらの引数を渡すことは、AOTキャッシュを使用する場合、基本的に必須です。そうでなければ、ユーザーはクラッシュに遭遇します。この問題は最終的にJDK-8388703として報告されました。JDK 27では、代わりにAOTキャッシュのネイティブコード部分(現在はアダプターが含まれています)のトレーニングマシンのCPU機能を格納し、ネイティブコード部分が正確なCPU機能の一致の場合にのみキャッシュされたアダプターを使用するようになります。

AOTコードキャッシュとは何か

さらに混乱を招くことに、「AOTコードキャッシュ」という用語は2つの異なる意味を持ちます。コンピュータサイエンスには2つの難しいことしかないと言われますが、キャッシュの無効化と名前付けであり、これは両方に関わっています。JDKソースコードとログでは、AOTコードキャッシュ (AOTCodeCache) は、ネイティブコードを含むAOTキャッシュの部分です。これはJDK 25から存在し、現在は上記のアダプターが含まれています。したがって、AOT Code Cache is not used: AOT Class Linking is not used や AOT Code Cache disabled: cpu features are incompatible のようなログメッセージを見たり、AOTコードキャッシュに関するバグレポートを読んだりする場合、それらは主にアダプターに関するものであり、コンパイル済みのJavaコードに関するものではありません。また、JITコンパイラが実行時にコンパイル済みコードを格納する通常のコードキャッシュとも異なります。JDK 27で言及されたCPU機能チェックもこれの一部です。Javaコードの事前コンパイル、つまりほとんどの人がAOTコードキャッシュという言葉を聞いて考えるであろうこと、はJDK 28でしか利用できず、コンパイルされたコードはこの同じAOTコードキャッシュに格納されます。

JDK 28でのAOTコードコンパイル

JDK 28では、JEP 544: Ahead-of-Time Code Compilation という追加機能がリリースされます。これは、その名の通り、アダプターだけでなく、より多くのコードを含むネイティブコードコンパイルもキャッシュに対して行うことになります。JEPによると、CPU機能に不一致がある場合、AOTコードはロードされないため、少なくともクラッシュは回避されます。これはJDK 27にすでに存在するCPU機能チェックを使用しており、正確な一致が必要です。x86では、ハイパースレッディングを除くすべてのCPU機能が同一である必要があります。そのため、トレーニングマシンよりも多くの機能をサポートする新しいCPUであっても、AOTコードはロードされません。

しかし、これは、ユーザーに配布されるアプリケーションにとっては、ユーザーのCPUがビルドマシンのCPUと正確に一致する可能性は非常に低いため、この機能を非常に役に立たないものにするでしょう。必要なのは、すべてのオプションCPU機能を無効にする互換性スイッチのようなものかもしれません。確かに、コンパイル済みコードのパフォーマンスは低下しますが、その代わりに起動時間は速くなります。技術的には、比較されるCPU機能はJVMで有効になっている機能であり、-XX:UseAVX のようなオプションで削減できます(こちらを参照)。しかし、トレーニング実行と実際のアプリケーション実行の両方に同じオプションを渡す必要があり、すべてのCPU機能にそのようなオプションがあるわけではないため、それは本当の解決策ではありません。これには適切な互換モードスイッチが必要です。

ログの解析

AOTキャッシュを扱う際、JVM引数 -Xlog:aot があなたの親友です。確かに、すべての質問に謎かけで答えるような友達ですが。AOTキャッシュを生成する際の詳細を表示し、ビルドしたアプリケーションを実行する際にAOTキャッシュが正しくロードされているかどうかも表示します。ただし、出力は非常に理解しにくく、誤解しやすいので、いくつかの落とし穴があります。

2回の実行がログに入る

JEP 514: Ahead-of-Time Command-Line Ergonomics は、AOTキャッシュをビルドするための簡単なワークフローを導入しました。2つのステップを実行する代わりに、現在はアプリケーションの1回の呼び出しでAOTキャッシュをビルドできます。しかし、技術的にはまだ2つの別々のJVM実行を実行しており、その事実を隠しているだけです。その結果、2つの呼び出しからのログが1つとして表示されますが、実際にAOTキャッシュを書き込むアセンブリ実行のみが、キャッシュに何が格納されるかを示します。両者は非常に似ています。

それに気づいていないと、間違ったログをデバッグするのに多くの時間を費やすことになります。そして、2つの別々のログが連続して表示され、キャッシュの内容に関連するのは後者だけであることに気づくまで、デバッグを続けるでしょう。したがって、CTRL+F を使用する際は注意し、正しいバージョンのログを見ているか確認してください。

紛らわしいログメッセージ

さらに、ログメッセージには関連情報がほとんど欠けており、それを裏付けるドキュメントはほとんどありません。例えば、AOTキャッシュをアセンブルする実行は次のようなログを生成します。

[0.024s][info][aot] full module graph: disabled
[0.024s][info][aot] initial optimized module handling: enabled
[0.024s][info][aot] initial full module graph: disabled
[0.024s][info][aot] Using AOT-linked classes: false (static archive: no aot-linked classes)
[0.032s][info][aot] JVM_StartThread() ignored: java.lang.ref.Reference$ReferenceHandler
[0.032s][info][aot] JVM_StartThread() ignored: java.lang.ref.Finalizer$FinalizerThread
[0.050s][info][aot] Not starting management agent during creation of AOT cache.

さて、最初の質問はおそらくこうでしょう。フルモジュールグラフとは何か、なぜ無効になっているのか、どうすれば有効にできるのか、それはAOTキャッシュのパフォーマンスに影響するか?すべての答えを得るには、JDKソースコードを参照する必要があります。なぜなら、それはほとんど説明されていないからです。特定の機能が無効になっている理由も説明されていないため、さらに掘り下げる必要があります。無効な機能の影響についても文書化されていません。

java manpageに含まれる数少ない関連情報は、実際のAhead-of-Timeキャッシュセクションではなく、CDSセクションにリストされています。そのため、どこを見ればよいかを知っておく必要があります。しかし、それらの説明はCDSを念頭に置いて書かれているため、AOTキャッシュに翻訳し、どの部分が適用されるかを知る必要があります。しかし、関連情報はごくわずかです。

さらに混乱させることに、上記の full module graph: disabled の行は、完全に機能しているセットアップでも、すべてのアセンブリ実行の開始時に印刷されます。フルモジュールグラフとAOTクラスリンキングが実際に機能したかどうかは、ログの最後に、Full module graph = enabled のような行と、クラスサマリー instance classes = 9033, aot-linked = 9033, ... のみで表示されます。何かがうまくいかなかった場合、代わりに archivedBootLayer not available, disabling full module graph のようなエラーが途中に表示され、aot-linked = 0 と共に表示されます。したがって、無効になっているものに関する一部のメッセージは無視できますが、見た目が非常に似ている他のメッセージは関連性があります。

要約すると、ほとんどすべてに関してドキュメントが不足しており、直感的でないログステートメントがあり、AOTエルゴノミクスのための不透明なワークフローがあります。何が起こっているのかを確実に理解するだけでも、多くの時間がかかります。

壊れやすいクラスリンキング

AOTクラスリンキングはAOTキャッシュの最も壊れやすい部分です。なぜなら、アプリケーションの完全なモジュールセットアップもキャッシュに格納する必要がある(いわゆるフルモジュールグラフ)ため、多くの条件がそれを静かに無効にするからです。そうなった場合でもAOTキャッシュは作成され使用されますが、クラスは実行時に再度リンクする必要があるため、起動時間の向上の部分が失われます。したがって、AOTキャッシュが正常にロードされたからといって、クラスリンキングが機能しているとは限りません。AOTキャッシュのアセンブルという手間をクラスリンキングなしで行うことは、その目的の一部を台無しにするため、目標はクラスリンキングを有効にすることです。

残念ながら、AOTキャッシュをアセンブルする際、AOTクラスリンキング機能があなたを狙い、機会があればすべて最適化を無効にするように感じられることがあります。多くの場合、その理由を教えてくれません。互換性のないJVMオプションについては、少なくともログメッセージが表示されます(JDK 24では -Xlog:cds のみで、-Xlog:aot では表示されません。誰かがログタグの名前を変更するのを忘れたのでしょう)。以下の他の例では、ログは無効になった理由を教えてくれません。

モジュールオプション

JDK 24のAOTキャッシュの最初のリリースでは、リフレクションで何かが必要な場合でも、--add-opens を指定することさえできませんでした。--add-opens、--add-exports、--add-reads はすべて互換性がないと見なされ、フルモジュールグラフとそれに伴うAOTクラスリンキングが無効になりました。少なくともそれはJDK 25で修正されました。

GraalVM

GraalVMのような別のJDK実装を使用すると、さらに問題が発生します。GraalVMには、JVMCI(Java-Level JVM Compiler Interface)を介した高度なJIT機能を提供するために、標準JDK実装にはない jdk.internal.vm.ci のような追加モジュールが含まれています。このモジュールは実行時に存在しますが、GraalVMがネイティブJITコンパイラライブラリを使用する場合、アプリケーションのモジュールグラフに自動的に追加されません(こちらを参照)。その結果、GraalVMでAOTキャッシュをビルドする際に最適化が無効になっている理由をログから特定する必要があり、最終的にAOTトレーニング実行で --add-modules jdk.internal.vm.ci が必要であることを発見することになります。

finalフィールドのミューテーション警告

次に、どのJVM引数を有効にするかに注意する必要があります。JDK 26を使用している場合、リフレクションを介してfinalフィールドが変更されると警告が表示されます(JEP 500: Prepare to Make Final Mean Final)。理論的には良いアイデアですが、実際には依存関係がそれをトリガーする場合(例えば、一部のJacksonの操作など)は非常に迷惑です。これらの警告を削除するには、--enable-final-field-mutation=... を渡す必要があります。しかし、そのオプションは、再び最適化されたモジュール処理を無効にします(--illegal-final-field-mutation および --illegal-native-access も同様です)。技術的な理由でそれが厳密に必要である理由はありません。なぜ自分でリスクを負って使用させてくれないのでしょうか?現状では、サードパーティの依存関係の不正なミューテーションに対する実行時警告か、クラスリンキングがないかのどちらかです。

インキュベーション中のモジュール

さまざまなモジュール構成が最適化を無効にします(文書化されていないため、自分で見つける必要があります)。例えば、JDKまたはJavaFXのインキュベーション中のモジュールを使用すると、AOTクラスリンキングは不可能になります。JavaFXのインキュベーターモジュールは、JDKのものと同じインキュベーションフラグでビルドされます。モジュールグラフにインキュベーション中のモジュールが含まれている場合、ブートレイヤーはアーカイブされません。これにより、フルモジュールグラフとクラスリンキングが無効になります。おそらく、アーカイブされたブートレイヤーが煩わしいインキュベーションモジュール警告を表示するモジュールシステムの初期化をスキップする単純な理由からであり、深い技術的な理由からではありません。インキュベーション中のJavaFXリッチテキストモジュールを追加した後、クラスリンキングが突然無効になった理由を理解するのにもかなりの時間がかかりました。

ベンチマークへの影響

これらの問題すべてが比較を非常に困難にしています。開発者がこの機能の利点を評価するには、一部の最適化が無効にならないように非常に注意する必要があるのに、どのように評価できるでしょうか?つい最近、誰かがGraalVMを使用したAOTベンチマークを投稿しているのを見かけ、私は思いました。その人が実際にaotログを検査し、曖昧なメッセージをすべて理解しない限り、GraalVMの異なるモジュールリストが多くの最適化を無効にしたことに気づいていない可能性が高いです。

他の多くの落とし穴も同様です。JDK25でアプリケーションにAOTを慎重に設定し、JDK26にアップグレードし、これらの迷惑な不正なミューテーション警告を見て、単に無効にしたとします。なぜなら、別に問題ないからです。しかし、そうすることで、AOTクラスリンキングがなくなったことに気づいていない可能性が非常に高いです。

クラスリンキングが無効になった場合に失敗する方法もないため、検出できません。CIパイプラインが何らかの理由でAOTクラスリンキングが無効になった場合に失敗するようにしたい場合、それはできません。そのため、簡単にキャッチすることはできません。

AOTキャッシュはどこにあるのか?

以前は、CDSでは、JVMがデータを簡単に見つけられないということはありませんでした。デフォルトのCDSアーカイブは、JDK内の固定された場所(Windowsでは lib/server/classes.jsa または bin ools ools.jar)にあるか、必要に応じてカスタマイズできる場所にありました。関連するJVM実行環境は1つだけでした。

AOTでは、複数の独立したJVM実行があります。トレーニング実行、キャッシュアセンブリ実行、および実際のターゲットシステムでのアプリケーション実行です。より複雑なアプリケーションの場合、複数の異なるAOTキャッシュが存在する可能性もあります。例えば、jlinkで複数のランチャーをビルドし、jpackageで複数のネイティブ実行可能ファイルをビルドする場合などです。トレーニング実行とアセンブリ実行は -XX:AOTCacheOutput を介してキャッシュの場所を取得しますが、アプリケーションの実際の実行のためには、-XX:AOTCache を使用してJVMにキャッシュデータがどこにあるかを伝える必要があります。そうしないとロードされず、デフォルト値や自動検索もありません。

相対パス

これは、相対ファイルパスに関しては実際にはそれほど簡単ではありません。なぜなら、オプションは基本的にそのように使用する必要があるからです。絶対ファイルパスは、アプリケーションがターゲットシステム上の任意のディレクトリ位置に配置される可能性があるため、AOTキャッシュには意味がありません。-XX:AOTCache に相対パスを指定できますが、これは作業ディレクトリからの相対パスであり、実行可能ファイルからの相対パスではありません。そのため、別の作業ディレクトリでアプリを実行する人は誰でもAOTキャッシュを壊すことになります。

jpackageインストールレイアウト

jlinkとjpackageを使用してアプリケーションのランタイムイメージを生成する場合(現代のJDKの意図された方法)、プラットフォームごとに異なるインストールレイアウトにも対処する必要があるため、プラットフォーム固有のAOTキャッシュの場所が必要になります。また、インストールルートからのパスを指定する方法を知る必要もあります(JDKソースコードを参照する必要があるため、jpackageドキュメントには言及されていません)。要約すると、jpackageのアプリケーション.cfgファイルでAOTキャッシュの場所を次のように指定する必要があります。

  • Windows: java-options=-XX:AOTCache=$ROOTDIR/runtime/bin/server/cache.aot
  • Linux: java-options=-XX:AOTCache=$APPDIR/../../lib/runtime/lib/server/cache.aot
  • macOS: java-options=-XX:AOTCache=$ROOTDIR/Contents/runtime/Contents/Home/lib/server/cache.aot

変数 $ROOTDIR と $APPDIR は、起動時にjpackageランチャーによって自動的に置き換えられます。$ROOTDIR はLinuxではうまく機能しないため、$APPDIR が回避策として使用されます(これは別の話です)。

後で生成する

AOTキャッシュは、本番環境でのアプリケーションの最初の起動前に生成する必要はありません。CDSと同様に、後で動的に生成することもできます。実際のキャッシュファイルに対するAOTCacheオプションの処理が改善されれば、それも可能になるでしょう。

しかし、現時点では、新しく生成されたAOTキャッシュをターゲットシステム上の適切な場所に配置すること、特に読み取り専用のインストールディレクトリを考慮に入れることは、簡単ではありません。デフォルトのファイルパスはありません。ユーザーのターゲットシステム上の固定絶対パスを使用することはできません。環境変数を使用することも、-XX:AOTCache ファイルにはできません。相対ファイルパスを指定できますが、作業ディレクトリからの相対パスのみです。インストールディレクトリが読み取り専用であるか、権限がない場合、希望する場所にAOTキャッシュを生成することはできません。

EDRの苦闘

もちろん、JVMTIを介してクラスローディングにフックするエージェント(ClassFileLoadHook)は、AOTリンククラスを含む場合、AOTキャッシュのロードを防ぎます。これは、以前に説明したAOTキャッシュの壊れやすさに関する例に基づけば驚くことではありません。しかし、実際には、これはより予期しない問題を引き起こします。

起動時にこのようなエージェントがアタッチされていると、AOTキャッシュは使用できず、次のようなエラーメッセージが表示されます。

[error][aot] AOT cache has aot-linked classes. It cannot be used when JVMTI ClassFileLoadHook is in use.
[error][aot] An error has occurred while processing the AOT cache.
[error][aot] Unable to map shared spaces

これは致命的なエラーではありませんが、何が問題なのでしょうか?それは、これらのエラーが標準エラー出力ではなく標準出力に印刷されるため、意図せずにアプリケーションの出力を壊す可能性があります。例えば、SentinelOneは起動されたすべてのJavaアプリケーションにエージェントをアタッチするため、デフォルトで常にこのエラーを印刷します。

ユーザーがアプリケーションの出力に依存している場合(例えば、API_KEY=$(myapp generate-key) のように)、EDRまたは同様のものがエージェントをアタッチしようとすると、ワークフローが壊れます。なぜなら、コマンドの通常の1行出力が、上にAOTエラーが表示された複数行になるからです。

本番ビルドでは、-Xlog:disable を使用してアプリを実行する必要があります。または、-Xlog:disable -Xlog:all=warning:stderr のようにして、警告やエラーが通常あるべき標準エラー出力に保持する必要があります。

まとめ

すべてがどのように連携するかを理解するために、AOTキャッシュを作成するために使用するGradleタスクの簡略化されたバージョンを以下に示します。これは、jlinkとjpackageが現在のプラットフォームのアプリケーションイメージを作成した後で実行され、インストーラーにパッケージ化される前にAOTキャッシュをそのイメージに追加します。

def distDir = "${project.layout.buildDirectory.get()}/dist"

tasks.register('trainAot', DefaultTask) {
    doLast {
        // 通常、トレーニング実行中にアプリケーションの動作を少し変えたい場合があります。
        // そのため、プロパティを渡してそれを実現します。
        // 例: GUIアプリケーションの場合、ダイアログでブロックしないようにします。
        // 無人で実行されるべきです。
        def properties = [
                "-D" + propertyName("dataDir") + "=$distDir/local_train",
                "-D" + propertyName("aotTrainMode") + "=true",
        ]

        def jvmName = System.getProperty("java.vendor.version")
        def graal = jvmName != null && jvmName.toLowerCase().contains("graal")

        // 生成されたjlink/jpackageイメージ
        def imageDir
        // jlinkランチャースクリプトを起動するコマンド
        def command
        // キャッシュ出力場所は、jlinkランチャースクリプトの作業ディレクトリからの相対パスです。
        def cacheOutput
        // jpackageアプリ設定ファイル
        def cfgFile
        // jpackageランチャー設定から見たAOTキャッシュの場所
        def cfgCache
        if (os.isWindows()) {
            // アプリケーションイメージはビルド中にここに生成されました
            imageDir = "$distDir/${productName}"
            command = ['cmd', '/c', "$imageDir/runtime/bin/${jpackageExecutableName}.bat"]
            cacheOutput = './server/cache.aot'
            cfgFile = "$imageDir/app/${jpackageExecutableName}.cfg"
            cfgCache = '
$ROOTDIR/runtime/bin/server/cache.aot'
        } else if (os.isLinux()) {
            // アプリケーションイメージはビルド中にここに生成されました
            imageDir = "$distDir/${productName}"
            command = ["$imageDir/lib/runtime/bin/${jpackageExecutableName}"]
            cacheOutput = './lib/runtime/lib/server/cache.aot'
            cfgFile = "$imageDir/lib/app/${jpackageExecutableName}.cfg"
            cfgCache = '
$APPDIR/../../lib/runtime/lib/server/cache.aot'
        } else {
            // アプリケーションイメージはビルド中にここに生成されました
            imageDir = "$distDir/${productName}.app"
            command = ["$imageDir/Contents/runtime/Contents/Home/bin/${jpackageExecutableName}"]
            cacheOutput = './Contents/runtime/Contents/Home/lib/server/cache.aot'
            cfgFile = "$imageDir/Contents/app/${jpackageExecutableName}.cfg"
            cfgCache = '
$ROOTDIR/Contents/runtime/Contents/Home/lib/server/cache.aot'
        }

        def jvmOptions = [
                "-XX:+UnlockDiagnosticVMOptions",
                "-XX:-AOTAdapterCaching",
                "-XX:+AOTClassLinking",
                "-XX:AOTCacheOutput=$cacheOutput",
                "-Xlog:aot",
        ]
        if (graal) {
            jvmOptions += ["--add-modules", "jdk.internal.vm.ci"]
        }

        execOps.exec {
            commandLine((command + properties).collect { it.toString() })
            workingDir imageDir
            // jlinkランチャースクリプトのデフォルトのCDS_JVM_OPTS変数を使用して、
            // カスタムAOT JVM引数をプロセスに渡します。
            // すべてのjlinkランチャースクリプトはこの変数をサポートしています。
            environment "CDS_JVM_OPTS", jvmOptions.join(" ")
        }

        def cfg = file(cfgFile)
        cfg.text = cfg.text + "java-options=-XX:AOTCache=$cfgCache\njava-options=-Xlog:disable\n"
    }
}

トレーニング実行では、トレーニングモードプロパティと別のデータディレクトリを使用するため、既存のアプリケーションデータに干渉しません。トレーニングモードでは、アプリケーションは固定されたワークロード(例えば、すべての異なるユーザーインターフェイスページを開く)を実行し、その後自己終了します。これにより、トレーニング実行は通常のセッションで使用されるクラスとコードパスをカバーします。

結論

要約すると、AOTキャッシュを適切かつデメリットなしで機能させることは、本来あるべきよりもはるかに困難です。この機能は壊れやすく、JEP以外にはほとんどドキュメントがなく、ログはしばしば質問よりも多くの疑問を投げかけます。この投稿で得られた知識の多くは、JDKソースコードを読むことによってのみ得られました。

そこで疑問が生じます。JEPはドキュメントと見なせるのでしょうか?説明されている機能(AOTキャッシュ機能など)が複数回更新された場合、元のJEPは更新されるのでしょうか?私はJEPを新しい提案機能の短い要約と見なしており、主にハッピーパスに焦点を当てています。これらのページの目的は、機能が必要であることを説得することであり、それを網羅的に文書化することではありません。誰かがドキュメントのためにJEPを見るように言われたら、何か問題があると感じます。

この機能は、実世界のアプリケーションの課題に取り組むために十分に検討されているようにも思えません。CDSの上にAOT機能を構築したことは、間違いなく助けにはなりませんでした。なぜなら、これらの機能は実際のユースケースの点でかなりかけ離れて成長したからです。おそらく、別の実装の方が良かったでしょう。しかしそれでも、CPU機能に関連するクラッシュ問題全体がJDK25リリースまで到達したという事実は、計画と準備の不足を示しています。これは実際にはバグではなく、計画ミスであり、おそらく誰もが、事前にコンパイルされたものに対しては、CPU機能が異なる可能性があり、しばしば異なるだろうということを忘れていたようです。これは実際のJEPからも逸脱しており、潜在的なリスクや考慮事項としてリストされていませんでした。この形で実装できた唯一の方法は、これが考慮またはテストされなかったということです。

しかし、いずれにせよ、これはAOTキャッシュに関する長すぎる記事であり、パフォーマンス比較には触れてもいません!それは別の投稿のトピックになるかもしれません。