Caso de éxito: Depura una falla por OOM

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.

Gráfico tipo llama del explorador de volcados de montón de la IU de Perfetto que muestra que StatsReporter retiene 8 instancias de OomSampleActivity que suman 240.28 MiB.
Figura 1: Gráfico tipo llama del volcado de montón que muestra 8 instancias retenidas de 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.OomSampleActivity retiene 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 array byte[] secundario.
  • Varias instancias de actividad en vivo: Si seleccionas el fotograma OomSampleActivity, se muestra Self Count: 8. Aunque solo debería existir una instancia de OomSampleActivity en 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 OomSampleActivity en el gráfico tipo llama muestra la cadena de referencia que mantiene activas esas instancias: com.example.sampleapp.StatsReporter contiene un java.util.concurrent.CopyOnWriteArrayList (respaldado por java.lang.Object[]), que hace referencia directamente a las 8 instancias de OomSampleActivity.

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:

  1. Cada instancia de OomSampleActivity asigna un búfer de byte[] de 30 MiB (heavyData en Kotlin, mHeavyData en Java) y se registra como un objeto de escucha en el singleton StatsReporter en onCreate.
  2. OomSampleActivity nunca llama a unregisterListener cuando el sistema lo destruye. Como StatsReporter sobrevive 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 de byte[].
  3. 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 en StatsReporter. Después de 7 rotaciones (8 instancias × 30 MiB ≈ 240 MiB), el montón se llena y la app falla con un OutOfMemoryError.

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:

  1. 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/.
  2. Abre OomSampleActivityFixed en 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.
  3. Activa un volcado de montón de Java (profiling_type 1) a pedido con el comando de shell cmd profiling_service request-profiling:

    adb shell cmd profiling_service request-profiling 1 com.example.sampleapp
    
  4. Extrae el perfil retenido de /data/misc/perfetto-traces/profiling/ y ábrelo en la IU de Perfetto.

Gráfico tipo llama del explorador de volcados de montón de la IU de Perfetto después de la corrección, en el que se muestra una sola instancia de OomSampleActivityFixed que retiene 30.01 MiB.
Figura 2: Gráfico tipo llama del volcado de montón después de la corrección, que muestra una sola instancia de OomSampleActivityFixed.

Como se muestra en la Figura 2, después de varias rotaciones de pantalla, ocurre lo siguiente:

  • com.example.sampleapp.OomSampleActivityFixed tiene 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 OutOfMemoryError en tu herramienta de informes de fallas como una métrica de proxy en todas las versiones para confirmar que disminuyan las fallas que involucran a OomSampleActivity.
  • Inspecciona los volcados de montón de TRIGGER_TYPE_OOM nuevos que se recopilaron de la versión actualizada para verificar que StatsReporter ya no retenga instancias de OomSampleActivity destruidas.