Gli arresti anomali per out of memory (OutOfMemoryError) si verificano quando l'app esaurisce lo heap Java allocato e non riesce a soddisfare una nuova allocazione della memoria. Le analisi dello stack dei report sugli arresti anomali di OutOfMemoryError raramente rivelano la causa principale perché mostrano solo la chiamata di allocazione che non è riuscita quando la memoria si è esaurita, non gli oggetti che trattengono il resto dell'heap.
ProfilingManager ti consente di acquisire dump dell'heap Java dai dispositivi sul campo e localmente, in modo da poter esaminare il grafico di conservazione degli oggetti nell'interfaccia utente di Perfetto e individuare con precisione cosa sta occupando la memoria.
Scenario
Supponiamo che lo strumento di segnalazione degli arresti anomali segnali un cluster crescente di arresti anomali di OutOfMemoryError in produzione quando gli utenti interagiscono con OomSampleActivity.
Per indagare, registri un trigger TRIGGER_TYPE_OOM con
ProfilingManager in modo che il sistema acquisisca automaticamente un dump dell'heap Java quando si verifica un
OutOfMemoryError.
Acquisisci il dump dell'heap
Su Android 17 (livello API 37) e versioni successive, per acquisire un dump dell'heap quando si verifica un arresto anomalo di OutOfMemoryError, configura la profilazione basata su trigger con ProfilingTrigger.TRIGGER_TYPE_OOM. Quando l'app genera un'eccezione OutOfMemoryError non gestita, il sistema avvia automaticamente una nuova acquisizione PROFILING_TYPE_JAVA_HEAP_DUMP.
Poiché il processo dell'app termina quando si verifica l'arresto anomalo, il sistema completa il salvataggio del dump dell'heap in background e mette in coda il risultato (consulta Ciclo di vita dell'attivatore). Registra un listener di risultati globale su un Executor in background dopo il completamento dell'avvio dell'app (in modo da non rallentare l'avvio dell'app) utilizzando registerForAllProfilingResults in modo che la tua app riceva l'ProfilingResult in coda al successivo avvio da parte dell'utente. A differenza di
addProfilingTriggers (che persiste tra i riavvii dell'app e deve essere
chiamato una sola volta o quando vengono aggiornati gli attivatori), registerForAllProfilingResults deve essere
chiamato a ogni avvio dell'app:
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)); }
Deoffusca il profilo
Dopo che un OutOfMemoryError attiva un'acquisizione e il tuo callback riceve
ProfilingResult al successivo avvio dell'app, carica il file del profilo sul tuo
server o esegui il pull sulla tua macchina di sviluppo con adb. Se hai acquisito il profilo da una build di release che utilizza la riduzione del codice e l'offuscamento di R8, deoffuscalo offline sulla workstation o sul server prima dell'analisi. Senza deoffuscamento, R8 offusca i nomi di classi, campi e metodi Java e Kotlin nel dump dell'heap non elaborato (ad esempio, trasformando i nomi delle classi originali in a.b).
Utilizza il comando trace_processor bundle di Perfetto con il flag --proguard-map per pacchettizzare il profilo acquisito insieme al file R8 mapping.txt generato per quella build di release esatta:
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
Questo comando genera deobfuscated_oom.tar, un archivio arricchito che raggruppa il profilo originale con le relative mappature di deoffuscamento. Quando apri questo archivio nella UI di Perfetto o lo interroghi con trace_processor, Perfetto ripristina automaticamente tutti i nomi originali di classi, campi e metodi. Per saperne di più, consulta la documentazione di Perfetto relativa alla simbolizzazione e alla deoffuscamento.
Analizza il dump dell'heap
Puoi ispezionare manualmente il dump dell'heap deoffuscato nella UI di Perfetto come mostrato nei passaggi seguenti o automatizzare l'analisi OOM con un agente AI.
Apri il dump dell'heap deoffuscato nella UI di Perfetto e seleziona Heapdump Explorer nella barra di navigazione a sinistra. Nella scheda Grafico a fiamme con Misura impostata su Dimensioni oggetto, la gerarchia di conservazione nella Figura 1 risalta immediatamente.
OomSampleActivity.L'ispezione del grafico a fiamme nella Figura 1 rivela diversi risultati chiave:
- Conservazione massiccia della memoria: su un heap totale di 254,79 MiB (
root),com.example.sampleapp.OomSampleActivityconserva una dimensione cumulativa di 240,28 MiB (94,31% dell'intero heap dell'app), la maggior parte della quale si trova in un arraybyte[]secondario. - Più istanze di attività dal vivo: se selezioni il frame
OomSampleActivity, viene visualizzato Self Count: 8. Anche se sullo schermo deve esistere una sola istanza diOomSampleActivityalla volta, in memoria sono presenti contemporaneamente 8 istanze separate. - Il percorso dell'oggetto di conservazione: la lettura dei frame principali di
OomSampleActivitynel grafico a fiamme mostra la catena di riferimento che mantiene attive queste istanze:com.example.sampleapp.StatsReportercontiene unjava.util.concurrent.CopyOnWriteArrayList(supportato dajava.lang.Object[]), che fa riferimento direttamente a tutte e 8 le istanze diOomSampleActivity.
Identifica la causa principale nel codice
Con il percorso di conservazione che punta da StatsReporter a OomSampleActivity,
esamina entrambe le classi nel codebase.
StatsReporter è un singleton che gestisce un elenco di callback StatsListener e pubblica periodicamente aggiornamenti da un thread di polling in background:
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(); } }
Poi, ispeziona 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); } }
Il codice conferma il motivo per cui StatsReporter conserva 8 istanze di
OomSampleActivity:
- Ogni istanza
OomSampleActivityalloca un bufferbyte[]da 30 MiB (heavyDatain Kotlin,mHeavyDatain Java) e si registra come listener sul singletonStatsReporterinonCreate. OomSampleActivitynon chiama maiunregisterListenerquando il sistema lo distrugge. PoichéStatsReportersopravvive all'attività e contiene un riferimento forte a ogni listener registrato, il garbage collector non può recuperare le istanze eliminate o i relativi arraybyte[]da 30 MiB.- Ogni volta che il sistema elimina e ricrea
OomSampleActivity, ad esempio durante una rotazione dello schermo nel ciclo di vita dell'attività Android, si verifica un leak di un'altra istanza da 30 MiB inStatsReporter. Dopo 7 rotazioni (8 istanze × 30 MiB ≈ 240 MiB), l'heap si riempie e l'app si arresta in modo anomalo con un erroreOutOfMemoryError.
Riparare la perdita
Per correggere la perdita di memoria, esegui l'override di onDestroy nell'attività e chiama
StatsReporter.getInstance().unregisterListener(this) in modo che il singleton rilasci
il suo riferimento quando il sistema elimina l'attività:
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); } }
Automatizza l'indagine OOM con un agente AI
Anziché ispezionare manualmente il grafico a fiamme e tracciare i riferimenti tra i file, puoi utilizzare un agente di programmazione AI configurato con la competenza Perfetto AI per analizzare il dump dell'heap e correlarlo al tuo codebase:
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.
L'agente esegue una query sul dump dell'heap con trace_processor, traccia il percorso del dominatore da StatsReporter alle istanze OomSampleActivity con leak e ispeziona entrambe le classi nel codebase. Senza intervento manuale, giunge alla stessa conclusione, ovvero che OomSampleActivity registra un listener in onCreate senza annullare la registrazione e genera una patch che aggiunge unregisterListener(this) in onDestroy (il seguente report è stato generato da un'acquisizione separata che aveva 7 istanze conservate al momento dell'arresto anomalo):
# 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.Verifica la correzione
Verifica che la fuga sia stata risolta localmente su un dispositivo di test o in produzione.
Se il problema è riproducibile localmente
Poiché TRIGGER_TYPE_OOM acquisisce un dump dell'heap solo quando si verifica un OutOfMemoryError, una correzione riuscita impedisce l'attivazione del trigger. Per ispezionare l'heap e verificare che la fuga sia stata risolta, ruota lo schermo e acquisisci un dump dell'heap Java on demand dalla riga di comando:
- Disattiva il limitatore di frequenza (
rate_limiter.disabled true) e conserva i risultati temporanei (delete_temporary_results.disabled true) in modo che il sistema mantenga l'acquisizione della riga di comando in/data/misc/perfetto-traces/profiling/. - Apri
OomSampleActivityFixedsul dispositivo di test, ruota lo schermo almeno 8 volte per verificare che l'app non si arresti più in modo anomalo e tienila in primo piano. Attiva un dump dell'heap Java on demand (
profiling_type1) utilizzando il comando shellcmd profiling_service request-profiling:adb shell cmd profiling_service request-profiling 1 com.example.sampleappEstrai il profilo conservato da
/data/misc/perfetto-traces/profiling/e aprilo nell'interfaccia utente di Perfetto.
OomSampleActivityFixed.Come mostrato nella Figura 2, dopo più rotazioni dello schermo:
com.example.sampleapp.OomSampleActivityFixedha un conteggio autonomo pari a 1 anziché accumulare un'istanza a ogni rotazione.- La dimensione mantenuta cumulativa rimane stabile a 30,01 MiB (su un heap totale di 44,27 MiB), a conferma del fatto che il garbage collector recupera le istanze di attività precedenti.
Se il problema è difficile da riprodurre localmente
Quando una navigazione complessa su più schermate, sessioni di lunga durata o dati utente specifici causano un OutOfMemoryError che non puoi riprodurre in modo affidabile su un dispositivo di test, convalida la correzione in produzione dopo l'implementazione:
- Monitora il cluster di arresti anomali
OutOfMemoryErrornello strumento di segnalazione degli arresti anomali come metrica proxy tra le release per verificare che gli arresti anomali che coinvolgonoOomSampleActivitydiminuiscano. - Esamina i nuovi dump dell'heap
TRIGGER_TYPE_OOMraccolti dalla release aggiornata per verificare cheStatsReporternon conservi più le istanzeOomSampleActivityeliminate.