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.

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

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.

Haz clic en la clase para encontrar todas las instancias.
Haz clic en la instancia MainActivity para inspeccionarla.

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.

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.

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.
- Acción: En MemoryLab, presiona Leak an Activity. Esto inicia
LeakedActivity, que se filtra de forma intencional. - Dump: Toma un volcado de montón.
- Analizar: Haz clic en Fugas de actividad en la barra lateral de AHAT.
- Verificar: AHAT mostrará
com.android.memorylab.LeakedActivitycomo filtrado porque su campomDestroyedes 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.

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
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 .Acción: Presiona Allocate Java Memory(10 MB) varias veces en la app.
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 .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.hprofDescripció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.

- 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
MainActivityen la parte superior con un delta positivo grande.

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
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/.MainActivityAcción: Presiona Allocate Java Memory(10MB) varias veces.
Dump: Toma un volcado de montón y extráelo.
Analizar: Abre el volcado en AHAT. Navega a una instancia de
byte[]grande. (p.ej., inspeccionarMainActivity→mJavaAllocations(ArrayList) →elementData(Object[]) → elemento de array[0]).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.

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:
- Abre la página Allocations y filtra por
java.lang.String. - Cuando compares dos volcados de montón con
--baseline, verifica si los recuentos de instancias dejava.lang.Stringy los bytes totales aumentan de forma desproporcionada después de hidratar un feed o cargar una caché local. - 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 unLruCache<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,memoryysched): Captura estados de subprocesos y eventos de GC. - Heapprofd (
android.heapprofd): Se dirige a los montonescom.android.art(Java) ylibc.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:
- Baseline: Estado de inactividad.
- Churn de Java: Son asignaciones temporales que se recopilan inmediatamente como basura.
- Asignación persistente de Java: Asignación de objetos Java que permanecen en la memoria.
- Asignación de mapa de bits: Asignar recursos gráficos grandes (que se encuentran en la memoria nativa o de gráficos).
- Recuperación: Liberación de todos los recursos asignados.
1. Lanzamiento y preparación
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.
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 EOFActiva 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_ALLAlternativa (herramienta de CLI): También puedes iniciar el perfil con la secuencia de comandos
heap_profiledirectamente, 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:
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.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.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.- 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.

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

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.allocateJavaque contribuye al tamaño retenido.
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.

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

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

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