OutOfMemoryError はプロセスを殺さない
一言でいうと
OutOfMemoryErrorが発生したときに必要なのは、その瞬間のヒープダンプと、プロセスを確実に終了させる設定です。-XX:+HeapDumpOnOutOfMemoryErrorが前者を、-XX:+ExitOnOutOfMemoryErrorが後者を担います。リークの犯人は、クラスヒストグラムの1行目と、ダンプのドミネーターツリーから探します。
なぜ必要なのか
「キャッシュ」という名前のstatic HashMapがありました。入れるだけで、空にしませんでした。1日後にヒープが埋まり、Full GCが連続して動いたあげく、java.lang.OutOfMemoryError: Java heap spaceが発生しました。ところがプロセスは死にませんでした。例外は1つのスレッドで発生しただけで、ほかのスレッドは空きのほとんどないヒープでGCを繰り返し、応答だけできない状態で数時間持ちこたえたのです。ヘルスチェックは通りました(そのリクエストはメモリをほとんど使いません)。ロードバランサーはこのインスタンスを外しませんでした。再起動したら元に戻り、ダンプがなかったので、原因は「次に起きたら見よう」になりました。
どう動くのか
javaコマンドのドキュメントは、-XX:+HeapDumpOnOutOfMemoryErrorを「OutOfMemoryErrorがスローされるときに、HPROF形式でヒープをカレントディレクトリにダンプする。デフォルトはオフ」と記載しています。また、-XX:HeapDumpPath=<경로>(プレースホルダーはパスです)でファイルの場所と名前を決め、デフォルトの名前はjava_pid<pid>.hprofだと記載しています。本番ではこの2つを常に有効にします。ダンプは例外が発生する瞬間にしか作られないので、有効にしていなければ、事件のあとには何も残りません。ファイルサイズはヒープサイズと同程度なので、ディスク容量をあらかじめ確保します(-Xmx64mのラボで約50MBになります。実測)。
プロセスが死なない問題は、-XX:+ExitOnOutOfMemoryErrorが解決します。このオプションは21のjavaコマンドのドキュメントには載っていませんが、HotSpotは受け付け、実測では最初のOutOfMemoryErrorでTerminating due to java.lang.OutOfMemoryError: Java heap spaceを出力し、終了コード3で終了します。Kubernetesならコンテナが終了して再起動されるため、「半分死んだまま持ちこたえる」状態がなくなります。ヘルスチェックでは検出できない状態を、プロセスの終了に変えるのが、このオプションの意味です。
稼働中のプロセスでは、jcmdの2つのコマンドを使います。GC.class_histogramはヒープの使用統計をクラス別に出します(影響: 高い。ヒープサイズに比例)。出力は順位・インスタンス数・バイト数・クラス名で、リークはほとんど常に1行目にあります。[B(byte配列)やjava.util.HashMap$Nodeが数百万個あれば、次の疑問は誰がそれを保持しているかです。GC.heap_dump <파일>(プレースホルダーはファイル名です)はHPROFダンプを作り、ドキュメントは、-allを指定しなければ先にFull GCを要求すると記載しています。そのため、ダンプには到達可能なオブジェクトだけが残り、それが「保持されているもの」の定義です。HPROFファイルはJAVA PROFILE 1.0.2で始まります(実測)。分析ツール(Eclipse MATなど)は、このファイルからドミネーターツリー(dominator tree)、つまり「どのオブジェクトを手放すとどれだけ解放されるか」を計算し、その最上部にstaticフィールドでつながったコレクションがあれば、それが犯人です。
同じ割り当てをしても、参照を手放せばリークではありません。ラボのLeakは-Dleak.retain=falseで同じ配列を作りますが、マップには入れません。割り当て量は同じなのに、-Xmx64mでも最後まで動きます。リークの定義は「たくさん作る」ではなく「手放さない」です。
コンテナの中では、ヒープのデフォルトがcgroupの上限から決まります。java -XX:+PrintFlagsFinal -versionが実際のMaxHeapSizeを出力し、-XX:MaxRAMPercentage(デフォルトは25%)を変えると、その値に合わせて変わります。メモリ上限が2GiのPodでは、デフォルトの最大ヒープは512MBで、50%に上げると1GiBです。ヒープを上限に近づけすぎると、ヒープ外のメモリ(メタスペース・スレッドスタック・ダイレクトバッファー)のせいで、OutOfMemoryErrorではなくOOMKilledで死にます。この2つは別の事件で、ダンプも残りません。
現場での姿
事件のあとにダンプがないのが最も多いパターンです。オプションを有効にしていなかった、有効にしていたがディスクがなかった、コンテナが再起動してファイルが消えた、のいずれかです。ダンプのパスは、残るボリュームでなければなりません。2つ目は、ExitOnOutOfMemoryErrorなしで「死なないゾンビ」を数時間放置することです。3つ目は、ヒストグラムの1行目を見て「byte配列が問題」と結論づけることです。[Bは常に1位です。問うべきは、誰がそれを保持しているかで、その答えはダンプのドミネーターツリーにあります。最後に、起動オプションを人の記憶に頼ることです。オプションは起動スクリプトに書き、そのスクリプトをリポジトリに置きます。
次のラボですること
Leak.javaを-Xmx64mで動かしてOutOfMemoryErrorを発生させ、HeapDumpOnOutOfMemoryErrorでダンプを取得し、稼働中のプロセスでjcmdを使ってヒストグラムとダンプを取り、ヒストグラムの1行目を読み、retain=falseで同じ割り当てがリークではないことを確認し、ExitOnOutOfMemoryErrorの終了コード3を確認し、MaxRAMPercentageでコンテナのヒープのデフォルトを測り、最後にこれらのオプションをすべて含む起動スクリプトを作って実際に実行します。