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.
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.OomSampleActivityretém um tamanho cumulativo de 240,28 MiB (94,31% de todo o heap do app), a maior parte em uma matrizbyte[]filha. - Várias instâncias de atividade em tempo real: selecionar o frame
OomSampleActivitymostra Self Count: 8. Embora apenas uma instância deOomSampleActivitydeva 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
OomSampleActivityno gráfico de chama mostra a cadeia de referência que mantém essas instâncias ativas:com.example.sampleapp.StatsReportercontém umjava.util.concurrent.CopyOnWriteArrayList(com suporte dejava.lang.Object[]), que faz referência direta a todas as 8 instâncias deOomSampleActivity.
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:
- Cada instância de
OomSampleActivityaloca um bufferbyte[]de 30 MiB (heavyDataem Kotlin,mHeavyDataem Java) e se registra como um listener no singletonStatsReporteremonCreate. - O
OomSampleActivitynunca chamaunregisterListenerquando o sistema o destrói. ComoStatsReporterdura 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 matrizesbyte[]de 30 MiB. - 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 emStatsReporter. Após sete rotações (8 instâncias × 30 MiB ≈ 240 MiB), o heap é preenchido e o app falha com umOutOfMemoryError.
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:
- 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/. - Abra o
OomSampleActivityFixedno 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. Acione um heap dump do Java (
profiling_type1) sob demanda usando o comando shellcmd profiling_service request-profiling:adb shell cmd profiling_service request-profiling 1 com.example.sampleappExtraia o perfil retido de
/data/misc/perfetto-traces/profiling/e abra-o na interface do Perfetto.
OomSampleActivityFixed.Conforme mostrado na Figura 2, após várias rotações de tela:
com.example.sampleapp.OomSampleActivityFixedtem 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
OutOfMemoryErrorna ferramenta de relatórios de erros como uma métrica de proxy em todas as versões para confirmar que as falhas que envolvemOomSampleActivitydiminuíram. - Inspecione todos os novos heap dumps de
TRIGGER_TYPE_OOMcoletados da versão atualizada para verificar seStatsReporternão retém mais instânciasOomSampleActivitydestruídas.