Análisis de la memoria de Java

Las aplicaciones de Java y Kotlin administran la memoria a través de un montón recopilado por el recolector de elementos no utilizados. Cuando los objetos ya no son accesibles, el recolector de elementos no utilizados (GC) recupera su espacio. Las fugas de memoria se producen cuando los objetos que ya no son necesarios siguen siendo retenidos por "raíces de GC", lo que impide que se recuperen.

Conceptos básicos

Raíces de GC

Una raíz de GC es un tipo especial de objeto que el recolector de basura trata como siempre accesible. Los siguientes son algunos ejemplos:

  • Hilos activos (y objetos a los que se hace referencia desde sus marcos de pila de Java en ejecución actualmente)
  • Clases con métodos que se ejecutan de forma activa.
  • Referencias de JNI (referencias globales o locales que mantiene el código nativo)

Ruta de acceso a la raíz de GC

Mientras haya una cadena de referencias desde una raíz de GC hasta un objeto, ese objeto será "accesible" y no se podrá recolectar basura. Esta cadena se denomina ruta al objeto raíz de GC. Para corregir una fuga de memoria, debes identificar y romper esta cadena.

Ruta de acceso a la raíz de GC

Árboles de dominadores

Si bien la ruta al objeto raíz de GC te indica por qué un objeto está activo, no te indica cuánta memoria se recuperaría si se rompiera esa referencia. Para ello, usamos árboles de dominadores.

Se dice que el objeto A domina al objeto B si cada ruta desde cualquier raíz del GC a B debe pasar por A. Si A domina a B, recuperar A también garantizará que se pueda recuperar B, ya que no hay otras rutas desde ninguna raíz hasta B.

En el siguiente diagrama, se muestran un grafo de objetos y su árbol de dominadores correspondiente. Observa cómo se llega al objeto D desde A y B en el gráfico, por lo que ni A ni B dominan a D. En cambio, la raíz de GC es su dominador más cercano.

Árbol de dominadores

Cómo obtener volcados de montón de Java

Un volcado de montón es una instantánea de todos los objetos del montón de Java en un momento específico.

Uso de ADB

Para capturar un volcado de montón de un proceso en ejecución, puedes pasar el nombre del paquete directamente a am dumpheap. Para ejecutar este comando, debes compilar tu app con <profileable android:shell="true"/> o <debuggable>.

# 1. Trigger the dump (the command takes a moment to complete):
adb shell am dumpheap -g -b png com.android.memorylab /data/local/tmp/heap.hprof

# 2. Pull the file to your development machine:
adb pull /data/local/tmp/heap.hprof .

Cómo usar Perfetto

Perfetto también puede capturar volcados de montón de Java como parte de un registro de todo el sistema si habilitas la fuente de datos android.java_hprof en tu configuración de Perfetto. Esto es útil para correlacionar el estado del montón con otros eventos del sistema.

Para capturar un volcado de montón de la app de MemoryLab con Perfetto, puedes usar el siguiente comando:

# Create a temp file for the configuration
cat > /tmp/java_heap.pbtx <<EOF
data_sources: {
    config {
        name: "android.java_hprof"
        java_hprof_config {
            process_cmdline: "com.android.memorylab"
        }
    }
}
EOF

# Run trace command referencing the file
external/perfetto/tools/record_android_trace -o java_heap.perfetto-trace \
  -t 10s -c /tmp/java_heap.pbtx

Consulta Volcados de montón de Java en la documentación de Perfetto.

Análisis con AHAT

AHAT (Android Heap Analysis Tool) es la herramienta recomendada para ver archivos .hprof en un navegador web.

Cómo iniciar AHAT

Si tienes ahat instalado en tu ruta de acceso, ejecútalo con el siguiente comando:

ahat heap.hprof

O bien, ejecuta el archivo .jar independiente:

java -jar ahat.jar heap.hprof

Luego, abre tu navegador en http://localhost:7100.

Para obtener detalles sobre cómo obtener o compilar AHAT, consulta el repositorio de código fuente de AHAT.

Flujos de trabajo de análisis clave

Cómo encontrar fugas

Busca tu clase de actividad (MainActivity) en la vista Allocations.

Vista de AHAT que muestra instancias

Haz clic en la clase para encontrar todas las instancias.

Vista de AHAT que muestra instancias de MainActivity Haz clic en la instancia MainActivity para inspeccionarla.

AHAT que muestra los detalles de una instancia

En la vista de instancia, puedes encontrar la ruta de muestra desde la raíz de GC, que muestra la cadena de referencias que impide que el objeto se recopile como basura, y el tamaño del objeto, que muestra cuánta memoria retiene esta instancia específica.

Ruta de muestra de AHAT desde la raíz de GC y el tamaño del objeto

Cómo analizar mapas de bits

AHAT tiene compatibilidad especial para ver objetos android.graphics.Bitmap, que a menudo consumen mucha memoria. Haz clic en una instancia de Bitmap para ver una vista previa renderizada de su contenido.

Vista previa de mapa de bits de AHAT

Página de filtraciones de actividad

AHAT tiene una vista especializada para identificar Activities con fugas, que son una de las fugas de memoria más comunes y con mayor impacto en Android.

  1. Acción: En MemoryLab, presiona Leak an Activity. Esto inicia LeakedActivity, que se filtra de forma intencional.
  2. Dump: Toma un volcado de montón.
  3. Analizar: Haz clic en Fugas de actividad en la barra lateral de AHAT.
  4. Verificar: AHAT mostrará com.android.memorylab.LeakedActivity como filtrado porque su campo mDestroyed es verdadero (lo que indica que finalizó el ciclo de vida de la actividad), pero aún se puede acceder a él desde una raíz de GC.

Página de filtraciones de actividad de AHAT

Cómo comparar volcados de montón

Comparar dos volcados de montón es una de las formas más eficaces de identificar problemas de memoria. Si comparas un volcado de referencia "limpio" con un volcado que se tomó después de realizar algunas acciones, podrás ver de inmediato qué objetos se acumularon.

Ejercicio: Identifica fugas a través de la comparación de diferencias

  1. Modelo de referencia: Inicia MemoryLab y toma un volcado de montón de referencia:

    adb shell am dumpheap com.android.memorylab /data/local/tmp/base.hprof
    adb pull /data/local/tmp/base.hprof .
    
  2. Acción: Presiona Allocate Java Memory(10 MB) varias veces en la app.

  3. Final: Toma un segundo volcado de montón:

    adb shell am dumpheap com.android.memorylab /data/local/tmp/leaked.hprof
    adb pull /data/local/tmp/leaked.hprof .
    
  4. Compare: Inicia AHAT con el segundo volcado como principal y el primero como referencia:

    java -jar out/host/linux-x86/framework/ahat.jar leaked.hprof --baseline base.hprof
    
  5. Descripción general del análisis: La página Descripción general ahora incluye una columna Δ (Delta). Verás un delta positivo grande para el montón de app, lo que indica un crecimiento significativo de la memoria.

Descripción general de AHAT con Delta

  1. Desglosar: Haz clic en rooted en el menú. En esta página, se muestran los objetos a los que se puede acceder desde las raíces de GC, ordenados por su tamaño retenido. Verás MainActivity en la parte superior con un delta positivo grande.

Vista con raíz de AHAT con delta

Cómo registrar seguimientos de pila de asignación

Si bien la ruta de muestra desde la raíz de GC te indica por qué un objeto sigue activo, no te indica cómo se creó. Los seguimientos de pila de asignación proporcionan la línea de código exacta que asignó un objeto.

Concepto y compensaciones: Registrar el seguimiento de pila de cada asignación es costoso en términos de procesamiento y consume una cantidad significativa de memoria. En una app de producción grande, esto puede hacer que la app sea casi inutilizable. Sin embargo, MemoryLab es una aplicación lo suficientemente pequeña como para que podamos habilitar este seguimiento de forma segura y determinar la fuente de las asignaciones.

Ejercicio: Identifica la fuente de los arrays de bytes

  1. Comienza con el seguimiento: Detén MemoryLab y reinícialo con la marca --track-allocation. Aumenta la profundidad de la pila predeterminada para captar más contexto.

    # Increase the allocation tracker's stack depth (requires a process restart)
    adb shell setprop dalvik.vm.allocTrackerMaxStack 16
    
    adb shell am force-stop com.android.memorylab
    adb shell am start --track-allocation -n com.android.memorylab/.MainActivity
    
  2. Acción: Presiona Allocate Java Memory(10MB) varias veces.

  3. Dump: Toma un volcado de montón y extráelo.

  4. Analizar: Abre el volcado en AHAT. Navega a una instancia de byte[] grande. (p.ej., inspeccionar MainActivity → mJavaAllocations (ArrayList) → elementData (Object[]) → elemento de array [0]).

  5. Verifica: En la vista de la instancia, consulta la sección Sitio de asignación. Se mostrará el seguimiento de pila completo que conduce a MainActivity.allocateJava.

Sitio de asignación de AHAT

Búsqueda de cadenas duplicadas y exceso de hidratación

Incluso cuando una app no tiene pérdidas clásicas de raíces de GC, su montón de Java activo puede estar hinchado por miles de instancias java.lang.String duplicadas creadas durante la deserialización de JSON, Protobuf, Cursor o la base de datos de Room. Las claves repetidas, las cadenas de estado, las etiquetas de categoría o las URLs suelen asignarse de nuevo en cada respuesta de red o consulta de base de datos. En las grandes apps de feeds, mensajería y contenido, las cadenas duplicadas suelen representar entre el 30% y el 60% de la memoria String activa.

Para inspeccionar cadenas duplicadas en AHAT, haz lo siguiente:

  1. Abre la página Allocations y filtra por java.lang.String.
  2. Cuando compares dos volcados de montón con --baseline, verifica si los recuentos de instancias de java.lang.String y los bytes totales aumentan de forma desproporcionada después de hidratar un feed o cargar una caché local.
  3. Navega por la tabla de instancias de java.lang.String (ordenada por tamaño o valor) para detectar valores de cadenas idénticos que se conservan en varios objetos del modelo en memoria.
  • Solución: Evita llamar a String.intern() de forma indiscriminada en entradas arbitrarias del usuario o de la red, ya que la tabla interna del tiempo de ejecución es global y puede generar contención de bloqueo o retener cadenas más tiempo del necesario. En su lugar, anula la duplicación de cadenas de dominio de alta frecuencia durante la deserialización con una caché de anulación de duplicación delimitada y con alcance (como un LruCache<String, String> dentro de tu analizador o adaptador), o bien representa conjuntos fijos de valores como enumeraciones o constantes de números enteros.

Análisis de la dinámica de la memoria de Java (perfil combinado)

Para obtener un panorama completo del comportamiento de la memoria de una aplicación, puedes combinar los contadores de memoria, la actividad de subprocesos y la generación de perfiles de asignación basada en la pila de llamadas en un solo registro de Perfetto. Esto te permite correlacionar las métricas de memoria de todo el sistema (como el RSS y el tamaño del montón) con sitios específicos de ejecución de código y asignación.

Usaremos una configuración combinada que habilite lo siguiente:

  • Contadores de memoria (linux.process_stats): Sondea el RSS y otras métricas de memoria.
  • ATrace (categorías dalvik, memory y sched): Captura estados de subprocesos y eventos de GC.
  • Heapprofd (android.heapprofd): Se dirige a los montones com.android.art (Java) y libc.malloc (nativos) con volcados continuos cada 5 segundos.

Ejercicio: Análisis combinado de la memoria

En este ejercicio, ejecutaremos la app de MemoryLab y realizaremos una secuencia de operaciones de memoria para observar diferentes patrones en el registro:

  1. Baseline: Estado de inactividad.
  2. Churn de Java: Son asignaciones temporales que se recopilan inmediatamente como basura.
  3. Asignación persistente de Java: Asignación de objetos Java que permanecen en la memoria.
  4. Asignación de mapa de bits: Asignar recursos gráficos grandes (que se encuentran en la memoria nativa o de gráficos).
  5. Recuperación: Liberación de todos los recursos asignados.

1. Lanzamiento y preparación

  1. Fuerza la detención y reinicia la app para garantizar un estado limpio:

    adb shell am force-stop com.android.memorylab
    adb shell am start -W -n com.android.memorylab/.MainActivity
    

2. Inicia el registro y activa la secuencia

Comenzaremos un registro de 40 segundos y activaremos los eventos de memoria con los comandos am broadcast.

  1. Inicia el registro:

    adb shell perfetto -c - --txt -o /data/misc/perfetto-traces/java_memory.perfetto-trace <<EOF
    buffers: {
        size_kb: 131072
        fill_policy: RING_BUFFER
    }
    data_sources: {
        config {
            name: "linux.process_stats"
            target_buffer: 0
            process_stats_config {
                scan_all_processes_on_start: true
                proc_stats_poll_ms: 100
            }
        }
    }
    data_sources: {
        config {
            name: "linux.ftrace"
            target_buffer: 0
            ftrace_config {
                ftrace_events: "sched/sched_switch"
                ftrace_events: "task/task_newtask"
                ftrace_events: "task/task_rename"
                ftrace_events: "ftrace/print"
                atrace_categories: "dalvik"
                atrace_categories: "am"
                atrace_categories: "res"
                atrace_categories: "memory"
                atrace_categories: "sched"
                atrace_apps: "com.android.memorylab"
            }
        }
    }
    data_sources: {
        config {
            name: "android.heapprofd"
            target_buffer: 0
            heapprofd_config {
                sampling_interval_bytes: 4096
                process_cmdline: "com.android.memorylab"
                heaps: "libc.malloc"
                heaps: "com.android.art"
                shmem_size_bytes: 8388608
                block_client: true
                continuous_dump_config {
                    dump_phase_ms: 1000
                    dump_interval_ms: 5000
                }
            }
        }
    }
    duration_ms: 40000
    EOF
    
  2. Activa la secuencia (ejecuta estos comandos en la terminal del host mientras se ejecuta el registro, respetando los tiempos sugeridos):

    # Wait ~5s for trace initialization, then start Java churn:
    adb shell am broadcast -a com.android.memorylab.CHURN_JAVA
    
    # Wait ~10s (at 15s mark), allocate 10MB of persistent Java memory:
    adb shell am broadcast -a com.android.memorylab.ALLOC_JAVA
    
    # Wait ~5s (at 20s mark), allocate 20MB of Bitmaps (native/graphics):
    adb shell am broadcast -a com.android.memorylab.LEAK_BITMAP
    
    # Wait ~10s (at 30s mark), free everything:
    adb shell am broadcast -a com.android.memorylab.FREE_ALL
    
  3. Alternativa (herramienta de CLI): También puedes iniciar el perfil con la secuencia de comandos heap_profile directamente, orientada a los montones nativos y de Java con volcados continuos:

    external/perfetto/tools/heap_profile -n com.android.memorylab \
      --heaps com.android.art,libc.malloc \
      -c 5000 \
      -d 40000 \
      -o java_memory_profile
    

3. Cómo analizar el registro combinado

Abre el java_memory.perfetto-trace recopilado en la IU de Perfetto.

Pistas clave en Perfetto

Antes de analizar la línea de tiempo, ubica estos segmentos esenciales del proceso de com.android.memorylab:

  1. mem.rss.anon (RSS anónimo): Se encuentra en la sección Memoria del proceso. Este segmento mide la memoria física (RAM) que el SO asigna al proceso. Representa el espacio en memoria real.
  2. Heap size (KB): También se encuentra en la sección Memoria. Este es un contador específico de Dalvik/ART que representa el espacio de direcciones virtuales reservado para el montón de Java. Refleja el límite interno del montón de la VM, que fluctúa a medida que se asignan objetos y se ejecuta el GC.
  3. HeapTaskDaemon: Se encuentra en la lista de subprocesos del proceso. Este es el subproceso en segundo plano en el que el recolector de basura de ART realiza la mayor parte de su trabajo. La actividad aquí indica pases de GC activos.
  4. Volcados de asignación continua (heapprofd): Se muestran como segmentos coloreados a lo largo de la línea de tiempo superior. Cada segmento representa una duración en el tiempo. Si haces clic en una sola porción o seleccionas un período, puedes inspeccionar el Flamegraph (en el panel inferior) para com.android.art (asignaciones de Java) o libc.malloc (asignaciones nativas) y ver qué se asignó durante ese período.

Análisis de fase cronológico

Revisemos el registro de forma cronológica para ver cómo interactúan estos segmentos durante cada fase del ejercicio.

Fase 1: Referencia (0 a 5 s)
  • Qué sucede: La app está inactiva y espera comandos.
  • Estado del seguimiento:
    • mem.rss.anon: Línea plana en el valor de referencia (por lo general, entre 60 y 80 MB, según el dispositivo).
    • Heap size (KB): Línea plana que coincide con la asignación inicial de la pila de Java.
    • HeapTaskDaemon: Inactivo (no se muestran segmentos de la ejecución).
    • Volcados de asignación: Muestran asignaciones de referencia mínimas.

IU de Perfetto que muestra la referencia de la fase 1

Fase 2: Rotación de la asignación de Java (5 a 15 s)
  • Qué sucede: Se inicia el AllocationChurnThread, y se asignan y descartan arreglos de 1 MB de forma repetida.
  • Estado del seguimiento:
    • Heap size (KB): Muestra un patrón de dientes de sierra rápido. El tamaño del montón aumenta a medida que se acumulan las asignaciones y disminuye drásticamente cuando se ejecuta el GC.
    • HeapTaskDaemon: Muestra una actividad casi constante, con segmentos de ejecución que se alinean perfectamente con las caídas en la sierra Heap size.
    • mem.rss.anon: Realiza un seguimiento de la actividad del montón de Java.
    • Volcados de asignación de montón de Java: Si seleccionas segmentos en este registro, se muestran las asignaciones de montón de com.android.art.

IU de Perfetto que muestra la deserción de la fase 2 Las muestras de asignación revelan que AllocationChurnThread es el asignador principal, con todas las asignaciones que comparten la misma pila de llamadas que apunta a la expresión lambda dentro de MainActivity.java.

IU de Perfetto que muestra las asignaciones de Java de la fase 2

Fase 3: Asignación persistente de Java (15 a 20 s)
  • Qué sucede: Asignamos 10 MB de objetos Java y conservamos una referencia a ellos en mJavaAllocations.
  • Estado del seguimiento:
    • Heap size (KB): La línea de base de los pasos de diente de sierra aumenta en aproximadamente 10 MB.
    • mem.rss.anon: Aumenta en aproximadamente 10 MB, ya que el SO debe respaldar esta asignación persistente con páginas físicas nuevas.
    • Volcados de asignación de montón de Java: Si seleccionas segmentos en este registro, se muestran las asignaciones de montón de com.android.art.
    • Volcados de asignación (gráfico de llamas): La inspección del montón de com.android.art para el volcado tomado en esta ventana muestra una nueva ruta de asignación desde MainActivity.allocateJava que contribuye al tamaño retenido.

IU de Perfetto que muestra la asignación persistente de Java de la fase 3 Selecciona una muestra de asignación que abarque un período que se superponga con el aumento de 10 MB para la asignación persistente. Deberías ver que los callstacks de asignación divergen en dos sitios diferentes, uno responsable de la misma rotación de asignación de corta duración que vimos antes y el otro de la nueva asignación de larga duración.

IU de Perfetto que muestra las asignaciones de Java de la fase 3

Fase 4: Asignación de mapa de bits (20 a 30 s)
  • Qué sucede: También asignamos 20 MB de mapas de bits.
  • Estado del seguimiento:
    • Heap size (KB): Igual que antes.
    • mem.rss.anon: Muestra un aumento significativo de aproximadamente 20 MB, lo que corresponde a las asignaciones nativas para los datos de píxeles de mapa de bits.
    • Volcados de asignación (gráfico de llamas): Esta vez, enfócate en los segmentos del montón de libc.malloc (nativo).

IU de Perfetto que muestra la asignación de mapa de bits de la fase 4 Los registros de pila de llamadas de asignación nativa revelan que la asignación de Bitmap se origina en bibliotecas de gráficos nativos. Este es un buen caso de uso para el seguimiento de asignación nativa, ya que no verás estas asignaciones de Bitmap en el heap de Java.

IU de Perfetto que muestra las asignaciones nativas de la fase 4

Fase 5: Recuperación (30 a 40 s)
  • Qué sucede: Activamos FREE_ALL, lo que borra las referencias a todas las asignaciones y los mapas de bits persistentes de Java, seguido de un System.gc() explícito.
  • Estado del seguimiento:
    • Heap size (KB): Vuelve al nivel de referencia.
    • mem.rss.anon: Vuelve a bajar y muestra el SO que reclama las páginas físicas.
    • HeapTaskDaemon: Muestra una ráfaga final de actividad mientras se procesa la recolección de elementos no utilizados.

IU de Perfetto que muestra la recuperación de la fase 5

Supervisión de errores de OOM históricos (ApplicationExitInfo)

Detectar un LMK cuando ocurre es ideal para la depuración activa, pero para la telemetría de campo, puedes usar la API de ApplicationExitInfo. Esto permite que tu app descubra por qué se cerró en una sesión anterior.

ActivityManager am = getSystemService(ActivityManager.class);
List<ApplicationExitInfo> exitReasons = am.getHistoricalProcessExitReasons(null, 0, 1);
if (!exitReasons.isEmpty()) {
    ApplicationExitInfo info = exitReasons.get(0);
    if (info.getReason() == ApplicationExitInfo.REASON_LOW_MEMORY) {
        // App was killed by the system Low Memory Killer
    }
}

Prácticas recomendadas

  1. Baseline First: Siempre toma un volcado de montón de "referencia" después de que se inicialice la app, pero antes de realizar la acción que estás probando.
  2. Usa la página Activity Leaks de AHAT: AHAT incluye una página Activity Leaks dedicada que identifica automáticamente las instancias de Activity que se destruyeron, pero que aún se conservan en la memoria. Esta suele ser la forma más rápida de encontrar fugas comunes.
  3. Check Path to GC Roots: Para cualquier objeto con fugas, usa la vista Path from Root en AHAT para comprender exactamente qué referencia lo mantiene activo (p.ej., un campo estático, un subproceso de ejecución prolongada o un objeto de escucha registrado).

← Herramientas | ↑ Arriba | Mapas de bits →