ケーススタディ: OOM クラッシュをデバッグする

メモリ不足(OutOfMemoryError)クラッシュは、アプリが割り当てられた Java ヒープを使い果たし、新しいメモリ割り当てを実行できない場合に発生します。OutOfMemoryError クラッシュ レポートのスタック トレースでは、メモリ不足が発生したときに失敗した割り当て呼び出しのみが示され、ヒープの残りの部分を保持しているオブジェクトは示されないため、根本原因を特定できることはほとんどありません。

ProfilingManager を使用すると、フィールド内のデバイスとローカルのデバイスから Java ヒープダンプをキャプチャして、Perfetto UI でオブジェクト保持グラフを検査し、メモリを保持しているものを特定できます。

シナリオ

ユーザーが OomSampleActivity を操作したときに、本番環境で OutOfMemoryError クラッシュのクラスタが増加していることを、クラッシュ レポート ツールが報告したとします。

調査のために、ProfilingManager に TRIGGER_TYPE_OOM トリガーを登録して、OutOfMemoryError が発生したときにシステムが Java ヒープダンプを自動的にキャプチャするようにします。

ヒープダンプをキャプチャする

Android 17(API レベル 37)以降で、OutOfMemoryError クラッシュが発生したときにヒープダンプをキャプチャするには、ProfilingTrigger.TRIGGER_TYPE_OOM を使用してトリガーベースのプロファイリングを構成します。アプリが未処理の OutOfMemoryError をスローすると、システムは自動的に新しい PROFILING_TYPE_JAVA_HEAP_DUMP キャプチャを開始します。

クラッシュが発生するとアプリのプロセスが終了するため、システムはバックグラウンドでヒープダンプの保存を完了し、結果をキューに入れます(トリガーのライフサイクルを参照)。アプリの起動が完了した後(アプリの起動が遅くならないようにするため)、registerForAllProfilingResults を使用して、バックグラウンド Executor にグローバル結果リスナーを登録します。これにより、ユーザーが次回アプリを起動したときに、キューに登録された ProfilingResult がアプリに届きます。addProfilingTriggers(アプリの再起動後も保持され、1 回だけ、またはトリガーの更新時にのみ呼び出す必要がある)とは異なり、registerForAllProfilingResults はアプリの起動ごとに呼び出す必要があります。

Kotlin

@RequiresApi(Build.VERSION_CODES.CINNAMON_BUN)
fun registerOomTrigger(context: Context) {
    val profilingManager =
        context.getSystemService(ProfilingManager::class.java) ?: return
    val callbackExecutor: Executor = Executors.newSingleThreadExecutor()

    val resultCallback = Consumer<ProfilingResult> { profilingResult ->
        if (
            profilingResult.errorCode == ProfilingResult.ERROR_NONE &&
            profilingResult.triggerType == ProfilingTrigger.TRIGGER_TYPE_OOM
        ) {
            val heapDumpPath = profilingResult.resultFilePath
            // Schedule a background job to compress and upload heapDumpPath
        }
    }

    profilingManager.registerForAllProfilingResults(callbackExecutor, resultCallback)

    val oomTrigger = ProfilingTrigger.Builder(ProfilingTrigger.TRIGGER_TYPE_OOM)
        .build()
    profilingManager.addProfilingTriggers(listOf(oomTrigger))
}

Java

@RequiresApi(Build.VERSION_CODES.CINNAMON_BUN)
public static void registerOomTrigger(Context context) {
    ProfilingManager profilingManager = context.getSystemService(ProfilingManager.class);
    if (profilingManager == null) return;
    Executor callbackExecutor = Executors.newSingleThreadExecutor();

    Consumer<ProfilingResult> resultCallback =
        profilingResult -> {
            if (profilingResult.getErrorCode() == ProfilingResult.ERROR_NONE
                && profilingResult.getTriggerType() == ProfilingTrigger.TRIGGER_TYPE_OOM) {
                String heapDumpPath = profilingResult.getResultFilePath();
                // Schedule a background job to compress and upload heapDumpPath
            }
        };

    profilingManager.registerForAllProfilingResults(callbackExecutor, resultCallback);

    ProfilingTrigger oomTrigger =
        new ProfilingTrigger.Builder(ProfilingTrigger.TRIGGER_TYPE_OOM)
            .build();
    profilingManager.addProfilingTriggers(Collections.singletonList(oomTrigger));
}

プロファイルの難読化を解除する

OutOfMemoryError がキャプチャをトリガーし、次のアプリ起動時にコールバックが ProfilingResult を受け取ったら、プロファイル ファイルをサーバーにアップロードするか、adb を使用して開発マシンに pull します。R8 コード圧縮と難読化を使用するリリースビルドからプロファイルをキャプチャした場合は、分析前にワークステーションまたはサーバーでオフラインで難読化を解除します。難読化解除を行わない場合、R8 は未加工のヒープダンプ内の Java と Kotlin のクラス、フィールド、メソッドの名前を難読化します(たとえば、元のクラス名を a.b に変換します)。

Perfetto の trace_processor bundle コマンドに --proguard-map フラグを指定して、キャプチャしたプロファイルを、そのリリースビルド用に生成された R8 mapping.txt ファイルと一緒にパッケージ化します。

trace_processor bundle \
  --proguard-map /path/to/mapping.txt \
  profile_trigger-type-7_2026-05-06-14-12-40-748_uid-10316.perfetto-java-heap-dump \
  deobfuscated_oom.tar

このコマンドは、元のプロファイルとその難読化解除マッピングをバンドルした、拡張アーカイブである deobfuscated_oom.tar を生成します。このアーカイブを Perfetto UI で開くか、trace_processor でクエリすると、Perfetto は元のクラス、フィールド、メソッドの名前をすべて自動的に復元します。詳細については、Perfetto のシンボル化と難読化解除に関するドキュメントをご覧ください。

ヒープダンプを分析する

難読化解除されたヒープダンプは、次の手順に沿って Perfetto UI で手動で検査することも、AI エージェントを使用して OOM 調査を自動化することもできます。

Perfetto UI で難読化解除されたヒープダンプを開き、左側のナビゲーション バーで [Heapdump Explorer] を選択します。[Flamegraph] タブで [Measure] を [Object Size] に設定すると、図 1 の保持階層がすぐに目立ちます。

Perfetto UI Heapdump Explorer のフレームグラフ。StatsReporter が OomSampleActivity の 8 つのインスタンス(合計 240.28 MiB)を保持していることを示しています。
図 1. OomSampleActivity の 8 個の保持インスタンスを示すヒープダンプのフレームグラフ。

図 1 のフレームグラフを調べると、いくつかの重要なことがわかります。

  • 大量のメモリ保持: 254.79 MiB の合計ヒープ(root)のうち、com.example.sampleapp.OomSampleActivity は 240.28 MiB(アプリヒープ全体の 94.31%)の累積サイズを保持しています。そのほとんどは子 byte[] 配列にあります。
  • 複数のライブ アクティビティ インスタンス: OomSampleActivity フレームを選択すると、[Self Count: 8] と表示されます。画面上に同時に存在する必要がある OomSampleActivity のインスタンスは 1 つだけですが、8 つの個別のインスタンスが同時にメモリに存在しています。
  • 保持オブジェクトのパス: フレームグラフで OomSampleActivity の親フレームを読み取ると、それらのインスタンスを保持している参照チェーンが表示されます。com.example.sampleapp.StatsReporter は java.util.concurrent.CopyOnWriteArrayList(java.lang.Object[] によってバックアップされている)を保持し、8 つの OomSampleActivity インスタンスすべてを直接参照しています。

コード内の根本原因を特定する

保持パスが StatsReporter から OomSampleActivity を指している状態で、コードベース内の両方のクラスを調べます。

StatsReporter は、StatsListener コールバックのリストを保持し、バックグラウンド ポーリング スレッドから定期的に更新をポストするシングルトンです。

Kotlin

class StatsReporter private constructor() {
    fun interface StatsListener {
        fun onReportStats(stats: String)
    }

    private val listeners: MutableList<StatsListener> = CopyOnWriteArrayList()
    private var isReporting = false

    init {
        startReporting()
    }

    fun registerListener(listener: StatsListener) {
        if (!listeners.contains(listener)) {
            listeners.add(listener)
        }
    }

    fun unregisterListener(listener: StatsListener) {
        listeners.remove(listener)
    }

    private fun startReporting() {
        if (isReporting) return
        isReporting = true

        Thread({
            while (true) {
                try {
                    // Poll every 10 seconds
                    Thread.sleep(10000)
                } catch (e: InterruptedException) {
                    Thread.currentThread().interrupt()
                    break
                }

                for (listener in listeners) {
                    // Send periodic mock stats update from the background thread
                    listener.onReportStats("Update from StatsReporter background polling thread")
                }
            }
        }, "StatsReporterPollingThread").start()
    }

    companion object {
        private var instance: StatsReporter? = null

        @JvmStatic
        fun getInstance(): StatsReporter {
            return instance ?: StatsReporter().also { instance = it }
        }
    }
}

Java

public class StatsReporter {
    public interface StatsListener {
        void onReportStats(String stats);
    }

    private static StatsReporter sInstance;

    private final List<StatsListener> mListeners = new CopyOnWriteArrayList<>();
    private boolean mIsReporting = false;

    private StatsReporter() {
        startReporting();
    }

    public static StatsReporter getInstance() {
        if (sInstance == null) {
            sInstance = new StatsReporter();
        }
        return sInstance;
    }

    public void registerListener(StatsListener listener) {
        if (!mListeners.contains(listener)) {
            mListeners.add(listener);
        }
    }

    public void unregisterListener(StatsListener listener) {
        mListeners.remove(listener);
    }

    private void startReporting() {
        if (mIsReporting) return;
        mIsReporting = true;

        new Thread(() -> {
            while (true) {
                try {
                    // Poll every 10 seconds
                    Thread.sleep(10000);
                } catch (InterruptedException e) {
                    Thread.currentThread().interrupt();
                    break;
                }

                for (StatsListener listener : mListeners) {
                    // Send periodic mock stats update from the background thread
                    listener.onReportStats("Update from StatsReporter background polling thread");
                }
            }
        }, "StatsReporterPollingThread").start();
    }
}

次に、OomSampleActivity を調べます。

Kotlin

class OomSampleActivity : Activity(), StatsReporter.StatsListener {
    // Allocate some memory.
    private val heavyData = ByteArray(30 * 1024 * 1024)

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_oom_sample)

        StatsReporter.getInstance().registerListener(this)
    }

    override fun onReportStats(stats: String) {
        // Read from heavyData so R8 doesn't strip it out as dead code during release builds
        Log.d(TAG, "Received stats in heavy leaked activity: $stats | Data size: ${heavyData.size}")
    }

    companion object {
        private const val TAG = "OomSampleActivity"
    }
}

Java

public class OomSampleActivity extends Activity implements StatsReporter.StatsListener {
    private static final String TAG = "OomSampleActivity";

    // Allocate some memory.
    private final byte[] mHeavyData = new byte[30 * 1024 * 1024];

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_oom_sample);

        StatsReporter.getInstance().registerListener(this);
    }

    @Override
    public void onReportStats(String stats) {
        // Read from mHeavyData so R8 does not strip it out as dead code during release builds
        Log.d(TAG, "Received stats in heavy leaked activity: " + stats + " | Data size: " + mHeavyData.length);
    }
}

このコードは、StatsReporter が OomSampleActivity の 8 つのインスタンスを保持している理由を確認します。

  1. 各 OomSampleActivity インスタンスは、30 MiB の byte[] バッファ(Kotlin では heavyData、Java では mHeavyData)を割り当て、onCreate のシングルトン StatsReporter のリスナーとして自身を登録します。
  2. OomSampleActivity は、システムによって破棄されるときに unregisterListener を呼び出すことはありません。StatsReporter はアクティビティよりも長く存続し、登録されたすべてのリスナーへの強い参照を保持するため、ガベージ コレクタは破棄されたインスタンスやその 30 MiB の byte[] 配列を再利用できません。
  3. Android アクティビティのライフサイクルで画面が回転したときなど、システムが OomSampleActivity を破棄して再作成するたびに、別の 30 MiB のインスタンスが StatsReporter にリークします。7 回のローテーション(8 インスタンス × 30 MiB ≈ 240 MiB)の後、ヒープがいっぱいになり、アプリが OutOfMemoryError でクラッシュします。

漏れを修理する

メモリリークを修正するには、アクティビティで onDestroy をオーバーライドし、StatsReporter.getInstance().unregisterListener(this) を呼び出して、システムがアクティビティを破棄するときにシングルトンが参照を解放するようにします。

Kotlin

class OomSampleActivityFixed : Activity(), StatsReporter.StatsListener {
    // Allocate some memory.
    private val heavyData = ByteArray(30 * 1024 * 1024)

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_oom_sample)

        // Register the activity as a listener
        StatsReporter.getInstance().registerListener(this)
    }

    override fun onDestroy() {
        super.onDestroy()
        // FIX: Unregister the listener to prevent the memory leak
        // when the Activity is destroyed (for example, during screen rotation).
        StatsReporter.getInstance().unregisterListener(this)
    }

    override fun onReportStats(stats: String) {
        // Read from heavyData so R8 doesn't strip it out as dead code during release builds
        Log.d(TAG, "Received stats in fixed heavy activity: $stats | Data size: ${heavyData.size}")
    }

    companion object {
        private const val TAG = "OomSampleFixed"
    }
}

Java

public class OomSampleActivityFixed extends Activity implements StatsReporter.StatsListener {
    private static final String TAG = "OomSampleFixed";

    // Allocate some memory.
    private final byte[] mHeavyData = new byte[30 * 1024 * 1024];

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_oom_sample);

        // Register the activity as a listener
        StatsReporter.getInstance().registerListener(this);
    }

    @Override
    protected void onDestroy() {
        super.onDestroy();
        // FIX: Properly unregister the listener to prevent the memory leak
        // when the Activity is destroyed (e.g., during screen rotation).
        StatsReporter.getInstance().unregisterListener(this);
    }

    @Override
    public void onReportStats(String stats) {
        // Read from mHeavyData so R8 does not strip it out as dead code during release builds
        Log.d(TAG, "Received stats in fixed heavy activity: " + stats + " | Data size: " + mHeavyData.length);
    }
}

AI エージェントを使用して OOM 調査を自動化する

フレームグラフを手動で検査してファイル間の参照をトレースする代わりに、Perfetto AI スキルで構成された AI コーディング エージェントを使用して、ヒープダンプを分析し、コードベースに関連付けることができます。

Analyze the Perfetto OOM heap dump at </path/to/profile>, which corresponds to
the codebase located at </path/to/codebase> using the Perfetto AI skill and find
the root cause of the OutOfMemoryError.

エージェントは trace_processor を使用してヒープダンプをクエリし、StatsReporter からリークされた OomSampleActivity インスタンスへのドミネーター パスをトレースして、コードベース内の両方のクラスを検査します。手動による介入なしで、OomSampleActivity が onCreate にリスナーを登録し、登録を解除していないという同じ結論に達し、onDestroy に unregisterListener(this) を追加するパッチを生成します(次のレポートは、クラッシュ時に 7 つの保持インスタンスがあった別のキャプチャから生成されたものです)。

# Perfetto OOM Heap Dump Analysis Report

## 1. Orienting Context & First-Pass Triage

| Property | Value |
| :--- | :--- |
| **Process Name** | `com.example.sampleapp` |
| **Sample Timestamp (`graph_sample_ts`)** | `3433221241585` |
| **Leaf Retained Class** | `byte[]` (7 instances) |
| **Leaf Self Size** | `220,201,044` bytes (~210 MiB) |

### Dominator Tree Retention Path

Querying the `android.memory.heap_graph.dominator_tree` standard library module identifies the following largest dominated class path from GC root to leaf allocation:

    [ROOT_JAVA_FRAME] com.example.sampleapp.StatsReporter [1]
      -> java.util.concurrent.CopyOnWriteArrayList [1]
      -> java.lang.Object[] [1]
      -> com.example.sampleapp.OomSampleActivity [7]
      -> byte[] [7] (220,201,044 bytes)
## 2. Root Cause Analysis

> **Important:** The `OutOfMemoryError` is caused by an **Activity listener leak**: `OomSampleActivity` (`app/src/main/java/com/example/sampleapp/OomSampleActivity.java#L8-L27`) registers itself as a listener on the long-lived `StatsReporter` (`app/src/main/java/com/example/sampleapp/StatsReporter.java#L6-L61`) singleton in `onCreate()`, but never unregisters itself in `onDestroy()`.

### Step-by-Step Retention Mechanism

1. **Per-Activity 30 MiB Allocation:**
   Each instance of `OomSampleActivity` (`OomSampleActivity.java#L11-L12`) allocates a 30 MiB byte array (`30 * 1024 * 1024 = 31,457,280` bytes):
   ```java
   // OomSampleActivity.java:12
   private final byte[] mHeavyData = new byte[30 * 1024 * 1024];
   ```

2. **Strong Reference Registration in `onCreate()`:**
   When created, `OomSampleActivity.onCreate()` (`OomSampleActivity.java#L15-L20`) registers `this` with `StatsReporter` (`StatsReporter.java#L29-L33`):
   ```java
   // OomSampleActivity.java:19
   StatsReporter.getInstance().registerListener(this);
   ```

3. **GC Root Pinning via Background Thread & Singleton:**
   `StatsReporter` (`StatsReporter.java#L11-L60`) is both a static singleton (`sInstance`) and the owner of an active background thread (`StatsReporterPollingThread`, which holds a `[ROOT_JAVA_FRAME]` GC root reference while sleeping/looping in `startReporting()`). It stores listeners in `mListeners` (`CopyOnWriteArrayList<StatsListener>`).

4. **Leak Accumulation Across Activity Recreations:**
   Because `OomSampleActivity` does not override `onDestroy()` to call `StatsReporter.unregisterListener(this)`, every destroyed instance of `OomSampleActivity` remains strongly reachable in `mListeners`. After 7 Activity creations (7 * 30 MiB = 220,200,960 bytes plus array headers = `220,201,044` bytes), the Java heap is exhausted.

---

## 3. Actionable Implementation Plan

### 1. Unregister the Listener in `OomSampleActivity.onDestroy()`

Update `OomSampleActivity.java` to unregister the listener when the `Activity` is destroyed (matching the fix in `OomSampleActivityFixed.java`):

```diff
--- a/app/src/main/java/com/example/sampleapp/OomSampleActivity.java
+++ b/app/src/main/java/com/example/sampleapp/OomSampleActivity.java
@@ -19,4 +19,10 @@
         StatsReporter.getInstance().registerListener(this);
     }

+    @Override
+    protected void onDestroy() {
+        super.onDestroy();
+        StatsReporter.getInstance().unregisterListener(this);
+    }
+
     @Override
```

### 2. Optional Architectural Hardening in `StatsReporter`

* **Lifecycle-Aware Registration:** Have `StatsReporter.registerListener()` accept a `LifecycleOwner` and attach a `DefaultLifecycleObserver` that automatically invokes `unregisterListener()` on `ON_DESTROY`.
* **Weak References:** Store `WeakReference<StatsListener>` entries in `StatsReporter` and evict cleared references during iteration so forgotten `unregisterListener()` calls cannot retain entire `Activity` hierarchies.

修正を確認する

テストデバイスでローカルに、または本番環境で、リークが解決されたことを確認します。

問題がローカルで再現可能な場合

TRIGGER_TYPE_OOM は OutOfMemoryError が発生した場合にのみヒープダンプをキャプチャするため、修正が成功するとトリガーは起動しません。ヒープを検査してリークが解消されたことを確認するには、画面を回転させてから、コマンドラインからオンデマンドの Java ヒープダンプをキャプチャします。

  1. レート制限を無効(rate_limiter.disabled true)にし、一時的な結果を保持(delete_temporary_results.disabled true)して、システムがコマンドライン キャプチャを /data/misc/perfetto-traces/profiling/ に保持するようにします。
  2. テストデバイスで OomSampleActivityFixed を開き、画面を 8 回以上回転させて、アプリがクラッシュしなくなったことを確認し、フォアグラウンドに維持します。
  3. cmd profiling_service request-profiling シェルコマンドを使用して、オンデマンドの Java ヒープダンプ(profiling_type 1)をトリガーします。

    adb shell cmd profiling_service request-profiling 1 com.example.sampleapp
    
  4. /data/misc/perfetto-traces/profiling/ から保持されたプロファイルを pull し、Perfetto UI で開きます。

修正後の Perfetto UI Heapdump Explorer のフレームグラフ。30.01 MiB を保持する OomSampleActivityFixed の単一インスタンスが表示されています。
図 2. 修正後のヒープダンプ フレームグラフ。OomSampleActivityFixed のインスタンスが 1 つだけ表示されています。

図 2 に示すように、画面を複数回回転すると、次のようになります。

  • com.example.sampleapp.OomSampleActivityFixed のセルフカウントは 1 で、回転ごとにインスタンスが累積されることはありません。
  • 累積保持サイズは 30.01 MiB(合計ヒープ 44.27 MiB のうち)で安定しており、ガベージ コレクタが以前のアクティビティ インスタンスを再利用していることが確認できます。

ローカルで問題を再現するのが難しい場合

複雑なマルチスクリーン ナビゲーション、長時間にわたるセッション、特定のユーザーデータが原因で OutOfMemoryError が発生し、テストデバイスで確実に再現できない場合は、ロールアウト後に本番環境で修正を検証します。

  • クラッシュ レポート ツールで OutOfMemoryError クラッシュ クラスタをリリース全体のプロキシ指標としてモニタリングし、OomSampleActivity に関連するクラッシュが減少していることを確認します。
  • 更新されたリリースから収集された新しい TRIGGER_TYPE_OOM ヒープダンプを検査して、StatsReporter が破棄された OomSampleActivity インスタンスを保持していないことを確認します。