Estudo de caso: depurar uma falha de OOM

As falhas de falta de memória (OutOfMemoryError) ocorrem quando o app esgota o heap Java alocado e não consegue realizar uma nova alocação de memória. Os stack traces de relatórios de falhas de OutOfMemoryError raramente revelam a causa raiz porque mostram apenas a chamada de alocação que falhou quando a memória acabou, não os objetos que retêm o restante do heap.

O ProfilingManager permite capturar heap dumps Java de dispositivos em campo e localmente para que você possa inspecionar o gráfico de retenção de objetos na interface do Perfetto e identificar o que está retendo a memória.

Cenário

Suponha que sua ferramenta de relatórios de falhas sinalize um cluster crescente de falhas de OutOfMemoryError na produção quando os usuários interagem com OomSampleActivity.

Para investigar, você registra um gatilho TRIGGER_TYPE_OOM com ProfilingManager para que o sistema capture automaticamente um heap dump do Java quando ocorrer um OutOfMemoryError.

Gerar o heap dump

No Android 17 (nível 37 da API) e versões mais recentes, para capturar um heap dump quando ocorre uma falha de OutOfMemoryError, configure a criação de perfil baseada em gatilhos com ProfilingTrigger.TRIGGER_TYPE_OOM. Quando o app gera uma OutOfMemoryError não processada, o sistema inicia automaticamente uma nova captura de PROFILING_TYPE_JAVA_HEAP_DUMP.

Como o processo do app é encerrado quando ocorre a falha, o sistema conclui o salvamento do heap dump em segundo plano e coloca o resultado na fila (consulte Ciclo de vida do acionador). Registre um listener de resultado global em um Executor em segundo plano depois que a inicialização do app for concluída (para não atrasar o início do app) usando registerForAllProfilingResults para que o app receba o ProfilingResult na fila na próxima vez que o usuário o iniciar. Ao contrário de addProfilingTriggers, que persiste entre as reinicializações do app e só precisa ser chamado uma vez ou ao atualizar acionadores, registerForAllProfilingResults precisa ser chamado sempre que o app é iniciado:

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));
}

Desofuscar o perfil

Depois que um OutOfMemoryError acionar uma captura e o callback receber o ProfilingResult na próxima inicialização do app, faça upload do arquivo de perfil para o servidor ou extraia-o para a máquina de desenvolvimento com adb. Se você capturou o perfil de um build de lançamento que usa a redução e ofuscação de código do R8, desofusque-o off-line na estação de trabalho ou no servidor antes da análise. Sem a desofuscação, o R8 ofusca os nomes de classes, campos e métodos Java e Kotlin no heap dump bruto (por exemplo, transformando nomes de classes originais em a.b).

Use o comando trace_processor bundle do Perfetto com a flag --proguard-map para empacotar o perfil capturado com o arquivo R8 mapping.txt gerado para esse build de lançamento exato:

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

Esse comando produz deobfuscated_oom.tar, um arquivo enriquecido que agrupa o perfil original com os mapeamentos de desofuscação. Quando você abre esse arquivo na interface do Perfetto ou o consulta com trace_processor, o Perfetto restaura automaticamente todos os nomes originais de classes, campos e métodos. Para mais informações, consulte a documentação do Perfetto sobre simbolização e desofuscação (link em inglês).

Analisar o heap dump

É possível inspecionar o heap dump desofuscado manualmente na interface do Perfetto, conforme mostrado nas etapas a seguir, ou automatizar a investigação de OOM com um agente de IA.

Abra o heap dump desofuscado na interface do Perfetto e selecione Heapdump Explorer na barra de navegação à esquerda. Na guia Flamegraph com Measure definida como Object Size, a hierarquia de retenção na Figura 1 se destaca imediatamente.

Gráfico de chama do Heapdump Explorer da interface do Perfetto mostrando o StatsReporter retendo oito instâncias de OomSampleActivity, totalizando 240,28 MiB.
Figura 1. Gráfico de chama do heap dump mostrando oito instâncias retidas de OomSampleActivity.

A inspeção do gráfico de chama na Figura 1 revela várias descobertas importantes:

  • Retenção de memória massiva: de um heap total de 254,79 MiB (root), com.example.sampleapp.OomSampleActivity retém um tamanho cumulativo de 240,28 MiB (94,31% de todo o heap do app), a maior parte em uma matriz byte[] filha.
  • Várias instâncias de atividade em tempo real: selecionar o frame OomSampleActivity mostra Self Count: 8. Embora apenas uma instância de OomSampleActivity deva existir na tela em um determinado momento, oito instâncias separadas estão ativas na memória simultaneamente.
  • O caminho do objeto de retenção: a leitura dos frames pai de OomSampleActivity no gráfico de chama mostra a cadeia de referência que mantém essas instâncias ativas: com.example.sampleapp.StatsReporter contém um java.util.concurrent.CopyOnWriteArrayList (com suporte de java.lang.Object[]), que faz referência direta a todas as 8 instâncias de OomSampleActivity.

Identificar a causa raiz no código

Com o caminho de retenção apontando de StatsReporter para OomSampleActivity, inspecione as duas classes na base de código.

StatsReporter é um singleton que mantém uma lista de callbacks StatsListener e publica periodicamente atualizações de uma linha de execução de sondagem em segundo plano:

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();
    }
}

Em seguida, inspecione 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);
    }
}

O código confirma por que StatsReporter retém oito instâncias de OomSampleActivity:

  1. Cada instância de OomSampleActivity aloca um buffer byte[] de 30 MiB (heavyData em Kotlin, mHeavyData em Java) e se registra como um listener no singleton StatsReporter em onCreate.
  2. O OomSampleActivity nunca chama unregisterListener quando o sistema o destrói. Como StatsReporter dura mais que a atividade e mantém uma referência forte a cada listener registrado, o coletor de lixo não pode recuperar instâncias destruídas ou as matrizes byte[] de 30 MiB.
  3. Sempre que o sistema destrói e recria OomSampleActivity, como durante uma rotação de tela no ciclo de vida da atividade do Android, outra instância de 30 MiB vaza em StatsReporter. Após sete rotações (8 instâncias × 30 MiB ≈ 240 MiB), o heap é preenchido e o app falha com um OutOfMemoryError.

Corrigir o vazamento

Para corrigir o vazamento de memória, substitua onDestroy na atividade e chame StatsReporter.getInstance().unregisterListener(this) para que o Singleton libere a referência quando o sistema destruir a atividade:

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);
    }
}

Automatizar a investigação de OOM com um agente de IA

Em vez de inspecionar manualmente o gráfico de chama e rastrear referências em arquivos, use um agente de programação de IA configurado com a habilidade de IA do Perfetto para analisar o heap dump e correlacioná-lo com sua base de código:

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.

O agente consulta o heap dump com trace_processor, rastreia o caminho do dominador de StatsReporter até as instâncias de OomSampleActivity vazadas e inspeciona as duas classes na base de código. Sem intervenção manual, ele chega à mesma conclusão: que OomSampleActivity registra um listener em onCreate sem cancelar o registro e gera um patch adicionando unregisterListener(this) em onDestroy. O relatório a seguir foi gerado com base em uma captura separada que tinha sete instâncias retidas no momento da falha:

# 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.

Verificar a correção

Verifique se o vazamento foi resolvido localmente em um dispositivo de teste ou em produção.

Se o problema for reproduzível localmente

Como o TRIGGER_TYPE_OOM só captura um heap dump quando ocorre um OutOfMemoryError, uma correção bem-sucedida impede que o gatilho seja acionado. Para inspecionar o heap e confirmar que o vazamento foi resolvido, gire a tela e capture um heap dump Java on demand na linha de comando:

  1. Desative o limitador de taxa (rate_limiter.disabled true) e retenha os resultados temporários (delete_temporary_results.disabled true) para que o sistema mantenha a captura da linha de comando em /data/misc/perfetto-traces/profiling/.
  2. Abra o OomSampleActivityFixed no dispositivo de teste, gire a tela oito ou mais vezes para confirmar que o app não falha mais e mantenha-o em primeiro plano.
  3. Acione um heap dump do Java (profiling_type 1) sob demanda usando o comando shell cmd profiling_service request-profiling:

    adb shell cmd profiling_service request-profiling 1 com.example.sampleapp
    
  4. Extraia o perfil retido de /data/misc/perfetto-traces/profiling/ e abra-o na interface do Perfetto.

Gráfico de chama do Heapdump Explorer da interface do Perfetto após a correção, mostrando uma única instância de OomSampleActivityFixed retendo 30,01 MiB.
Figura 2. Gráfico de chama do heap dump após a correção mostrando uma única instância de OomSampleActivityFixed.

Conforme mostrado na Figura 2, após várias rotações de tela:

  • com.example.sampleapp.OomSampleActivityFixed tem uma contagem própria de 1, em vez de acumular uma instância em cada rotação.
  • O tamanho retido cumulativo permanece estável em 30,01 MiB (de um heap total de 44,27 MiB), confirmando que o coletor de lixo recupera instâncias de atividades anteriores.

Se o problema for difícil de reproduzir localmente

Quando uma navegação complexa em multitelas, sessões de longa duração ou dados específicos do usuário causam um OutOfMemoryError que não pode ser reproduzido de forma confiável em um dispositivo de teste, valide a correção na produção após o lançamento:

  • Monitore o cluster de falhas OutOfMemoryError na ferramenta de relatórios de erros como uma métrica de proxy em todas as versões para confirmar que as falhas que envolvem OomSampleActivity diminuíram.
  • Inspecione todos os novos heap dumps de TRIGGER_TYPE_OOM coletados da versão atualizada para verificar se StatsReporter não retém mais instâncias OomSampleActivity destruídas.