Fix Memory Issues in Your Java Apps

原文はこちら。
The original article was written by Chi Wang (Software Engineering Lead, EInstein.AI – Salesforce.com) and edited by Dianne Siebold (Principal Technical Writer at Salesforce.com).
https://engineering.salesforce.com/fix-memory-issues-in-your-java-apps-c261046bae52

この記事は、以下の内容を理解してもらうことを目的にしています。

  1. データ量の多いアプリケーションのメモリプレッシャーの軽減
  2. ネイティブメモリリークの修正
  3. 運用環境における複雑なトラブルシューティングへの対応

Mitigate Memory Pressure

通常、メモリ問題の根本原因がリソース未解放などのコーディングミスであれば、すぐに修正できます。しかし複雑なケースでは、短時間に大量のオブジェクトが作成されることでOut-of-memoryの例外が発生することがあります。ここでは、この手の問題に対処する方法についてお話しします。

データ量の多いアプリケーションでは、GCによる十分なメモリ空間解放がオブジェクト生成に間に合わないため、out-of-memoryエラーが発生することがあります。下図は、このような場合のプロファイラグラフです。

青はnew genヒープ空間、緑は割り当てられたメモリの合計、赤はold genヒープ空間を表しています。多くの中間オブジェクトが作成され、ヒープのyoung gen空間に積み重なっていることがわかります(青のスパイク部分)。下向きのスパイクは、GCがメモリを解放していることを示しています。GC発生前に多数のオブジェクトがyoung gen空間に作成された場合、スパイクはメモリ制限を超えてしまい、out-of-memoryエラーが発生します。

Image for post

このような状況に陥った場合、簡単な解決策はありません。やるべきことは2つあり、一つは JVMのメモリ設定をチューニングすることと、もう一つはコードをリファクタリングすることです。一つずつ説明していきましょう。

Tune the JVM

Oracleの公式ドキュメントでは、ほとんど全てのJVMチューニングの詳細をカバーしています。これらのドキュメントはメモリ最適化チューニング時においてまず最初にチェックしておくべきものです。

Oracle WebLogic Serverのパフォーマンスのチューニング
Java仮想マシン(JVM)のチューニング
https://docs.oracle.com/cd/F32751_01/weblogic-server/14.1.1.0/perfm/jvm_tuning.html
Tuning Performance of Oracle WebLogic Server
Tuning Java Virtual Machines (JVMs)
https://docs.oracle.com/en/middleware/standalone/weblogic-server/14.1.1.0/perfm/jvm_tuning.html
HotSpot Virtual Machineガベージ・コレクション・チューニング・ガイド
https://docs.oracle.com/javase/jp/15/gctuning/introduction-garbage-collection-tuning.html
HotSpot Virtual Machine Garbage Collection Tuning Guide
https://docs.oracle.com/en/java/javase/15/gctuning/introduction-garbage-collection-tuning.html

コードのリファクタリングを始める前に、最初に数日かけてJVMのチューニングを行うことをお勧めします。JVMのカスタマイズは低コストで始めることが可能な方法です。JVMにいくつかのフラグを設定するだけで済みますが、チューニングがうまくいくと、本番アプリの火事を止め、適切なコードリファクタリングを計画するための時間を稼ぐことができます。

以下は試してみたいJVM変数です。みなさんの環境で値を変えてパフォーマンスを検証してください。時間をかけたいのはコードリファクタリングなので、チューニングは一定の時間が来たら切り上げましょう。

-XX:NewSize -XX:MaxNewSize -XX:SurvivorRatio -Xms -Xmx -XX:GCTimeRatio -XX:GCTimeLimit

Refactor Your Code

個人的な思いとしては、JVMチューニングは一時的な消火活動のためのアプローチに過ぎません。コードリファクタリングは、メモリの問題を解決するための正しいアプローチです。

Javaでは、開発者は通常、(一時的な)ローカルオブジェクトを任意に作成し、それらを効果的にリサイクルするためにJVMのガベージコレクタを信頼する傾向があります。このコーディングパターンは、通常のJavaアプリケーションでは問題ないかもしれませんが、データ処理、データ転送、ストリーミングアプリなどの大規模でデータ量の多いアプリケーションでは問題になる可能性があります。

例えば、今年、私はデータセット管理アプリケーションのout-of-memory問題をトラブルシューティングしていました。このアプリケーションは、2GBの画像データセットを解析するために10GB近くのメモリを消費していました。アプリケーションがデータセットを解析しているとき、データセット内の各画像に対して多くの中間オブジェクトを作成していました。これは、アプリケーションが3つのデータセットを並行して処理する際に、メモリ使用量が増大する原因となっていました。

Code refactoring suggestions

以下はメモリプレッシャーを軽減するためにコードをリファクタリングするためのいくつかの提案です。

Always use stream with fix size buffer and avoid entire array copy (常に固定サイズのバッファを持つストリームを使用し、配列全体のコピーを避ける)

以下の例は、ストリーム全体をヒープ内のバイト配列にロードしようとする非常に悪い方法です。ストリームがディスク上の2GBのデータファイルを表している場合、この操作はヒープ内で2GB超のメモリを消費します。より良い方法は、固定サイズのバッファでストリームからデータをバッチで読み出すことで、ファイルがどれだけ大きくてもデータファイルの読み込みと処理には常に同じ量のメモリを消費します。

Image for post
Avoid inheriting a large abstract parent class (大きな抽象親クラスの継承を避ける)

煩雑な抽象ルートクラスには、その子クラスが持つべき多くの不必要な属性が入っています。これは、これらの子クラスのために多くのオブジェクトを作成する際に、多くのメモリを浪費します。

Limit memory consumption for serving each customer request (各顧客のリクエストに対応するためのメモリ消費量を制限する)

理想的には、同じタイプの顧客のリクエストに対応するために、アプリケーションが同じ量のメモリ/CPUを消費するようにしたいと考えています。例えば、ユーザが2GBのデータセットをアップロードするか20GBのデータセットをアップロードするかに関わらず、メモリコストは同じであるべきです。このようにして、アプリケーションを拡張するために必要なリソースの数を簡単に見積もることができます。

画像データセットの解析を具体的な例として使用すると、データセット内のすべての画像に対して新しい ImageExample オブジェクトを作成する代わりに、いくつかの固定サイズの画像バッファをあらかじめ割り当てておき、それをデータセット全体の解析に使用することができます。この方法では、総メモリコストはデータセットのサイズ(画像の数)ではなく、事前に割り当てられたバッファの数に依存します。

もう一つのヒントは、toList()toArray() のように、ストリーム全体を一度にメモリに読み込む関数を避けることです。標準的なパターンは、ストリームから固定サイズのバッチでコンテンツを読み込むことです。

Utilize disk space and only keep necessary information in memory (ディスク容量を有効活用し、必要な情報だけをメモリに保存する)

データセットの解析を例にすると、すべてのデータセットファイル(画像、ラベル、スキーマ)を一時的にディスクに保存し、データセットファイルのローカルディレクトリやexampleCnt、ラベルなどの重要な属性だけをメモリに保存します。

Test your dependencies’ memory performance (依存関係のメモリ性能をテストする)

アプリケーションの依存関係(特にI/Oライブラリ)もメモリリークする可能性があることに注意してください。過去に関わったプロジェクトで、サードパーティ製のライブラリ(Amazon-S3-FileSystem-NIO2)に深刻なメモリリークがあることを発見しました。その際は、ライブラリのfileDAO.createAPIが26KBの画像をアップロードするために300~400MBのメモリを消費していました。依存ライブラリがこのような問題を抱えているのであれば、できることはあまりありません。結局、独自のNIOライブラリを実装して問題を解決しました。

An Amazon AWS S3 FileSystem Provider JSR-203 for Java 7 (NIO2)
https://github.com/Upplication/Amazon-S3-FileSystem-NIO2

パフォーマンスのリファクタリングは長引く作業なので、期待値を調整するようにしましょう。1回の変更でアプリケーションが完璧に動作するようになる銀の弾丸はありません。異なる試みを繰り返してこそ、目的地にたどり着くことができるのです。

Detect and Solve a Native Memory Leak

これまでにJavaネイティブメモリリークを聞いたことがないのであれば、以下の記事を読むことをおすすめします。

native-jvm-leaks
https://github.com/jeffgriffith/native-jvm-leaks
Native Memory — The Silent JVM Killer
https://medium.com/swlh/native-memory-the-silent-jvm-killer-595913cba8e7
Using jemalloc to get to the bottom of a memory leak
https://technology.blog.gov.uk/2015/12/11/using-jemalloc-to-get-to-the-bottom-of-a-memory-leak/

Javaアプリケーションは、それ自身のJVM王国内に住んでいますが、Java Native Interface(JNI)を使ってネイティブ空間にリソースを割り当てることもできます。リソースのクリーンアップを忘れた場合、ネイティブメモリリークが発生しますが、これらの大きなチャンクのリソースリークはヒープ内で発生していないため、JVMはまだ正常に見えます。したがって、これまでに説明したすべてのツールは、このタイプのメモリリークを検出することができません。これがJavaのネイティブメモリリークが厄介な理由です。

Java Native Interface
https://ja.wikipedia.org/wiki/Java_Native_Interface
https://en.wikipedia.org/wiki/Java_Native_Interface

Detect a Native Memory Leak

ネイティブメモリリークがあるかどうかを検出するには、物理メモリとヒープメモリの両方を監視し、ヒープメモリサイズは安定しているが、プロセスの物理メモリ消費量が増え続けている場合は、ネイティブメモリリークを発見したことになります。ネイティブメモリトラッキング(Native Memory Tracking、NMT)は、ネイティブ空間でヒープがどのように見えるかを監視するのに非常に便利です。

もしアプリケーションがKubernetesで動作していて、Kubernetesコントローラによってout-of-memoryでkillされ続けているのに、JVMダンプファイルが生成されていない場合は、ネイティブメモリリークのサインでもあります。

もう一つの便利なヒントは、フラグ-XX:MaxDirectMemorySizeを500MBや1GBのような小さな数字に設定することです。これにより、Direct Byte Bufferのリークを検知しやすくなります。 -Djdk.nio.maxCachedBufferSizeフラグを追加すると、このタイプの問題が解決します。

Troubleshoot the Root Cause of a Native Memory Leak

ネイティブメモリのトラブルシューティングの一般的な考え方は非常に単純です。デフォルトのネイティブメモリ割り当て関数(malloc)をオペレーティングシステムレベルで別のアロケータであるjemallocに置き換えます。標準のアロケータと比較して、jemallocはイントロスペクション、メモリ管理、チューニング機能を提供します。

jemalloc – Background
https://github.com/jemalloc/jemalloc/wiki/Background

jemallocの設定が完了したら、アプリケーションを起動してテストシナリオを実行しましょう。するとjemallocは設定に応じてダンプファイルを生成します。テストが終了すると、(jeprof.1968.0.f.heapのような名前の)ダンプファイルを可視化して、メモリ割り当てのグラフを得ることができます。グラフを作成した後、根本的な原因がどこにあるのかを見分けるのはたいてい簡単です(下記のjemallocレポートのサンプルを参照してください)。

Image for post

これですべてをコンテナ化したので、Ubuntuのコンテナでjemallocを有効にする方法を紹介します。

RUN apt updateRUN apt install -y bzip2
RUN apt install -y build-essential
RUN apt install -y autoconf
RUN apt install -y wget
RUN apt install -y graphviz
RUN apt install -y ghostscript
RUN apt install -y unzip# Download jemalloc source code and build its library
RUN mkdir -p /export/app/jemalloc && cd /export/app/jemalloc \
&& wget https://github.com/jemalloc/jemalloc/releases/download/5.2.1/jemalloc-5.2.1.tar.bz2 \
&& tar -xvf jemalloc-5.2.1.tar.bz2 && cd jemalloc-5.2.1 \
&& ./configure --enable-prof --enable-stats --enable-debug --enable-fill \
&& make && make installENV LD_PRELOAD "/usr/local/lib/libjemalloc.so"
ENV MALLOC_CONF "prof:true,lg_prof_interval:31,lg_prof_sample:17,prof_prefix:/export/app/<your_ap>/logs/jeprof"

メモリ割り当てグラフを可視化するコマンドを紹介します。

jeprof --show_bytes `which java` {jemalloc dump, ex:jeprof.19678.0.f.heap} > ps_out.pdf
jeprof --show_bytes --pdf `which java` {jemalloc dump, ex:jeprof.19678.0.f.heap} > ps_out.pdf

Native Memory Leak When Serving a Tensorflow Model in Java

具体的なネイティブメモリリークの例を見てみましょう。今回はTensorflowモデルのサーブを例にします。Google Tensorflowチームは、JavaプロセスでTensorflowモデルをホストすることをサポートする、非常に便利なJava JNIライブラリをリリースしました。JNIライブラリを使うと、ネイティブ空間でモデルをロードして計算グラフを構築できます。次の例に示すように、わずか数行のJavaコードで予測リクエストを提供できます。

TensorFlow for Java
https://www.tensorflow.org/install/lang_java

// Load model in memory (native space)
SavedModelBundle model = SavedModelBundle.load(modelFile.getPath(), "serve")// Get session of the model.
Session session = model.getTFSession();
Runner runner = session.runner();// Set input
inputTensors.forEach(runner::feed);
// Run the model with input and return result
outputs = runner.run();

このコード例には、深刻なメモリリークが発生する可能性のある場所がいくつかあります。

Close objects to prevent resource leaks (オブジェクトをクローズしてリソースリークを防ぐ)

inputTensor と outputTensor のオブジェクトをリソースリークしないようにクローズする必要があります。一般的に複数のoutput tensorとinput tensorをがある点に注意してください。

inputTensors.forEach((name, tensor) -> tensor.close());
if (Objects.nonNull(outputs)) {
   outputs.forEach(Tensor::close);
}
Model loading isn’t reflected in the JVM memory usage (モデルのロードがJVMのメモリ使用量に反映されない)

モデルのロードは、JVMのメモリ使用量に反映されない大量のネイティブメモリを消費することに注意してください。下図のグラフからわかるように、モデルをロードするとき、2つの大きなオブジェクト(GraphSession)がネイティブ空間に作成され、ハンドル(ポインタ)だけがJVMに送り返されます。

JVMはポインタだけを持っているので、これらのネイティブメモリの割り当てはわかりません。複数のモデルをキャッシュでホストしていて、モデルをロードする際の実際のメモリ使用量を過小評価していると、非常に間単にメモリの問題に遭遇します。経験上、80〜840 KB の言語モデルをロードすると、200〜300 MB のメモリ使用量になります。

Image for post

Troubleshoot in Production

運用環境でのメモリ問題のトラブルシューティングは、ローカルマシンでのトラブルシューティングとは大きく異なります。パフォーマンスが低下し、サービスがクラッシュする可能性があるため、本番環境のサービスからメモリダンプ(スナップショット)を取ることはできません。また、リアルタイムの顧客からのトラフィックがあるため、セキュリティ上の理由でアクセスが制限されていたり、時間がかかる厳格なデプロイプロセスだったり、顧客に影響を与える可能性のあるリスクがあったりと、トラブルシューティングには多くの障害があります。毎秒40,000クエリ(QPS)のトラフィックがあるアクセス制限された本番環境で、Webサービスのout-of-memory問題をデバッグすることがどれほど難しいかを想像してみてください。

ここでは、本番環境でのトラブルシューティングに対処するためのヒントをいくつか紹介したいと思います。重要なのは、本番環境で直接作業するのではなく、メモリリークの原因となる可能性のある条件を特定し、それらを複製し、ローカルのテスト環境でトラブルシューティングを行うことに時間を費やすことです。ローカルのテスト環境で作業する場合、物事を変更するのは簡単です。反復や実験を素早く行うことができます。

運用環境の問題を分析するためのおすすめの方法を紹介します。

  1. 包括的な測定メトリクス(トラフィック、レイテンシ、サービス内部の詳細、リソース消費量 – 特にJVM内部の詳細、例: metrics-jvm)を構築し、GCログを有効にする。
    metrics-jvm : JVM Instrumentation
    https://metrics.dropwizard.io/4.1.2/manual/jvm.html
  2. 各マイナー/メジャーGCとメモリ使用量の急増のメトリクスを調査する。メモリメトリクスの変化をトラフィックパターンやサービスロジックに関連付けることで、疑わしいユーザーシナリオを発見する。
  3. ローカルのテスト環境を設定する。例えば、サービスを自分のKubernetesネームスペースにデプロイし、本番環境の設定を複製する。
  4. 疑わしいユーザーシナリオを複製または増幅し、テスト環境でメモリ問題を再現させるため、Apache JMeterなどを使いパフォーマンステストを作成する。
    Apache JMeter
    https://jmeter.apache.org/
  5. トラブルシューティング/テスト計画と実験ログを作成する。テスト計画を使用して、異なる理論を評価するための主要な作業項目をリストアップし、実験ログを使用して各実験の結果を追跡する。メモリのトラブルシューティングとリファクタリングは、通常、長期的な作業になるが、実験ログは、調査が数週間以上続く場合に、プレッシャーの中で明確な心を保ち、着実な進歩を遂げるために非常に有用である。
  6. テスト計画に沿って、(この記事の知識で)ダンプファイルを調査し、コードリファクタリングの変更を1つずつ行い、その結果を評価する。根本的な原因を見つけたり、メモリ圧を軽減したりするまで、これを繰り返し行う。
  7. 発見したことをまとめてチームに提示することで、他の開発者が学習し、将来的に問題を繰り返さないようにすることができる。

経験の浅い開発者がよく犯す間違いは、修正プログラムを開発して本番環境で直接検証することです。テスト環境を設定してメモリリークを再現するのは大変です。開発者の中には必要ないと考える人もいます。

私にしてみれば、高速な繰り返しがメモリ問題の解決の鍵なので、テスト環境をセットアップし、out-of-memory状態を再現するために3〜5日を費やす価値は完全にあります。アイデアをかなり速く(数時間以内に)実験でき、発見内容と修正に非常に自信を持つことができます。テスト環境を完全にコントロールできるので、結果を検証するためにほとんど何でもできるし、何かを壊す心配もありません。

Conclusion

今回の2部作が、Javaのメモリ関連のトラブルシューティングの世界をもう少し深く掘り下げて、エンタープライズの本運用アプリケーションでメモリ問題に取り組む際の緊張感を和らげることに役立つことを願っています。

Troubleshoot Memory Issues in Your Java Apps
https://engineering.salesforce.com/troubleshoot-memory-issues-in-your-java-apps-719b1d0f9b78
https://logico-jp.io/2021/01/03/troubleshoot-memory-issues-in-your-java-apps/
Fix Memory Issues in Your Java Apps
https://engineering.salesforce.com/fix-memory-issues-in-your-java-apps-c261046bae52

メモリ問題に取り組むことは、実際にはとても楽しいことです。事実、本を読むよりもトラブルシューティングからJavaについて非常にたくさん学びました。この長いブログ記事を読み終えたばかりなので、あなたもきっと楽しめると思います。

Einstein Vision and Language の開発者向け公開ドキュメントのほとんどを書いてくれた技術ライターの Dianne Siebold に感謝します。彼女の助けと指導がなければ、このブログを完成させることはできなかったでしょう。

Reference

コメントを残す

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

WordPress.com ロゴ

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

Google フォト

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

Twitter 画像

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

Facebook の写真

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

%s と連携中