Las fallas por memoria insuficiente (OutOfMemoryError) se producen cuando tu app agota el montón de Java asignado y no puede completar una nueva asignación de memoria. Los seguimientos de pila de los informes de fallas de OutOfMemoryError rara vez revelan la causa raíz, ya que solo muestran la llamada de asignación que falló cuando se agotó la memoria, no los objetos que retienen el resto del montón.
ProfilingManager te permite capturar volcados de montón de Java de dispositivos en el campo y de forma local para que puedas inspeccionar el gráfico de retención de objetos en la IU de Perfetto y determinar qué está reteniendo la memoria.
Situación
Supongamos que tu herramienta de informes de fallas marca un clúster creciente de fallas de OutOfMemoryError
en producción cuando los usuarios interactúan con OomSampleActivity.
Para investigar, registra un activador TRIGGER_TYPE_OOM con ProfilingManager para que el sistema capture automáticamente un volcado de montón de Java cuando se produzca un OutOfMemoryError.
Captura el volcado de montón
En Android 17 (nivel de API 37) y versiones posteriores, para capturar un volcado de montón cuando se produce una falla de OutOfMemoryError, configura la generación de perfiles basada en activadores con ProfilingTrigger.TRIGGER_TYPE_OOM. Cuando tu app arroja un OutOfMemoryError no controlado, el sistema inicia automáticamente una nueva captura de PROFILING_TYPE_JAVA_HEAP_DUMP.
Debido a que el proceso de la app finaliza cuando se produce la falla, el sistema termina de guardar el volcado de montón en segundo plano y pone en cola el resultado (consulta Ciclo de vida del activador). Registra un objeto de escucha de resultados global en un Executor en segundo plano después de que se complete el inicio de la app (para no ralentizar el inicio de la app) con registerForAllProfilingResults para que tu app reciba el ProfilingResult en cola la próxima vez que el usuario la inicie. A diferencia de addProfilingTriggers (que persiste en los reinicios de la app y solo se debe llamar una vez o cuando se actualizan los activadores), se debe llamar a registerForAllProfilingResults cada vez que se inicia la 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)); }
Desofusca el perfil
Después de que un OutOfMemoryError active una captura y tu devolución de llamada reciba el ProfilingResult en el próximo inicio de la app, sube el archivo de perfil a tu servidor o extráelo a tu máquina de desarrollo con adb. Si capturaste
el perfil de una compilación de lanzamiento que usa la reducción y ofuscación de código de R8, desofúscala sin conexión en tu estación de trabajo o servidor antes
del análisis. Sin desofuscación, R8 ofusca los nombres de las clases, los campos y los métodos de Java y Kotlin en el volcado de montón sin procesar (por ejemplo, convirtiendo los nombres de las clases originales en a.b).
Usa el comando trace_processor bundle de Perfetto con la marca --proguard-map para empaquetar el perfil capturado junto con el archivo mapping.txt de R8 generado para esa compilación de lanzamiento exacta:
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
Este comando produce deobfuscated_oom.tar, un archivo enriquecido que agrupa
el perfil original con sus asignaciones de desofuscación. Cuando abres este archivo en la IU de Perfetto o lo consultas con trace_processor, Perfetto restablece automáticamente todos los nombres originales de clases, campos y métodos. Para obtener más información, consulta
la documentación de Perfetto sobre simbolización y desofuscación.
Analiza el volcado de montón
Puedes inspeccionar el volcado de montón desofuscado de forma manual en la IU de Perfetto, como se muestra en los siguientes pasos, o automatizar la investigación de OOM con un agente de IA.
Abre el volcado de montón desofuscado en la IU de Perfetto y selecciona Heapdump Explorer en la barra de navegación izquierda. En la pestaña Flamegraph con Measure configurado como Object Size, la jerarquía de retención de la Figura 1 se destaca de inmediato.
OomSampleActivity.La inspección del gráfico tipo llama de la figura 1 revela varios hallazgos clave:
- Retención masiva de memoria: De un montón total de 254.79 MiB (
root),com.example.sampleapp.OomSampleActivityretiene un tamaño acumulativo de 240.28 MiB (94.31% de todo el montón de la app), la mayor parte del cual se encuentra en un arraybyte[]secundario. - Varias instancias de actividad en vivo: Si seleccionas el fotograma
OomSampleActivity, se muestra Self Count: 8. Aunque solo debería existir una instancia deOomSampleActivityen la pantalla en un momento determinado, hay 8 instancias independientes activas en la memoria de forma simultánea. - La ruta del objeto de retención: Leer los marcos superiores de
OomSampleActivityen el gráfico tipo llama muestra la cadena de referencia que mantiene activas esas instancias:com.example.sampleapp.StatsReportercontiene unjava.util.concurrent.CopyOnWriteArrayList(respaldado porjava.lang.Object[]), que hace referencia directamente a las 8 instancias deOomSampleActivity.
Identifica la causa raíz en el código
Con la ruta de retención que apunta de StatsReporter a OomSampleActivity,
inspecciona ambas clases en tu base de código.
StatsReporter es un singleton que mantiene una lista de devoluciones de llamada de StatsListener y publica actualizaciones de forma periódica desde un subproceso de sondeo en 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(); } }
A continuación, inspecciona 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); } }
El código confirma por qué StatsReporter conserva 8 instancias de
OomSampleActivity:
- Cada instancia de
OomSampleActivityasigna un búfer debyte[]de 30 MiB (heavyDataen Kotlin,mHeavyDataen Java) y se registra como un objeto de escucha en el singletonStatsReporterenonCreate. OomSampleActivitynunca llama aunregisterListenercuando el sistema lo destruye. ComoStatsReportersobrevive a la actividad y mantiene una referencia fuerte a cada objeto de escucha registrado, el recolector de elementos no utilizados no puede recuperar las instancias destruidas ni sus arrays de 30 MiB debyte[].- Cada vez que el sistema destruye y vuelve a crear
OomSampleActivity, como durante una rotación de pantalla en el ciclo de vida de la actividad de Android, se produce una fuga de otra instancia de 30 MiB enStatsReporter. Después de 7 rotaciones (8 instancias × 30 MiB ≈ 240 MiB), el montón se llena y la app falla con unOutOfMemoryError.
Corrige la fuga
Para corregir la fuga de memoria, anula onDestroy en la actividad y llama a StatsReporter.getInstance().unregisterListener(this) para que el singleton libere su referencia cuando el sistema destruya la actividad:
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); } }
Automatiza la investigación de OOM con un agente de IA
En lugar de inspeccionar manualmente el gráfico tipo llama y rastrear las referencias en los archivos, puedes usar un agente de programación de IA configurado con la habilidad de IA de Perfetto para analizar el volcado de montón y correlacionarlo con tu 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.
El agente consulta el volcado de montón con trace_processor, rastrea la ruta de acceso del dominador
desde StatsReporter hasta las instancias de OomSampleActivity filtradas y,
luego, inspecciona ambas clases en tu base de código. Sin intervención manual, llega a la misma conclusión: que OomSampleActivity registra un objeto de escucha en onCreate sin cancelar el registro y genera un parche que agrega unregisterListener(this) en onDestroy (el siguiente informe se generó a partir de una captura independiente que tenía 7 instancias retenidas en el momento de la falla):
# 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 corrección
Verifica que la fuga se haya resuelto de forma local en un dispositivo de prueba o en producción.
Si el problema se puede reproducir de forma local
Debido a que TRIGGER_TYPE_OOM solo captura un volcado de montón cuando se produce un OutOfMemoryError, una corrección exitosa evita que se active el activador. Para inspeccionar el montón y confirmar que ya no hay pérdidas, rota la pantalla y, luego, captura un volcado de montón de Java a pedido desde la línea de comandos:
- Inhabilita el limitador de frecuencia (
rate_limiter.disabled true) y conserva los resultados temporales (delete_temporary_results.disabled true) para que el sistema mantenga la captura de la línea de comandos en/data/misc/perfetto-traces/profiling/. - Abre
OomSampleActivityFixeden tu dispositivo de prueba, rota la pantalla 8 veces o más para confirmar que la app ya no falla y mantenla en primer plano. Activa un volcado de montón de Java (
profiling_type1) a pedido con el comando de shellcmd profiling_service request-profiling:adb shell cmd profiling_service request-profiling 1 com.example.sampleappExtrae el perfil retenido de
/data/misc/perfetto-traces/profiling/y ábrelo en la IU de Perfetto.
OomSampleActivityFixed.Como se muestra en la Figura 2, después de varias rotaciones de pantalla, ocurre lo siguiente:
com.example.sampleapp.OomSampleActivityFixedtiene un recuento propio de 1 en lugar de acumular una instancia en cada rotación.- Su tamaño retenido acumulativo se mantiene estable en 30.01 MiB (de un montón total de 44.27 MiB), lo que confirma que el recolector de basura recupera instancias de actividad anteriores.
Si el problema es difícil de reproducir de forma local
Cuando la navegación compleja multipantalla, las sesiones de larga duración o los datos específicos del usuario
causan un OutOfMemoryError que no puedes reproducir de forma confiable en un dispositivo de prueba,
valida la corrección en producción después del lanzamiento:
- Supervisa el clúster de fallas de
OutOfMemoryErroren tu herramienta de informes de fallas como una métrica de proxy en todas las versiones para confirmar que disminuyan las fallas que involucran aOomSampleActivity. - Inspecciona los volcados de montón de
TRIGGER_TYPE_OOMnuevos que se recopilaron de la versión actualizada para verificar queStatsReporterya no retenga instancias deOomSampleActivitydestruidas.