個案研究:對 OOM 異常終止問題進行偵錯

當應用程式用盡分配的 Java 堆積,且無法執行新的記憶體配置時,就會發生記憶體不足 (OutOfMemoryError) 當機。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 在應用程式重新啟動後仍會保留,且只需呼叫一次或在更新觸發條件時呼叫,但 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 將設定檔提取至開發機器。如果您是從使用 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 保留 8 個 OomSampleActivity 執行個體,總計 240.28 MiB。
圖 1. 記憶體快照資料火焰圖,顯示 8 個保留的 OomSampleActivity 執行個體。

檢查圖 1 中的火焰圖,可發現幾個重要結果:

  • 大量記憶體保留:在 254.79 MiB 的總堆積 (root) 中,com.example.sampleapp.OomSampleActivity 保留了 240.28 MiB 的「累計」大小 (整個應用程式堆積的 94.31%),其中大部分位於子項 byte[] 陣列中。
  • 多個即時活動例項:選取 OomSampleActivity 框架會顯示「Self Count: 8」。雖然畫面上任何時候都應該只有一個 OomSampleActivity 例項,但記憶體中同時有 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 保留 8 個 OomSampleActivity 執行個體的原因:

  1. 每個 OomSampleActivity 執行個體都會分配 30 MiB 的 byte[] 緩衝區 (Kotlin 中的 heavyData,Java 中的 mHeavyData),並在 onCreate 中將自己註冊為單例模式 StatsReporter 的事件監聽器。
  2. 系統 刪除時,OomSampleActivity 絕不會呼叫 unregisterListener。由於 StatsReporter 的生命週期比活動長,且會保留每個已註冊事件監聽器的強參照,因此垃圾收集器無法回收已刪除的例項或其 30 MiB byte[] 陣列。
  3. 每次系統刪除並重新建立 OomSampleActivity 時 (例如在 Android 活動生命週期中旋轉螢幕),StatsReporter 就會洩漏另一個 30 MiB 例項。旋轉 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/ 提取保留的設定檔,並在 Perfetto UI 中開啟。

修正後的 Perfetto UI Heapdump Explorer 火焰圖,顯示 OomSampleActivityFixed 的單一執行個體保留 30.01 MiB。
圖 2. 修正後的記憶體快照資料火焰圖,顯示單一 OomSampleActivityFixed 例項。

如圖 2 所示,在多次旋轉螢幕後:

  • com.example.sampleapp.OomSampleActivityFixed 的自我計數為 1,而不是在每次旋轉時累積一個例項。
  • 「Cumulative」保留大小穩定維持在 30.01 MiB (總堆積為 44.27 MiB),確認垃圾收集器會回收先前的活動例項。

如果問題難以在本機重現

如果複雜的多螢幕導覽、長時間工作階段或特定使用者資料導致 OutOfMemoryError,且您無法在測試裝置上可靠地重現,請在推出後驗證正式版中的修正:

  • 在當機回報工具中監控 OutOfMemoryError 當機叢集,做為各版本之間的代理指標,確認涉及 OomSampleActivity 的當機次數減少。
  • 檢查從更新版本收集到的任何新 TRIGGER_TYPE_OOM 記憶體快照資料,確認 StatsReporter 不再保留已刪除的 OomSampleActivity 執行個體。