wandres.dev
PROFILING · medir con Perfetto

Memoria: heap dumps, fugas típicas y LeakCanary como red de seguridad

En un entorno con recolector de basura una fuga no es memoria olvidada sino memoria indeseadamente alcanzable, y esa definición cambia por completo el método de búsqueda. Esta lección construye el vocabulario del análisis de montón —raíces, alcanzabilidad, tamaño propio frente a tamaño retenido, árbol de dominadores— y lo aplica al catálogo de fugas que se repiten en Android: el contexto retenido, el escucha jamás dado de baja, el enlace de vistas que sobrevive a su fragmento y la corrutina huérfana. Cierra con `LeakCanary`, su mecánica real de detección y, sobre todo, con la delimitación honesta de lo que no puede ver.

⏱ 20 min

La palabra fuga viene de un mundo donde la memoria se pedía y se devolvía a mano, y donde fugarse significaba olvidarse de devolverla. En un entorno con recolector de basura ese olvido es imposible: el recolector libera absolutamente todo lo que ya no es alcanzable desde una raíz. Por eso una fuga en Android no es memoria perdida, es memoria retenida, y la diferencia no es terminológica sino operativa. No hay que buscar quién se olvidó de liberar algo, hay que buscar quién sigue apuntando a algo que ya no debería importarle a nadie. Toda la disciplina se apoya en esa inversión: el objeto sospechoso no es culpable de existir, es víctima de una referencia que alguien mantiene viva más allá de su tiempo, y encontrar la fuga consiste siempre en reconstruir la cadena de referencias que va desde una raíz del recolector hasta el cadáver que se niega a desaparecer.

🎯 Al terminar esta lección sabrás
  • Definir una fuga como alcanzabilidad indeseada y separar el problema de la simple ocupación excesiva del montón.
  • Manejar el vocabulario del análisis: raíces, camino de referencias, tamaño propio, tamaño retenido y dominadores.
  • Reconocer el catálogo de fugas recurrentes de Android y su patrón común de vida desacoplada.
  • Integrar LeakCanary como red de seguridad conociendo su mecánica y, sobre todo, sus puntos ciegos.

El montón, la alcanzabilidad y las dos magnitudes

Un volcado del montón es una fotografía completa de los objetos vivos en un instante: sus clases, sus campos y las referencias que los enlazan. Se captura desde la tarea correspondiente del perfilador o directamente desde la consola, lo que resulta útil cuando el problema aparece en un dispositivo concreto o en un momento que no se puede reproducir con el entorno conectado.

# Fuerza recoleccion y vuelca el monton del proceso indicado.
adb shell am dumpheap com.ejemplo.app /data/local/tmp/volcado.hprof
adb pull /data/local/tmp/volcado.hprof .

Sobre esa fotografía todo el análisis se apoya en dos magnitudes que se confunden con enorme facilidad. El tamaño propio es lo que ocupa el objeto en sí mismo: sus campos, y nada más. Una actividad tiene un tamaño propio ridículo, unos pocos cientos de bytes. El tamaño retenido es lo que se liberaría si ese objeto desapareciera, es decir, todo lo que solo él mantiene vivo, y ahí una actividad filtrada puede arrastrar la jerarquía entera de vistas, los mapas de bits que estaban cargados y el estado de la pantalla completa: varios megabytes por instancia. Ordenar el volcado por tamaño propio no encuentra jamás una fuga; ordenarlo por tamaño retenido la pone en la primera fila.

La estructura que hace posible ese cálculo es el árbol de dominadores. Se dice que un objeto domina a otro cuando todos los caminos desde cualquier raíz hasta el segundo pasan por el primero, y el tamaño retenido de un objeto es simplemente la suma de lo que domina. Esa noción es la que convierte una maraña de referencias en una jerarquía legible, y es la que permite responder la única pregunta que importa: si esto desapareciera, ¿cuánta memoria volvería?

💡
Dos volcados valen mucho más que uno

Un volcado aislado dice qué hay, pero no si eso es normal. El método fiable es diferencial: se captura un volcado, se ejecuta el ciclo sospechoso varias veces —entrar en la pantalla y salir, rotar el dispositivo, abrir y cerrar el diálogo—, se fuerza la recolección y se captura un segundo volcado. Lo que interesa no es la lista de objetos sino el delta: si tras cinco entradas y salidas hay cinco instancias de la misma actividad, la fuga está demostrada y además está cuantificada. Este procedimiento también protege del falso positivo más habitual, que es acusar a un objeto que todavía no había sido recolectado simplemente porque nadie se lo había pedido al recolector.

El catálogo de fugas y su patrón común

Las fugas de Android se repiten con una monotonía sorprendente, y todas responden al mismo esquema: algo cuya vida es larga —el proceso, un objeto único, un hilo, el sistema— guarda una referencia a algo cuya vida es corta: una actividad, un fragmento, una vista, un contexto de interfaz.

🏛️

Contexto retenido

Un campo estático o un objeto único que guarda un contexto de actividad. La regla es que todo lo que viva más que una pantalla debe recibir el contexto de la aplicación, nunca el de la actividad.

👂

Escucha sin dar de baja

Un sensor, un receptor, un observador del árbol de vistas o una devolución de llamada registrada en un servicio del sistema y nunca retirada. El sistema vive para siempre y arrastra al oyente con él.

🧷

Enlace de vistas superviviente

Un fragmento que guarda el enlace generado y no lo anula al destruirse su vista. El fragmento sobrevive a su jerarquía y la mantiene entera en memoria.

🧶

Corrutina o tarea huérfana

Trabajo lanzado en un ámbito que no está atado al ciclo de vida y que captura la pantalla en su cierre. Mientras no termine, la pantalla no puede morir.

Hay dos casos que merecen mención aparte porque desafían la intuición. Una clase interna no estática mantiene una referencia implícita a su instancia externa, de modo que un mensaje diferido publicado en una cola desde una clase interna retiene la actividad durante todo el retardo, sin que ninguna línea de código exprese esa relación. Y un objeto observable de larga vida que acepta observadores sin ámbito de ciclo de vida convierte cada suscripción en una fuga potencial, que es exactamente la razón por la que la variante ligada al ciclo de vida existe.

La corrección estructural de casi todas ellas cabe en una idea: hacer que el ámbito de vida sea una propiedad expresada por el tipo en lugar de un acuerdo que alguien debe recordar.

class Pantalla : Fragment(R.layout.pantalla) {

    // Mal: el enlace sobrevive a la vista y retiene la jerarquia entera.
    private var enlaceMal: PantallaBinding? = null

    // Bien: el delegado lo anula al destruirse la vista, sin recordarlo nadie.
    private val enlace by enlaceDeVista(PantallaBinding::bind)

    override fun onViewCreated(v: View, estado: Bundle?) {
        // Atado al ciclo de vida: se cancela solo, no puede quedar huerfano.
        viewLifecycleOwner.lifecycleScope.launch {
            viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
                modelo.estado.collect { enlace.titulo.text = it.titulo }
            }
        }
    }
}

Conviene además separar la fuga de su prima cercana, el exceso de ocupación. Una caché sin límite, un mapa de bits decodificado a resolución completa para mostrarse en miniatura o una lista que acumula todo el histórico no son fugas: son objetos legítimamente alcanzables y correctamente retenidos. El síntoma es el mismo —la memoria sube y no baja— pero el diagnóstico y el remedio son opuestos, y ninguna herramienta de detección de fugas los señalará jamás.

LeakCanary: qué hace exactamente y qué no ve

LeakCanary no es un analizador que se ejecuta cuando uno lo abre: es un vigilante que se instala solo en las compilaciones de depuración y observa la destrucción de los objetos cuyo fin de vida el sistema declara explícitamente. Su mecánica tiene cuatro pasos y entenderlos evita tanto la desconfianza como la fe ciega.

Primero, envuelve el objeto que acaba de ser destruido en una referencia débil y anota su identidad. Segundo, espera: le da un margen de tiempo y provoca una recolección, porque un objeto todavía alcanzable un instante después de morir no prueba nada. Tercero, si la referencia débil sigue sin vaciarse, declara el objeto retenido y vuelca el montón dentro del propio proceso. Cuarto, analiza ese volcado buscando el camino de referencias más corto desde una raíz del recolector hasta el objeto retenido, y presenta esa cadena eliminando los tramos irrelevantes: lo que queda es el nombre del campo concreto que sostiene la fuga.

// Vigilar objetos propios cuyo fin de vida conoces tu y no el sistema.
class Presentador(private val vista: Vista) {
    fun destruir() {
        AppWatcher.objectWatcher.expectWeaklyReachable(
            this, "El presentador fue destruido explicitamente",
        )
    }
}

Esa cuarta fase, la del camino más corto, es el verdadero producto de la herramienta y también la razón de su nombre popular: no dice que hay una fuga, dice quién la sostiene. El resto —la vigilancia automática de actividades, fragmentos, vistas, modelos de vista y servicios— es solo la conveniencia de no tener que pedirlo.

flowchart TD
A[El sistema destruye una actividad o fragmento] --> B[Referencia debil y espera]
B --> C[Se fuerza una recoleccion]
C --> D{La referencia debil se vacio}
D -->|Si| E[No hay fuga: fin]
D -->|No| F[Objeto retenido: volcado en proceso]
F --> G[Camino mas corto desde una raiz]
G --> H[Nombre del campo que sostiene la fuga]
style A fill:#89b4fa,color:#11111b
style E fill:#a6e3a1,color:#11111b
style H fill:#fab387,color:#11111b
⚠️
Sus puntos ciegos son tan importantes como sus aciertos

LeakCanary solo ve lo que vigila, y solo vigila objetos cuyo final de vida el sistema anuncia. No detecta el exceso de ocupación, porque una caché desbordada está perfectamente referenciada. No detecta la memoria nativa, y desde que los mapas de bits viven fuera del montón gestionado una aplicación puede morir por consumo sin que el análisis del montón muestre nada anómalo. No detecta las fugas de tus propias abstracciones salvo que se lo pidas explícitamente. Y no corre en producción: su coste —volcados dentro del proceso, pausas visibles— lo hace inaceptable allí. Es una red de seguridad para el desarrollo, no un sistema de vigilancia de campo, y la señal que da nunca sustituye al análisis del volcado cuando la cadena de referencias resulta ambigua.

Una fuga es un desacuerdo entre dos relojes, no un error de contabilidad

Si se examinan las fugas del catálogo buscando lo que tienen en común, no aparece ningún descuido de gestión de memoria. Aparece siempre lo mismo: dos objetos con ciclos de vida gobernados por autoridades distintas que se enlazan como si compartieran uno solo. La actividad la destruye el sistema cuando quiere; el objeto único vive lo que vive el proceso; el hilo termina cuando termina su trabajo; el receptor registrado en un servicio del sistema vive hasta que alguien lo da de baja. Cada uno responde a un reloj diferente, y cuando el de vida larga guarda una referencia al de vida corta, el segundo queda encadenado al primero sin que ninguna línea de código exprese esa dependencia. Por eso las fugas no se previenen leyendo el código con más atención: no son visibles en el código, son visibles en el diagrama de tiempos que el código no dibuja. Y por eso la solución estructural nunca ha sido acordarse de anular referencias, que es una disciplina que falla en cuanto el equipo crece, sino hacer que el ámbito de vida sea una propiedad expresada del objeto y no un acuerdo tácito entre programadores. Ese es exactamente el sentido de todo lo que Android ha ido construyendo alrededor del ciclo de vida durante una década: el observador consciente del ciclo de vida que se da de baja solo, el ámbito de corrutinas que se cancela con el modelo de vista, el flujo que se recoge únicamente mientras la pantalla está en primer plano, la propiedad delegada que anula el enlace al destruirse la vista. Ninguna de esas piezas es azúcar sintáctico: todas son el mismo movimiento, que consiste en trasladar la duración de una relación desde la cabeza del programador hasta el sistema de tipos y el entorno de ejecución. Detectar una fuga con un volcado del montón es, en ese sentido, un diagnóstico tardío de un problema de diseño; el instrumento encuentra el cadáver, pero la enfermedad estaba en haber unido dos relojes que nunca acordaron marchar juntos. La herramienta que de verdad elimina esta clase de fallo no es el analizador de montones, es la disciplina de no escribir nunca una referencia que sobreviva al ámbito que la justifica.

⚔️ Provoca la fuga, mídela y ciérrala
  1. Retén una actividad desde un campo estático, entra y sal de la pantalla cinco veces y demuestra con dos volcados diferenciales que hay exactamente cinco instancias vivas.
  2. Ordena ese volcado por tamaño propio y después por tamaño retenido. Explica por qué el primero no habría encontrado nada.
  3. Registra un escucha en un servicio del sistema sin darlo de baja y sigue el camino de referencias completo desde la raíz hasta la actividad. Nombra el campo culpable.
  4. Instala LeakCanary, reproduce ambas fugas y compara su cadena resumida con la que tú reconstruiste a mano. Después vigila con expectWeaklyReachable un objeto propio que la herramienta no observa.
  5. Construye una caché sin límite que haga crecer la memoria hasta el fallo y comprueba que LeakCanary guarda silencio. Explica por qué el diagnóstico y el remedio son distintos a los de una fuga.