當應用程式用盡分配的 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 中的保留階層會立即醒目顯示。

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 執行個體的原因:
- 每個
OomSampleActivity執行個體都會分配 30 MiB 的byte[]緩衝區 (Kotlin 中的heavyData,Java 中的mHeavyData),並在onCreate中將自己註冊為單例模式StatsReporter的事件監聽器。 - 系統
刪除時,
OomSampleActivity絕不會呼叫unregisterListener。由於StatsReporter的生命週期比活動長,且會保留每個已註冊事件監聽器的強參照,因此垃圾收集器無法回收已刪除的例項或其 30 MiBbyte[]陣列。 - 每次系統刪除並重新建立
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 記憶體快照資料:
- 停用頻率限制 (
rate_limiter.disabled true) 並保留暫時結果 (delete_temporary_results.disabled true),讓系統將指令列擷取內容保留在/data/misc/perfetto-traces/profiling/中。 - 在測試裝置上開啟
OomSampleActivityFixed,旋轉螢幕 8 次以上,確認應用程式不會再當機,並保持在前景。 使用
cmd profiling_service request-profiling殼層指令,觸發隨選 Java 記憶體快照資料 (profiling_type1):adb shell cmd profiling_service request-profiling 1 com.example.sampleapp從
/data/misc/perfetto-traces/profiling/提取保留的設定檔,並在 Perfetto UI 中開啟。
OomSampleActivityFixed 例項。如圖 2 所示,在多次旋轉螢幕後:
com.example.sampleapp.OomSampleActivityFixed的自我計數為 1,而不是在每次旋轉時累積一個例項。- 「Cumulative」保留大小穩定維持在 30.01 MiB (總堆積為 44.27 MiB),確認垃圾收集器會回收先前的活動例項。
如果問題難以在本機重現
如果複雜的多螢幕導覽、長時間工作階段或特定使用者資料導致 OutOfMemoryError,且您無法在測試裝置上可靠地重現,請在推出後驗證正式版中的修正:
- 在當機回報工具中監控
OutOfMemoryError當機叢集,做為各版本之間的代理指標,確認涉及OomSampleActivity的當機次數減少。 - 檢查從更新版本收集到的任何新
TRIGGER_TYPE_OOM記憶體快照資料,確認StatsReporter不再保留已刪除的OomSampleActivity執行個體。