Todo lo que necesitas saber sobre las fugas de memoria en Android.

Ali Asadi
Jun 30, 2019 – 16 min read

Una de las principales ventajas de Java, o para ser más exactos, de la JVM (Java Virtual Machine), es el recolector de basura (GC). Podemos crear nuevos objetos sin preocuparnos de liberarlos de la memoria. El recolector de basura se encargará de asignar y liberar la memoria por nosotros.

¡No exactamente! Podemos evitar que el recolector de basura libere la memoria por nosotros si no entendemos bien cómo funciona el GC.

Escribir un código sin entender bien cómo funciona el GC podría provocar fugas de memoria en la app. Estas fugas pueden afectar a nuestra aplicación desperdiciando la memoria no liberada y eventualmente causando excepciones de falta de memoria y retrasos.

¿Qué es una fuga de memoria?

Falta de liberación de objetos no utilizados de la memoria

Falta de liberación de objetos no utilizados de la memoria significa que hay objetos no utilizados en la aplicación que el GC no puede limpiar de la memoria.

Cuando el GC no puede limpiar los objetos no utilizados de la memoria, estamos en problemas. La unidad de memoria que contiene los objetos no utilizados estará ocupada hasta el final de la aplicación o (hasta el final del método).

¿Hasta el final del método? Sí, así es. Tenemos dos tipos de fugas, las que ocupan la unidad de memoria hasta el final de la aplicación y las que ocupan la unidad de memoria hasta el final del método. El primero está claro. La segunda necesita más aclaración. Pongamos un ejemplo para explicarlo. Supongamos que tenemos el método X. El método X está haciendo alguna tarea de larga duración en segundo plano, y tardará un minuto en terminar. Además, el método X está reteniendo objetos no utilizados mientras lo hace. En ese caso, la unidad de memoria estará ocupada, y los objetos no utilizados no podrán ser limpiados durante un minuto hasta el final de la tarea. Después de la terminación del método, el GC puede borrar los objetos no utilizados y recuperar la memoria.

Eso es lo que quiero que sepas por ahora volveremos a esto más adelante con algo de código y visualización. SERÁ DIVERTIDO. 👹😜

¡Espera un momento!!!🥴

Antes de saltar al contexto, empecemos por lo básico.

La RAM, o memoria de acceso aleatorio, es la memoria de los dispositivos androides u ordenadores que se utiliza para almacenar las aplicaciones que se están ejecutando y sus datos.

Voy a explicar dos personajes principales de la RAM, el primero es el Heap, y el segundo es el Stack. Pasemos a la parte divertida 🤩🍻.

Heap & Stack

No voy a hacerlo muy largo. Vamos al grano, una breve descripción, la Pila se utiliza para la asignación de memoria estática mientras que el Heap se utiliza para la asignación de memoria dinámica. Sólo hay que tener en cuenta que tanto el Heap como el Stack se almacenan en la RAM.

Más sobre la memoria Heap

La memoria Heap de Java es utilizada por la máquina virtual para asignar objetos. Siempre que se crea un objeto, se crea en el heap. Las máquinas virtuales, como JVM o DVM, realizan una recolección de basura (GC) regular, haciendo que la memoria heap de todos los objetos que ya no son referenciados esté disponible para futuras asignaciones.

Para proporcionar una experiencia de usuario sin problemas, Android establece un límite duro en el tamaño del heap para cada aplicación en ejecución. El límite de tamaño de la pila varía entre los dispositivos y se basa en la cantidad de RAM que tiene un dispositivo. Si tu aplicación alcanza este límite de heap e intenta asignar más memoria, recibirá un OutOfMemoryError y terminará.

¿Te has preguntado alguna vez cuál es el tamaño del heap para tu aplicación?

Descubramos esto juntos. En android, tenemos la Dalvik VM (DVM). La DVM es una máquina virtual Java única optimizada para dispositivos móviles. Optimiza la máquina virtual para la memoria, la vida de la batería, y el rendimiento, y es responsable de la distribución de la cantidad de memoria para cada aplicación.

Hablemos de dos líneas en el DVM:

  1. dalvik.vm.heapgrowthlimit: Esta línea se basa en cómo Dalvik se iniciará en el tamaño del heap de su aplicación. Es el tamaño de heap por defecto para cada aplicación. El máximo que puede alcanzar tu aplicación!
  2. dalvik.vm.heapsize: Esta línea representa el tamaño máximo del heap para un heap más grande. Puedes conseguirlo pidiendo a android un heap mayor en el manifiesto de tu aplicación (android:largeHeap=»true»).

No utilices un heap mayor en tu aplicación. Hazlo SOLO si sabes exactamente el efecto secundario de este paso. Aquí te daré suficiente información para que sigas investigando el tema.

Aquí tienes una tabla que muestra qué tamaño de heap tienes en función de la RAM de tu dispositivo:

+==========================+=========+=========+===================+
| DVM | 1GB RAM | 2GB RAM | 3GB RAM OR HIGHER |
+==========================+=========+=========+===================+
| DEFAULT(heapgrowthlimit) | 64m | 128m | 256m |
+--------------------------+---------+---------+-------------------+
| LARGE(heapsize) | 128m | 256m | 512m |
+--------------------------+---------+---------+-------------------+

Recuerda que cuanto más ram tengas mayor será el tamaño del heap. Tenga en cuenta que no todos los dispositivos con mayor ram van por encima de 512m hacer su investigación en su dispositivo si su dispositivo tiene más de 3 GB para ver si su tamaño de la pila es mayor que 512m.

¿Cómo se puede comprobar el tamaño de la pila de aplicaciones para su dispositivo?

Usando el ActivityManager. Puedes comprobar el tamaño máximo del heap en tiempo de ejecución utilizando los métodos getMemoryClass() o getLargeMemoryClass() (cuando se habilita un heap grande).

  • getMemoryClass(): Devuelve el tamaño máximo de heap por defecto.
  • getLargeMemoryClass(): Devuelve el tamaño máximo de heap disponible después de habilitar la bandera de heap grande en el manifiesto.
ActivityManager am = getSystemService(ACTIVITY_SERVICE);
Log.d("XXX", "dalvik.vm.heapgrowthlimit: " + am.getMemoryClass());
Log.d("XXX", "dalvik.vm.heapsize: " + am.getLargeMemoryClass());

¿Cómo funciona en el mundo real?

Utilizaremos esta sencilla aplicación para entender cuándo utilizamos el heap y cuándo la pila.

aplicación

La imagen de abajo muestra una representación del heap y la pila de la aplicación y dónde apunta y se almacena cada objeto cuando ejecutamos la aplicación.

Vamos a repasar la ejecución de la app, detenernos en cada línea, explicar cuándo la aplicación asigna los objetos y los almacena en el heap o en la pila. También veremos cuando la app libera los objetos de la pila y del heap.

  • Línea 1 – La JVM crea un bloque de memoria en la pila para el método main.

  • Línea 2 – En esta línea, creamos una variable local primitiva. La variable será creada y almacenada en la memoria de la pila del método principal.

  • Línea 3 -¡Aquí necesito tu atención! En esta línea, creamos un nuevo objeto. El objeto se crea en la pila del método main y se almacena en el heap. La pila almacena la referencia, la dirección de memoria del objeto en el heap (puntero), mientras que el heap almacena el objeto original.

  • Línea 4 – Lo mismo que la línea 3.

  • Línea 5 – La JVM crea un bloque de memoria de pila para el método foo.

  • Línea 6 -Crea un nuevo objeto. El objeto se crea en la memoria de la pila del método foo, y almacenamos en la pila la dirección de memoria heap del objeto que creamos en la Línea 3. El valor (dirección de memoria heap del objeto en la línea 3) lo pasamos en la Línea 5. Hay que tener en cuenta que Java siempre pasa referencias por valor.

  • Línea 7 – Estamos creando un nuevo objeto. El objeto creado en la pila y apuntado al pool de cadenas en el heap.

  • Línea 8 – En la línea final del método foo, el método termina. Y los objetos serán liberados del bloque de pila del método foo.

  • Línea 9- Igual que la línea 8, en la línea final del método main, el método terminó. Y el bloque de pila del método main se libera.

¿Qué pasa con la liberación de la memoria del heap? Pronto llegaremos a eso. Coge un café☕️, y sigue 😼.

¿Qué pasa cuando los métodos terminan?

Cada método tiene su propio ámbito. Cuando el método se termina, los objetos son liberados y reclamados automáticamente de la pila.

Figura 1

En la Figura 1, cuando el método foo termina. La memoria de la pila o el bloque de la pila del método foo se liberará y recuperará automáticamente.

figura 2

En la Figura 2, Lo mismo. Cuando el método main terminó. La memoria de la pila o el bloque de pila del método principal será liberado y reclamado automáticamente.

Conclusión

Ahora, nos queda claro que los objetos de la pila son temporales. Una vez que el método termine, los objetos serán liberados y reclamados.

La pila es una estructura de datos LIFO (Last-In-First-Out). Se puede ver como una caja. Usando esta estructura, el programa puede gestionar fácilmente todas sus operaciones usando dos simples operaciones: push y pop.

Cada vez que necesitas guardar algo como una variable o un método empuja y mueve el puntero de la pila hacia arriba. Cada vez que se sale de un método, hace saltar todo del puntero de la pila hasta el retorno a la dirección del método anterior. En nuestro ejemplo volviendo del método foo al método main.

¿Qué pasa con el Heap?

El heap es diferente de la pila. Para liberar y reclamar los objetos de la memoria del heap, necesitamos ayuda.

Para eso, Java, o para ser más exactos, la JVM ha hecho un superhéroe para ayudarnos. Lo hemos llamado el Recolector de Basura. Él va a hacer el trabajo duro por nosotros. Y preocuparse de detectar los objetos no utilizados, liberarlos, y recuperar más espacio en la memoria.

¿Cómo funciona el recolector de basura?

Simplemente. El recolector de basura busca objetos no utilizados o inalcanzables. Cuando hay un objeto en el heap que no tiene ninguna referencia apuntada a él, el recolector de basura se encarga de liberarlo de la memoria y reclamar más espacio.

GC

Las raíces de los GC son objetos referenciados por la JVM. Son los objetos iniciales del árbol. Cada objeto del árbol tiene uno o más objetos raíz. Mientras la aplicación o las raíces GC puedan alcanzar esas raíces o esos objetos, todo el árbol es alcanzable. Una vez que se vuelvan inalcanzables desde la aplicación o las raíces del GC, se considerarán como objetos no utilizados o inalcanzables.

¿Qué ocurre cuando se ejecuta el recolector de basura?

Por ahora, este es el estado actual de la memoria de la aplicación. La pila está limpia y el heap está lleno de objetos no utilizados.

Antes del GC

Después de ejecutar el GC los resultados serán los siguientes:

Después de la GC

La GC liberará y borrará todos los objetos no utilizados del heap.

¡Hombre! Qué pasa con la fuga de memoria que estamos esperando? LOL, sólo un poco más, y estaremos allí. En nuestra aplicación simple, la aplicación fue escrito grande y simple. No había nada malo en el código que pudiera impedir que el GC liberara los objetos del heap. Y por eso, el GC libera y reclama todos los objetos de la memoria del heap. Continúa. Tenemos un montón de ejemplos de fugas de memoria en la siguiente sección😃.

¿Cuándo y cómo se producen las fugas de memoria?

Una fuga de memoria se produce cuando la pila sigue haciendo referencias a objetos no utilizados en el heap.

Hay una representación visual sencilla en la imagen de abajo para entender mejor el concepto.

En la representación visual, vemos que cuando tenemos objetos referenciados de la pila pero que ya no están en uso. El recolector de basura nunca los liberará de la memoria porque muestra que esos objetos están en uso cuando no lo están.

¿Cómo podemos provocar una fuga?

Hay varias formas de provocar una fuga de memoria en Android. Y se puede hacer fácilmente usando AsyncTasks, Handlers, Singleton, Threads, y más.

Mostraré algunos ejemplos usando threads, singleton, y listeners para explicar cómo podemos causar una fuga y evitarlas y arreglarlas.

Consulta mi repositorio de Github. Tengo algunos ejemplos de código.

¿Cómo podemos provocar una fuga utilizando hilos?

En este ejemplo, vamos a iniciar una actividad que ejecuta un hilo en el fondo. El hilo va a realizar una tarea que tarda 20 segundos en terminar.

Como es sabido, las clases internas mantienen una referencia implícita a su clase envolvente.

En realidad, así es como se ve la actividad.

La DownloadTask mantiene una referencia a la ThreadActivity.

¿Qué pasa después de iniciar la tarea o el hilo?

Hay dos flujos posibles para utilizar la aplicación. Un flujo regular que funciona como se espera sin errores y un flujo de fuga que causa una fuga de memoria.

Flujo regular

En la imagen, estamos presentando el heap y la pila de la aplicación.

El usuario inicia la aplicación, abre el ThreadActivity, y espera en la pantalla hasta la finalización de la tarea de descarga. El usuario esperó durante 20 segundos. ¿Por qué 20 segundos? Porque este es el tiempo que el hilo tarda en completar la tarea.

Tarea en ejecución

La tarea se está ejecutando en segundo plano. El usuario ha esperado 20 segundos a que se complete la tarea de descarga. Cuando la tarea termina, la pila libera el bloque del método run().

No hay ninguna referencia que mantenga la DownloadTask. El GC consideró el objeto DownladTask como un objeto no utilizado, y por esa razón, el próximo ciclo de GC lo borrará de la memoria del heap.

El GC borra los objetos no utilizados del heap. Ahora, cuando el usuario cierra la actividad. El método main será liberado de la pila, y en el siguiente ciclo del GC, el GC borrará el ThreadActivity de la memoria del heap.

¡Perfecto!