Java メモリの分析

Java アプリケーションと Kotlin アプリケーションは、ガベージ コレクションされたヒープを介してメモリを管理します。オブジェクトにアクセスできなくなると、ガベージ コレクタ(GC)が最終的にそのスペースを再利用します。メモリリークは、不要になったオブジェクトが「GC ルート」によって保持され、再利用されない場合に発生します。

基本コンセプト

GC ルート

GC ルートは、ガベージ コレクタが常に到達可能として扱う特殊なタイプのオブジェクトです。次に例を示します。

  • アクティブなスレッド(および現在実行中の Java スタック フレームから参照されるオブジェクト)。
  • アクティブに実行されているメソッドを含むクラス。
  • JNI 参照(ネイティブ コードによって保持されるグローバル参照またはローカル参照)。

GC ルートへのパス

GC ルートからオブジェクトへの参照チェーンが存在する限り、そのオブジェクトは「到達可能」であり、ガベージ コレクションの対象にはなりません。このチェーンは Path to GC Root と呼ばれます。メモリリークを修正するには、このチェーンを特定して切断する必要があります。

GC ルートへのパス

支配木

GC ルートへのパスは、オブジェクトが存続している理由を示しますが、その参照が切断された場合に回収されるメモリの量は示しません。これには、ドミネーター ツリーを使用します。

任意の GC ルートから B へのすべてのパスが A を通過する必要がある場合、オブジェクト A はオブジェクト B を支配すると言います。A が B を支配している場合、A を再利用すると、B を再利用できることも保証されます。これは、ルートから B への他のパスが存在しないためです。

次の図は、オブジェクト グラフとそれに対応するドミネーター ツリーを示しています。グラフでは、オブジェクト D に A と B の両方から到達できるため、A も B も D を支配していません。代わりに、GC ルートが最も近い支配者になります。

支配木

Java ヒープダンプの取得

ヒープダンプは、特定の時点での Java ヒープ内のすべてのオブジェクトのスナップショットです。

ADB の使用

実行中のプロセスからヒープダンプを取得するには、パッケージ名を am dumpheap に直接渡します。このコマンドを実行するには、<profileable android:shell="true"/> または <debuggable> を使用してアプリをビルドする必要があります。

# 1. Trigger the dump (the command takes a moment to complete):
adb shell am dumpheap -g -b png com.android.memorylab /data/local/tmp/heap.hprof

# 2. Pull the file to your development machine:
adb pull /data/local/tmp/heap.hprof .

Perfetto の使用

Perfetto では、Perfetto 構成で android.java_hprof データソースを有効にすることで、システム全体のトレースの一部として Java ヒープダンプをキャプチャすることもできます。これは、ヒープ状態を他のシステム イベントと関連付ける場合に便利です。

Perfetto を使用して MemoryLab アプリのヒープダンプをキャプチャするには、次のコマンドを使用します。

# Create a temp file for the configuration
cat > /tmp/java_heap.pbtx <<EOF
data_sources: {
    config {
        name: "android.java_hprof"
        java_hprof_config {
            process_cmdline: "com.android.memorylab"
        }
    }
}
EOF

# Run trace command referencing the file
external/perfetto/tools/record_android_trace -o java_heap.perfetto-trace \
  -t 10s -c /tmp/java_heap.pbtx

Perfetto ドキュメントの Java ヒープダンプをご覧ください。

AHAT を使用した分析

AHAT(Android Heap Analysis Tool)は、ウェブブラウザで .hprof ファイルを表示するのに推奨されるツールです。

AHAT の開始

パスに ahat がインストールされている場合は、次のコマンドで起動します。

ahat heap.hprof

または、スタンドアロン jar を実行します。

java -jar ahat.jar heap.hprof

次に、ブラウザで http://localhost:7100 を開きます。

AHAT の取得またはビルドの詳細については、AHAT ソース リポジトリをご覧ください。

主な分析ワークフロー

リークの検出

[Allocations] ビューでアクティビティ クラス(MainActivity)を検索します。

インスタンスを表示している AHAT ビュー

クラスをクリックして、すべてのインスタンスを見つけます。

MainActivity インスタンスを示す AHAT ビュー MainActivity インスタンスをクリックして検査します。

インスタンスの詳細を示す AHAT

インスタンス ビューでは、GC ルートからのサンプルパス(オブジェクトがガベージ コレクションされないようにする参照のチェーンを示す)と、オブジェクト サイズ(この特定のインスタンスによって保持されているメモリの量を示す)を確認できます。

GC ルートとオブジェクト サイズからの AHAT サンプルパス

ビットマップの分析

AHAT には、メモリを大量に消費することが多い android.graphics.Bitmap オブジェクトを表示するための特別なサポートがあります。Bitmap インスタンスをクリックすると、その内容のレンダリングされたプレビューが表示されます。

AHAT ビットマップ プレビュー

アクティビティ リーク ページ

AHAT には、リークしたアクティビティを特定するための専用ビューがあります。これは、Android で最も一般的で影響の大きいメモリリークの 1 つです。

  1. アクション: MemoryLab で [アクティビティをリーク] をタップします。これにより、意図的にリークする LeakedActivity が起動します。
  2. ダンプ: ヒープダンプを取得します。
  3. 分析: AHAT のサイドバーで [Activity Leaks] をクリックします。
  4. 検証: AHAT は、mDestroyed フィールドが true(アクティビティのライフサイクルが終了したことを示す)であるにもかかわらず、GC ルートから到達可能であるため、com.android.memorylab.LeakedActivity をリークとしてリストします。

AHAT アクティビティ リーク ページ

ヒープダンプの差分

2 つのヒープダンプを比較することは、メモリの問題を特定する最も強力な方法の 1 つです。「クリーン」なベースライン ダンプと、何らかのアクションを実行した後に取得したダンプを比較することで、どのオブジェクトが蓄積されたかをすぐに確認できます。

演習: 差分を使用してリークを特定する

  1. ベースライン: MemoryLab を起動して、ベースラインのヒープダンプを取得します。

    adb shell am dumpheap com.android.memorylab /data/local/tmp/base.hprof
    adb pull /data/local/tmp/base.hprof .
    
  2. アクション: アプリで [Allocate Java Memory(10MB)] を数回タップします。

  3. 最終: 2 回目のヒープダンプを取得します。

    adb shell am dumpheap com.android.memorylab /data/local/tmp/leaked.hprof
    adb pull /data/local/tmp/leaked.hprof .
    
  4. 比較: 2 番目のダンプをプライマリ、1 番目のダンプをベースラインとして AHAT を起動します。

    java -jar out/host/linux-x86/framework/ahat.jar leaked.hprof --baseline base.hprof
    
  5. 分析の概要: [概要] ページに [Δ(差分)] 列が追加されました。app ヒープのデルタが大幅に増加し、メモリが大幅に増加していることがわかります。

AHAT の概要(Delta)

  1. ドリルダウン: メニューで [ルート化] をクリックします。このページには、GC ルートから到達可能なオブジェクトが保持サイズで並べ替えられて表示されます。MainActivity が上部に表示され、大きなプラスの差分が表示されます。

AHAT Rooted View with Delta

割り当てのスタック トレースを記録する

[Sample Path from GC Root] は、オブジェクトがまだ存続している理由を示しますが、 オブジェクトがどのように作成されたかは示しません。割り当てスタック トレースは、オブジェクトを割り当てたコードの正確な行を提供します。

コンセプトとトレードオフ: すべての割り当てのスタック トレースを記録すると、計算コストが高くなり、大量のメモリが消費されます。大規模な本番環境アプリでは、アプリがほぼ使用できなくなる可能性があります。ただし、MemoryLab は、このトラッキングを安全に有効にして割り当てのソースを特定できるほど小さいアプリケーションです。

演習: バイト配列のソースを特定する

  1. トラッキングから開始: MemoryLab を強制停止し、--track-allocation フラグを指定して再起動します。より多くのコンテキストをキャプチャするために、デフォルトのスタックの深さを増やしました。

    # Increase the allocation tracker's stack depth (requires a process restart)
    adb shell setprop dalvik.vm.allocTrackerMaxStack 16
    
    adb shell am force-stop com.android.memorylab
    adb shell am start --track-allocation -n com.android.memorylab/.MainActivity
    
  2. アクション: [Allocate Java Memory(10MB)] を数回タップします。

  3. Dump: ヒープダンプを取得して pull します。

  4. 分析: AHAT でダンプを開きます。大規模な byte[] インスタンスに移動します。(例: MainActivity → mJavaAllocations(ArrayList)→ elementData(Object[])→ 配列要素 [0] を検査します)。

  5. 確認: インスタンス ビューで、[割り当てサイト] セクションを確認します。MainActivity.allocateJava につながる完全なスタック トレースが表示されます。

AHAT 割り当てサイト

重複する文字列とハイドレーションの肥大化を特定する

アプリに従来の GC ルートのリークがない場合でも、JSON、Protobuf、Cursor、Room データベースの逆シリアル化中に作成された何千もの重複する java.lang.String インスタンスによって、ライブ Java ヒープが肥大化する可能性があります。繰り返されるキー、ステータス文字列、カテゴリラベル、URL は、ネットワーク レスポンスやデータベース クエリごとに新たに割り当てられることがよくあります。大規模なフィード、メッセージング、コンテンツ アプリでは、重複する文字列がライブ String メモリの 30 ~ 60% を占めることがよくあります。

AHAT で重複する文字列を検査するには:

  1. [割り当て] ページを開き、java.lang.String でフィルタします。
  2. --baseline で 2 つのヒープダンプを比較するときは、フィードのハイドレーション後またはローカル キャッシュの読み込み後に、java.lang.String インスタンス数と合計バイト数が不均衡に増加しているかどうかを確認します。
  3. java.lang.String インスタンス テーブル(サイズまたは値で並べ替え)を参照して、複数のインメモリ モデル オブジェクトに保持されている同一の文字列値を特定します。
  • 解決策: ランタイム インターン テーブルはグローバルであり、ロック競合が発生したり、文字列が必要以上に長く保持されたりする可能性があるため、任意のユーザー入力やネットワーク入力に対して String.intern() を無差別に呼び出すことは避けてください。代わりに、境界付きのスコープ付き重複除去キャッシュ(パーサーまたはアダプタ内の LruCache<String, String> など)を使用して、逆シリアル化中に高頻度のドメイン文字列を重複除去するか、値の固定セットを列挙型または整数定数として表します。

Java メモリの動的分析(結合プロファイル)

アプリのメモリ動作の全体像を把握するには、メモリ カウンタ、スレッド アクティビティ、コールスタック ベースの割り当てプロファイリングを 1 つの Perfetto トレースに統合します。これにより、システム全体のメモリ指標(RSS やヒープサイズなど)を特定のコード実行サイトや割り当てサイトと関連付けることができます。

次の機能を有効にする統合構成を使用します。

  • メモリカウンタ(linux.process_stats): RSS やその他のメモリ指標をポーリングします。
  • ATrace(dalvik、memory、sched カテゴリ): スレッドの状態と GC イベントをキャプチャします。
  • Heapprofd(android.heapprofd): com.android.art(Java)ヒープと libc.malloc(ネイティブ)ヒープの両方を対象とし、5 秒ごとに連続してダンプします。

演習: 結合されたメモリ分析

この演習では、MemoryLab アプリを実行し、一連のメモリ オペレーションを実行して、トレースのさまざまなパターンを観察します。

  1. ベースライン: アイドル状態。
  2. Java Churn: すぐにガベージ コレクションされる一時的な割り当て。
  3. 永続的な Java 割り当て: メモリに残る Java オブジェクトを割り当てます。
  4. ビットマップ割り当て: 大量のグラフィック アセット(ネイティブ ヒープ/グラフィック メモリに存在する)を割り当てます。
  5. 再利用: 割り当てられたすべてのリソースを解放します。

1. リリースと準備

  1. アプリを強制停止して再起動し、クリーンな状態にします。

    adb shell am force-stop com.android.memorylab
    adb shell am start -W -n com.android.memorylab/.MainActivity
    

2. トレースを開始してシーケンスをトリガーする

40 秒間のトレースを開始し、am broadcast コマンドを使用してメモリ イベントをトリガーします。

  1. トレースを開始:

    adb shell perfetto -c - --txt -o /data/misc/perfetto-traces/java_memory.perfetto-trace <<EOF
    buffers: {
        size_kb: 131072
        fill_policy: RING_BUFFER
    }
    data_sources: {
        config {
            name: "linux.process_stats"
            target_buffer: 0
            process_stats_config {
                scan_all_processes_on_start: true
                proc_stats_poll_ms: 100
            }
        }
    }
    data_sources: {
        config {
            name: "linux.ftrace"
            target_buffer: 0
            ftrace_config {
                ftrace_events: "sched/sched_switch"
                ftrace_events: "task/task_newtask"
                ftrace_events: "task/task_rename"
                ftrace_events: "ftrace/print"
                atrace_categories: "dalvik"
                atrace_categories: "am"
                atrace_categories: "res"
                atrace_categories: "memory"
                atrace_categories: "sched"
                atrace_apps: "com.android.memorylab"
            }
        }
    }
    data_sources: {
        config {
            name: "android.heapprofd"
            target_buffer: 0
            heapprofd_config {
                sampling_interval_bytes: 4096
                process_cmdline: "com.android.memorylab"
                heaps: "libc.malloc"
                heaps: "com.android.art"
                shmem_size_bytes: 8388608
                block_client: true
                continuous_dump_config {
                    dump_phase_ms: 1000
                    dump_interval_ms: 5000
                }
            }
        }
    }
    duration_ms: 40000
    EOF
    
  2. シーケンスをトリガーする(トレースの実行中に、推奨されるタイミングでホスト ターミナルで次のコマンドを実行します)。

    # Wait ~5s for trace initialization, then start Java churn:
    adb shell am broadcast -a com.android.memorylab.CHURN_JAVA
    
    # Wait ~10s (at 15s mark), allocate 10MB of persistent Java memory:
    adb shell am broadcast -a com.android.memorylab.ALLOC_JAVA
    
    # Wait ~5s (at 20s mark), allocate 20MB of Bitmaps (native/graphics):
    adb shell am broadcast -a com.android.memorylab.LEAK_BITMAP
    
    # Wait ~10s (at 30s mark), free everything:
    adb shell am broadcast -a com.android.memorylab.FREE_ALL
    
  3. 代替(CLI ツール): heap_profile スクリプトを直接使用してプロファイリングを開始することもできます。この場合、Java ヒープとネイティブ ヒープの両方をターゲットとし、連続ダンプを行います。

    external/perfetto/tools/heap_profile -n com.android.memorylab \
      --heaps com.android.art,libc.malloc \
      -c 5000 \
      -d 40000 \
      -o java_memory_profile
    

3. 結合されたトレースの分析

収集した java_memory.perfetto-trace を Perfetto UI で開きます。

Perfetto のキー トラック

タイムラインを分析する前に、com.android.memorylab プロセスの次の重要なトラックを見つけます。

  1. mem.rss.anon(匿名 RSS): プロセスの [メモリ] セクションにあります。このトラックは、OS によってプロセスに割り当てられた物理メモリ(RAM)を測定します。これは実際のメモリ使用量を表します。
  2. Heap size (KB): [メモリ] セクションにも表示されます。これは、Java ヒープ用に予約された仮想アドレス空間を表す Dalvik/ART 固有のカウンタです。これは、オブジェクトの割り当てと GC の実行に応じて変動する VM の内部ヒープ上限を反映しています。
  3. HeapTaskDaemon: プロセスのスレッドのリストにあります。これは、ART ガベージ コレクタがほとんどの処理を行うバックグラウンド スレッドです。ここでのアクティビティは、アクティブな GC パスを示します。
  4. 継続的割り当てダンプ(heapprofd): 上部のタイムラインに沿って色付きのスライスとして表示されます。各スライスは時間の長さを表します。スライスを 1 つクリックするか、時間範囲を選択すると、com.android.art(Java 割り当て)または libc.malloc(ネイティブ割り当て)のいずれかの Flamegraph(下部ペイン)を調べて、その期間中に何が割り当てられたかを確認できます。

時系列フェーズ分析

トレースを時系列で確認して、エクササイズの各フェーズでこれらのトラックがどのように相互作用するかを見てみましょう。

フェーズ 1: ベースライン(0 秒~ 5 秒)
  • 現在の状態: アプリはアイドル状態で、コマンドを待機しています。
  • トラックのステータス:
    • mem.rss.anon: ベースラインでのフラットな線(通常はデバイスに応じて 60 ~ 80 MB 程度)。
    • Heap size (KB): 初期 Java ヒープ割り当てと一致するフラットな線。
    • HeapTaskDaemon: アイドル状態(実行を示すスライスがない)。
    • 割り当てダンプ: 最小ベースライン割り当てを表示します。

フェーズ 1 のベースラインを表示している Perfetto UI

フェーズ 2: Java 割り当てのチャーン(5 ~ 15 秒)
  • 発生している事象: AllocationChurnThread が開始され、1 MB の配列の割り当てと破棄が繰り返し行われています。
  • トラックのステータス:
    • Heap size (KB): 急激なのこぎり歯状のパターンが表示されます。割り当てが累積するとヒープサイズが増加し、GC が実行されると急激に減少します。
    • HeapTaskDaemon: ほぼ一定のアクティビティを示し、実行スライスが Heap size ののこぎり波の低下と完全に一致しています。
    • mem.rss.anon: Java ヒープ アクティビティを追跡します。
    • Java Heap Allocation Dumps: このトラックのスライスを選択すると、com.android.art ヒープ割り当てが表示されます。

フェーズ 2 のチャーンを示す Perfetto UI 割り当てサンプルでは、AllocationChurnThread がプライマリ アロケータであることがわかります。すべての割り当ては、MainActivity.java 内のラムダを指す同じコールスタックを共有しています。

フェーズ 2 の Java 割り当てを示す Perfetto UI

フェーズ 3: 永続的な Java 割り当て(15 ~ 20 秒)
  • 何が起こっているか: 10 MB の Java オブジェクトを割り当て、mJavaAllocations でそれらへの参照を保持します。
  • トラックのステータス:
    • Heap size (KB): 鋸歯状のベースラインが約 10 MB 増加します。
    • mem.rss.anon: OS がこの永続的な割り当てを新しい物理ページでバックアップする必要があるため、約 10 MB ずつ増加します。
    • Java Heap Allocation Dumps: このトラックのスライスを選択すると、com.android.art ヒープ割り当てが表示されます。
    • 割り当てダンプ(フレームグラフ): このウィンドウで取得したダンプの com.android.art ヒープを検査すると、保持サイズに寄与する MainActivity.allocateJava からの新しい割り当てパスが表示されます。

フェーズ 3 の永続的な Java 割り当てを示す Perfetto UI 永続割り当ての 10 MB の増加と重複する期間をカバーする割り当てサンプルを選択します。割り当てコールスタックが 2 つの異なるサイトに分岐していることがわかります。1 つは以前に確認した短期間の割り当てチャーンを担当し、もう 1 つは新しい長期間の割り当てを担当しています。

フェーズ 3 の Java 割り当てを示す Perfetto UI

フェーズ 4: ビットマップの割り当て(20 ~ 30 秒)
  • 状況: また、20 MB のビットマップも割り当てます。
  • トラックのステータス:
    • Heap size (KB): 以前と同じ。
    • mem.rss.anon: ビットマップのピクセルデータのネイティブ割り当てに対応する約 20 MB の大幅な増加を示しています。
    • 割り当てダンプ(フレームグラフ): 今回は、libc.malloc(ネイティブ)ヒープのスライスに注目します。

フェーズ 4 のビットマップ割り当てを表示している Perfetto UI ネイティブ割り当てコールスタックは、ネイティブ グラフィック ライブラリから発生したビットマップ割り当てを示します。これらの Bitmap 割り当ては Java ヒープに表示されないため、ネイティブ割り当てトラッキングのユースケースとして適しています。

フェーズ 4 のネイティブ割り当てを示す Perfetto UI

フェーズ 5: 再利用(30 ~ 40 秒)
  • 何が起こっているか: FREE_ALL をトリガーし、永続的な Java 割り当てとビットマップへの参照をすべてクリアしてから、明示的な System.gc() を実行します。
  • トラックのステータス:
    • Heap size (KB): ベースライン レベルまで低下します。
    • mem.rss.anon: 物理ページを OS が再利用していることを示すように、再び減少します。
    • HeapTaskDaemon: ガベージ コレクションの処理中に、アクティビティの最後のバーストを表示します。

フェーズ 5 の再利用を表示している Perfetto UI

過去の OOM のモニタリング(ApplicationExitInfo)

LMK をリアルタイムでキャッチすることはアクティブなデバッグに最適ですが、フィールド テレメトリーには ApplicationExitInfo API を使用できます。これにより、アプリは前のセッションで終了した理由を検出できます。

ActivityManager am = getSystemService(ActivityManager.class);
List<ApplicationExitInfo> exitReasons = am.getHistoricalProcessExitReasons(null, 0, 1);
if (!exitReasons.isEmpty()) {
    ApplicationExitInfo info = exitReasons.get(0);
    if (info.getReason() == ApplicationExitInfo.REASON_LOW_MEMORY) {
        // App was killed by the system Low Memory Killer
    }
}

ベスト プラクティス

  1. ベースライン優先: アプリが初期化された後、テストするアクションを実行する前に、常に「ベースライン」ヒープダンプを取得します。
  2. AHAT のアクティビティ リークページを使用する: AHAT には、破棄されたがメモリに保持されているアクティビティ インスタンスを自動的に特定する専用のアクティビティ リークページがあります。多くの場合、これが一般的なリークを見つける最も早い方法です。
  3. GC ルートへのパスを確認する: リークされたオブジェクトについては、AHAT の [Path from Root] ビューを使用して、どの参照によってオブジェクトが存続しているか(静的フィールド、長時間実行中のスレッド、登録済みのリスナーなど)を正確に把握します。

← ツール | ↑ 上へ | ビットマップ →