Case study: esegui il debug di un arresto anomalo OOM

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.

Grafico a fiamme di Heapdump Explorer nell'interfaccia utente di Perfetto che mostra StatsReporter che conserva 8 istanze di OomSampleActivity per un totale di 240,28 MiB.
Figura 1. Grafico a fiamme del dump dell'heap che mostra 8 istanze conservate di 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.OomSampleActivity conserva una dimensione cumulativa di 240,28 MiB (94,31% dell'intero heap dell'app), la maggior parte della quale si trova in un array byte[] 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 di OomSampleActivity alla volta, in memoria sono presenti contemporaneamente 8 istanze separate.
  • Il percorso dell'oggetto di conservazione: la lettura dei frame principali di OomSampleActivity nel grafico a fiamme mostra la catena di riferimento che mantiene attive queste istanze: com.example.sampleapp.StatsReporter contiene un java.util.concurrent.CopyOnWriteArrayList (supportato da java.lang.Object[]), che fa riferimento direttamente a tutte e 8 le istanze di OomSampleActivity.

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:

  1. Ogni istanza OomSampleActivity alloca un buffer byte[] da 30 MiB (heavyData in Kotlin, mHeavyData in Java) e si registra come listener sul singleton StatsReporter in onCreate.
  2. OomSampleActivity non chiama mai unregisterListener quando il sistema lo distrugge. Poiché StatsReporter sopravvive all'attività e contiene un riferimento forte a ogni listener registrato, il garbage collector non può recuperare le istanze eliminate o i relativi array byte[] da 30 MiB.
  3. 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 in StatsReporter. Dopo 7 rotazioni (8 istanze × 30 MiB ≈ 240 MiB), l'heap si riempie e l'app si arresta in modo anomalo con un errore OutOfMemoryError.

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:

  1. 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/.
  2. Apri OomSampleActivityFixed sul 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.
  3. Attiva un dump dell'heap Java on demand (profiling_type 1) utilizzando il comando shell cmd profiling_service request-profiling:

    adb shell cmd profiling_service request-profiling 1 com.example.sampleapp
    
  4. Estrai il profilo conservato da /data/misc/perfetto-traces/profiling/ e aprilo nell'interfaccia utente di Perfetto.

Grafico a fiamme di Heapdump Explorer della UI di Perfetto dopo la correzione, che mostra una singola istanza di OomSampleActivityFixed che conserva 30,01 MiB.
Figura 2. Grafico a fiamme del dump dell'heap dopo la correzione che mostra una singola istanza di OomSampleActivityFixed.

Come mostrato nella Figura 2, dopo più rotazioni dello schermo:

  • com.example.sampleapp.OomSampleActivityFixed ha 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 OutOfMemoryError nello strumento di segnalazione degli arresti anomali come metrica proxy tra le release per verificare che gli arresti anomali che coinvolgono OomSampleActivity diminuiscano.
  • Esamina i nuovi dump dell'heap TRIGGER_TYPE_OOM raccolti dalla release aggiornata per verificare che StatsReporter non conservi più le istanze OomSampleActivity eliminate.