Todo lo que necesitas saber sobre las fugas de memoria en Android.
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?
- ¡Espera un momento!!!🥴
- Heap & Stack
- Más sobre la memoria Heap
- ¿Te has preguntado alguna vez cuál es el tamaño del heap para tu aplicación?
- ¿Cómo se puede comprobar el tamaño de la pila de aplicaciones para su dispositivo?
- ¿Cómo funciona en el mundo real?
- ¿Qué pasa cuando los métodos terminan?
- Conclusión
- ¿Qué pasa con el Heap?
- ¿Cómo funciona el recolector de basura?
- ¿Qué ocurre cuando se ejecuta el recolector de basura?
- ¿Cuándo y cómo se producen las fugas de memoria?
- ¿Cómo podemos provocar una fuga?
- ¿Cómo podemos provocar una fuga utilizando hilos?
- Flujo regular
¿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
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.
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.
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.
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.
Después de ejecutar el GC los resultados serán los siguientes:
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.
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!