Java アプリケーションの最適化

このガイドでは、Java プログラミング言語で作成された Knative serving サービスの最適化と、最適化の一部に関連したトレードオフの理解に役立つ背景情報について説明します。このページの情報は、Java にも適用される全般的な最適化のヒントを補完するものです。

従来の Java ウェブベース アプリケーションは、同時実行性と低レイテンシでリクエストを処理するよう意図され、そのためアプリケーションの実行時間が長くなる傾向にあります。また、JVM 自体も JIT を使用して時間とともに実行コードを最適化することで、ホットパスが最適化され、アプリケーションが時間とともに効率的に実行されるようにしています。

これらの従来の Java ウェブベース アプリケーションのベスト プラクティスと最適化の多くは、以下を中心に展開されています。

  • 同時リクエストの処理(スレッドベースと非ブロッキング I/O の両方)。
  • バックグラウンド タスクへのトレースや指標の送信など、重要性が低い機能に接続プールとバッチ処理を適用してレスポンス レイテンシを短縮する。

これらの従来の最適化の多くは長時間実行アプリケーションには適していますが、リクエストへの対応時にのみ実行される Knative serving サービスではうまく機能しない場合があります。このページでは、Knative serving のさまざまな最適化とトレードオフについて説明します。これにより起動時間とメモリ使用量を低減できます。

コンテナ イメージを最適化する

コンテナ イメージを最適化すると、負荷を軽減し、起動時間を短縮できます。次の方法でイメージを最適化できます。

  • コンテナ イメージを最小化する
  • ネストされたライブラリ アーカイブ JAR の使用を回避する
  • Jib を使用する

コンテナ イメージを最小化する

この問題の詳細については、コンテナの最小化に関する一般的なヒントのページをご覧ください。一般的なヒントページでは、コンテナ イメージの内容を必要なものだけに制限することをおすすめします。たとえば、コンテナ イメージに以下が含まれないことを確認します。

  • ソースコード
  • Maven ビルド アーティファクト
  • ビルドツール
  • Git ディレクトリ
  • 未使用のバイナリ / ユーティリティ

Dockerfile 内からコードをビルドしている場合は、Docker マルチステージ ビルドを使用して、最終的なコンテナ イメージに JRE とアプリケーション JAR ファイルのみが含まれるようにします。

ネストされたライブラリ アーカイブ JAR を回避する

Spring Boot などの人気のあるフレームワークでは、追加のライブラリ JAR ファイル(ネストされた JAR)を含むアプリケーション アーカイブ(JAR)ファイルを作成します。これらのファイルは起動時に展開・解凍する必要があるため、Knative serving の起動時間が長くなる可能性があります。可能であれば、外部化されたライブラリで thin JAR を作成します。この処理は、Jib を使用してアプリケーションをコンテナ化することで自動化できます。

Jib を使用する

Jib プラグインを使用して、最小限のコンテナを作成し、アプリケーション アーカイブを自動的にフラット化します。Jib は Maven と Gradle の両方で動作します。また、Spring Boot アプリケーションもすぐに使用できます。アプリケーション フレームワークによっては、Jib 構成の追加が必要になることがあります。

JVM の最適化

Knative serving サービス用に JVM を最適化すると、パフォーマンスとメモリ使用量が改善されます。

コンテナ対応の JVM バージョンを使用する

VM で CPU とメモリを割り当てる際に、JVM は Linux、/proc/cpuinfo/proc/meminfo などのよく知られた場所から使用可能な CPU とメモリを確認します。ただし、コンテナ内で実行している場合、CPU とメモリの制約は /proc/cgroups/... に格納されます。旧バージョンの JDK は /proc/cgroups ではなく引き続き /proc を使用します。そのため、割り当てられた量よりも多くの CPU とメモリが使用される場合があります。これにより、次のような影響が出る可能性があります。

  • スレッドプールのサイズが Runtime.availableProcessors() によって構成されるため、スレッド数が課題になる。
  • デフォルトの最大ヒープがコンテナのメモリ上限を超える。JVM はガベージ コレクションを行う前にメモリを積極的に使用します。これにより、コンテナのメモリ上限を超え、OOMKilled が発生する可能性があります。

したがって、コンテナ対応の JVM バージョンを使用します。バージョン 8u192 以上の OpenJDK バージョンは、デフォルトでコンテナ対応です。

JVM メモリの使用量を理解する

JVM のメモリ使用量は、ネイティブ メモリとヒープの使用量から構成されます。通常、アプリケーションの作業メモリはヒープ内にあります。ヒープのサイズは最大ヒープ構成によって制限されます。RAM が 256 MB の Knative serving インスタンスの場合、256 MB すべてが最大ヒープに割り当てられるわけではありません。JVM と OS にもスレッド スタック、コード キャッシュ、ファイルハンドル、バッファなどのネイティブ メモリが必要です。アプリケーションで OOMKilled が発生し、JVM のメモリ使用量(ネイティブ メモリ + ヒープ)を確認する必要がある場合は、ネイティブ メモリのトラッキングをオンにして、アプリケーションが正常に終了したときの使用量を確認します。アプリケーションで OOMKilled が発生すると、情報が出力されません。その場合は、出力が正常に生成されるように、メモリを増やしてからアプリケーションを実行します。

ネイティブ メモリのトラッキングは、JAVA_TOOL_OPTIONS 環境変数で有効にすることはできません。Java コマンドライン スタートアップ引数をコンテナ イメージ エントリポイントに追加して、アプリケーションが次の引数で起動されるようにする必要があります。

java -XX:NativeMemoryTracking=summary \
  -XX:+UnlockDiagnosticVMOptions \
  -XX:+PrintNMTStatistics \
  ...

ネイティブ メモリの使用量は、読み込むクラスの数に基づいて計算できます。必要なメモリ量の見積りを行う場合は、オープンソースの Java Memory Calculator の使用を検討してください。

最適化コンパイラをオフにする

デフォルトでは、JVM には複数の JIT コンパイル フェーズがあります。これらのフェーズは、アプリケーションの効率性を時間とともに改善する一方で、メモリ使用時のオーバーヘッドが増加し、起動時間が長くなることがあります。

実行時間の短いサーバーレス アプリケーション(関数など)では、最適化フェーズをオフにして、長期的な効率性よりも起動時間の短縮を優先させることを検討します。

Knative serving サービスの場合、環境変数を構成します。

JAVA_TOOL_OPTIONS="-XX:+TieredCompilation -XX:TieredStopAtLevel=1"

アプリケーションのクラスデータ共有を使用する

JIT 時間とメモリ使用量をさらに削減するには、アプリケーション クラス データ共有(AppCDS)を使用して、コンパイル済みの Java クラスをアーカイブとして共有することを検討してください。AppCDS アーカイブは、同じ Java アプリケーションの別のインスタンスを起動するときに再利用できます。JVM がアーカイブから計算済みのデータを再利用できるため、起動時間を短縮できます。

AppCDS を使用する場合、以下の考慮事項が適用されます。

  • 再利用する AppCDS アーカイブは、元の作成に使用された OpenJDK ディストリビューション、バージョン、アーキテクチャとまったく同じ方法で再現する必要があります。
  • 共有するクラスのリストを生成するには、少なくとも 1 回アプリケーションを実行してから、そのリストを使用して AppCDS アーカイブを生成する必要があります。
  • クラスの範囲は、アプリケーションの実行中に実行されるコードパスによって異なります。対象範囲を広げるには、より多くのコードパスをプログラムでトリガーします。
  • このクラスリストを生成するには、アプリケーションを正常に終了する必要があります。アプリケーションをすぐに終了できるように、AppCDS アーカイブの生成に使用されるアプリケーション フラグの実装を検討してください。
  • AppCDS アーカイブを再利用できるのは、アーカイブの生成とまったく同じ方法で新しいインスタンスを起動する場合に限られます。
  • AppCDS アーカイブは通常の JAR ファイル パッケージでのみ機能します。ネストされた JAR は使用できません

シェーディングされた JAR ファイルを使用した Spring Boot の例

Spring Boot アプリケーションはデフォルトで uber JAR を使用しますが、これは AppCDS で機能しません。AppCDS を使用する場合は、シェーディングされた JAR を作成する必要があります。たとえば、Maven と Maven Shade プラグインを使用する場合は次のようになります。

<build>
  <finalName>helloworld</finalName>
  <plugins>
    <plugin>
      <groupId>org.apache.maven.plugins</groupId>
      <artifactId>maven-shade-plugin</artifactId>
      <configuration>
        <keepDependenciesWithProvidedScope>true</keepDependenciesWithProvidedScope>
        <createDependencyReducedPom>true</createDependencyReducedPom>
        <filters>
          <filter>
            <artifact>*:*</artifact>
            <excludes>
              <exclude>META-INF/*.SF</exclude>
              <exclude>META-INF/*.DSA</exclude>
              <exclude>META-INF/*.RSA</exclude>
            </excludes>
          </filter>
        </filters>
      </configuration>
      <executions>
        <execution>
          <phase>package</phase>
          <goals><goal>shade</goal></goals>
          <configuration>
            <transformers>
              <transformer implementation="org.apache.maven.plugins.shade.resource.AppendingTransformer">
                <resource>META-INF/spring.handlers</resource>
              </transformer>
              <transformer implementation="org.springframework.boot.maven.PropertiesMergingResourceTransformer">
                <resource>META-INF/spring.factories</resource>
              </transformer>
              <transformer implementation="org.apache.maven.plugins.shade.resource.AppendingTransformer">
                <resource>META-INF/spring.schemas</resource>
              </transformer>
              <transformer implementation="org.apache.maven.plugins.shade.resource.ServicesResourceTransformer" />
              <transformer implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer">
                <mainClass>${mainClass}</mainClass>
              </transformer>
            </transformers>
          </configuration>
        </execution>
      </executions>
    </plugin>
  </plugins>
</build>

シェーディングされた JAR にすべての依存関係が含まれている場合は、コンテナをビルドする際に Dockerfile を使用してシンプルなアーカイブを作成できます。

# Use Docker's multi-stage build
FROM adoptopenjdk:11-jre-hotspot as APPCDS

COPY target/helloworld.jar /helloworld.jar

# Run the application, but with a custom trigger that exits immediately.
# In this particular example, the application looks for the '--appcds' flag.
# You can implement a similar flag in your own application.
RUN java -XX:DumpLoadedClassList=classes.lst -jar helloworld.jar --appcds=true

# From the captured list of classes (based on execution coverage),
# generate the AppCDS archive file.
RUN java -Xshare:dump -XX:SharedClassListFile=classes.lst -XX:SharedArchiveFile=appcds.jsa --class-path helloworld.jar

FROM adoptopenjdk:11-jre-hotspot

# Copy both the JAR file and the AppCDS archive file to the runtime container.
COPY --from=APPCDS /helloworld.jar /helloworld.jar
COPY --from=APPCDS /appcds.jsa /appcds.jsa

# Enable Application Class-Data sharing
ENTRYPOINT java -Xshare:on -XX:SharedArchiveFile=appcds.jsa -jar helloworld.jar

クラス確認をオフにする

JVM が実行するためにクラスをメモリに読み込むと、そのクラスが改ざんされていない、悪意のある編集や破損がないことを確認します。お使いのソフトウェア配信パイプラインが信頼されている(すべての出力の確認と検証ができるなど)場合や、コンテナ イメージのバイトコードを完全に信頼でき、アプリケーションが任意のリモートソースからクラスを読み込まない場合は、確認機能をオフにすることを検討します。確認機能をオフにすると、起動時に多数のクラスが読み込まれる場合に、起動速度が改善される場合があります。

Knative serving サービスの場合、環境変数を構成します。

JAVA_TOOL_OPTIONS="-noverify"

スレッド スタックのサイズを減らす

ほとんどの Java ウェブ アプリケーションは接続ごとのスレッドをベースにしています。Java スレッドはヒープではなくネイティブ メモリを使用します。これはスレッド スタックと呼ばれ、デフォルトではスレッドあたり 1 MB に設定されています。アプリケーションが 80 件の同時リクエストを処理する場合、少なくとも 80 個のスレッドがあり、80 MB のスレッド スタック空間が使用されています。メモリはヒープサイズに追加されます。デフォルトが必要以上に大きい場合もあります。その場合、スレッドのスタックサイズを小さくできます。

縮小しすぎると、java.lang.StackOverflowError が表示されます。アプリケーションをプロファイリングすれば、構成する最適なスレッド スタックのサイズを見つけることができます。

Knative serving サービスの場合、環境変数を構成します。

JAVA_TOOL_OPTIONS="-Xss256k"

スレッドを減らす

メモリを最適化するには、スレッド数を減らし、非ブロッキング型の事後対応戦略を使用して、バックグラウンド アクティビティを回避します。

スレッド数を減らす

スレッド スタックが原因で各 Java スレッドのメモリ使用量が増加することがあります。Knative serving では、最大 80 件の同時リクエストが許可されます。接続ごとのモデルでは、すべての同時リクエストを処理できるように最大で 80 個のスレッドが必要になります。ほとんどのウェブサーバーとフレームワークでは、スレッドと接続の最大数を構成できます。たとえば、Spring Boot では、applications.properties ファイルに最大接続数を定義できます。

server.tomcat.max-threads=80

メモリと起動を最適化するための非ブロッキング リアクティブ コードを作成する

スレッド数を減らすため、非ブロッキング リアクティブ プログラミング モデルの採用を検討してください。これにより、より多くの同時リクエストを処理しながらスレッド数を大幅に削減できるようになります。アプリケーション フレームワーク(Spring Boot with Webflowx、マイクロノート、Quakus など)は、リアクティブなウェブ アプリケーションをサポートしています。

通常、Spring Boot with Webflux、Micronaut、Quarrkus などのリアクティブ フレームワークでは起動時間が短くなります。

非ブロッキング フレームワークでブロッキング コードの記述を続けると、Knative serving サービスのスループットとエラー率が著しく悪化します。これは、非ブロッキング フレームワークには 2 つや 4 つなど、小数のスレッドしかないためです。コードがブロックしている場合、極めて小数の同時リクエストしか処理できません。

非ブロッキング フレームワークでは、ブロッキング コードを制限なしスレッドプールにオフロードすることもできます。この場合、多数の同時リクエストが受け付けられるものの、ブロッキング コードは新しいスレッドで実行されることを意味します。スレッドがこのように制限なしに蓄積されると CPU リソースが使い尽くされ、CPU にスラッシングが発生し始めます。これは、レイテンシに多大な影響を与えます。非ブロッキング フレームワークを使用する場合、スレッドプール モデルを理解し、それに応じてプールをバインドしてください。

バックグラウンド アクティビティを回避する

このインスタンスがリクエストを受信しなくなると、Knative serving はインスタンスの CPU を抑制します。バックグラウンド タスクのある従来のワークロードを Knative serving で実行する場合は、特別な考慮事項があります。

たとえば、アプリケーション指標を収集し、バックグラウンドで指標をバッチにまとめて定期的に送信する場合、CPU が抑制されているとそれらの指標は送信されません。アプリケーションがリクエストを絶えず受信していると、問題が少なくなります。アプリケーションの QPS が低い場合、バックグラウンド タスクは実行されません。

以下に、バックグラウンド化されたよく知られているパターンで注意が必要なものを示します。

  • JDBC 接続プール - 通常、クリーンアップと接続チェックはバックグラウンドで行われます。
  • 分散トレース送信機能 - 通常、分散トレースはバッチで処理されます。定期的またはバッファがいっぱいになったときにバックグラウンドで送信されます。
  • 指標送信機能 - 通常、指標はバッチで処理され、バックグラウンドで定期的に送信されます。
  • Spring Boot の場合、@Async アノテーションが付いたすべてのメソッド
  • タイマー - CPU が抑制されると、タイマーに基づくトリガー(例: ScheduledThreadPoolExecutor、Quartz、@Scheduled Spring annotation) が動作しない場合があります。
  • メッセージ レシーバ - Pub/Sub ストリーミング pull クライアント、JMS クライアント、Kafka クライアントなどのクライアントは、通常、リクエストを必要とせずにバックグラウンド スレッドで実行されます。アプリケーションにリクエストがない場合、これらは機能しません。Knative serving では、この方法でのメッセージの受信はおすすめしません。

アプリケーションの最適化

Knative serving サービスコードにより、起動時間とメモリ使用量を最適化することもできます。

起動タスクを減らす

従来の Java ウェブベース アプリケーションでは、起動中に完了するタスクが多数あります。たとえば、データのプリロード、キャッシュのウォームアップ、接続プールの確立などです。これらのタスクは順次実行すると、遅くなる原因になります。ただし、同時に実行するには、CPU コアの数を増やす必要があります。

Knative serving は現在、コールド スタート インスタンスをトリガーするために実際のユーザー リクエストを送信しています。新しく開始したインスタンスにリクエストが割り当てられているユーザーは、大幅な遅延が発生する可能性があります。Knative serving には現在、準備未完了のアプリケーションに対するリクエスト送信を回避するための「準備」チェックがありません。

接続プールを使用する

接続プールを使用する場合、接続プールがバックグラウンドで不要な接続を削除することに注意してください(バックグラウンド タスクの回避を参照)。アプリケーションの QPS が低く高レイテンシを許容できる場合、リクエストごとに接続の開始と終了を行うことを検討してください。アプリケーションの QPS が高い場合、アクティブなリクエストがあれば、バックグラウンド エビクションが実行されている可能性があります。

どちらの場合も、アプリケーションによるデータベース アクセスは、データベースで許可される最大接続数によってボトルネックになります。Knative serving インスタンスごとに確立できる最大接続数を計算し、最大インスタンス数 × インスタンスごとの接続数が、許可される最大接続数より小さくなるよう Knative serving の最大インスタンス数を構成します。

Spring Boot を使用する

Spring Boot を使用する場合、次の最適化を検討する必要があります。

Spring Boot バージョン 2.2 以降を使用する

バージョン 2.2 以降の Spring Boot では起動速度が大幅に最適化されています。2.2 より前のバージョンの Spring Boot を使用している場合、アップグレードを検討するか、個々の最適化を手動で適用してください。

遅延初期化を使用する

グローバル遅延初期化フラグは、Spring Boot 2.2 以降で有効にできます。これにより起動速度は向上しますが、最初のリクエストでコンポーネントの初期化を待機する必要があるため、レイテンシが長くなる可能性があります。

遅延初期化は application.properties で有効にできます。

spring.main.lazy-initialization=true

環境変数を使用する場合は次のようになります。

SPRING_MAIN_LAZY_INITIALIZATIION=true

ただし、min-instances を使用している場合、min-instance の起動時に初期化が行われるため、遅延初期化の効果はありません。

クラススキャンを回避する

Knative serving では、一般的にディスク アクセスが通常のマシンよりも遅いため、クラススキャンによって Knative serving で追加のディスク読み取りが発生します。コンポーネント スキャンを制限するか、完全に回避するようにします。Spring Context Indexer でインデックスを事前に生成することも検討してください。これにより起動速度が改善されるかどうかは、アプリケーションによって異なります。

たとえば、Maven の pom.xml にインデクサの依存関係を追加します(実際にはアノテーション プロセッサです)。

<dependency>
  <groupId>org.springframework</groupId>
  <artifactId>spring-context-indexer</artifactId>
  <optional>true</optional>
</dependency>

本番環境にない Spring Boot デベロッパー ツールを使用する

開発時に Spring Boot デベロッパー ツールを使用する場合は、本番環境のコンテナ イメージにパッケージ化されていないことを確認してください。これは、Spring Boot ビルド プラグインを使用せずに Spring Boot アプリケーションをビルドした場合に発生します(たとえば、Shade プラグインや Jib を使用した場合)。

その場合は、Spring Boot デベロッパー ツールがビルドツールにより明示的に除外されていることを確認します。Spring Boot デベロッパー ツールを明示的に無効にすることもできます

次のステップ

その他のヒントについては、以下を参照してください。