Introducción.
La gestión de memoria de Android.
- Se carece de unidad swap y la memoria virtual sólo sirve para el código de la aplicación. Es decir; los objetos de nuestro programa se encuentran en memoria y no son escritos a la memoria secundaria en ningún momento. Conforme se liberan las referencias de objetos, el recolector de basura (GC) los va destruyendo y la memoria disponible va aumentando.
- Android comparte la memoria principal entre todos los procesos. El proceso de la app se crea a partir del denominado proceso Zygote una vez que el SO cargó todo el marco de trabajo.
- Los datos estáticos de la app son mapeados de la memoria a un fichero para ser compartidos con otros procesos o para poder ser recuperados posteriormente. Entre este tipo de datos se encuentran el código Dalvik y el código de la propia aplicación.
- Android comparte las mismas zonas de memoria entre varios procesos para ciertos menesteres, como por ejemplo, hacer uso de los cursores. Si una app utiliza cursores y hace uso de algún ContentProvider, los cursores estarán en una zona de memoria compartida por el ContentProvider y nuestra app.
- La pila o heap asociada a cada proceso tiene asignado un tamaño de memoria, el cual puede incrementarse sólo hasta el límite establecido para la aplicación. Este límite varía en función del tamaño de memoria del dispositivo y tiene como principal función garantizar la multi-tarea. Si dicho límite se excede se lanza un OutOfMemoryError.
- El GC está mejorado para liberar más memoria y hacerlo más rápido a partir de Android 2.3. Si la app no libera los recursos usados el GC no sirve de nada. Suena a perogrullada, pero siempre que se pueda es mejor no reservar memoria que utilizarla (a pesar de que después se libere). Además a mayor memoria usada, más posibilidades hay de que la acción del recolector sobre el proceso de la app se vuelva más agresiva provocando pequeños retardos o lags que perjudiquen la experiencia del usuario.
- La caché de procesos en ejecución de Android es de tipo LRU por lo que en caso de poca memoria se finalizará con los procesos que menos se estén usando. Aún así, se pueden seleccionar procesos que estén abarcando mucha cantidad de RAM aunque se hayan usado con mayor frecuencia, ya que al eliminar un proceso grande se libera mayor cantidad de memoria. Moraleja: El ahorro de memoria ayuda a mantener nuestros procesos en ejecución.
Optimización del uso de memoria.
OPTIMIZACIONES GENÉRICAS.
Refactorizar el código.
Utilizar bucles for-each siempre que sea posible.Este tipo de bucle proporciona un mayor rendimiento en recorridos secuenciales y consume menos memoria que un bucle for normal que trabaje explícitamente con una variable a modo de contador. Funciona con vectores y con clases que implementen la interfaz Iterable. Fue incorporado al lenguaje Java a partir de la versión 1.5 y posee la siguiente sintaxis:
for ( Tipo variable : coleciónDeElementosTipo ) { // Hacer algo. }
Utilizar tipos de datos enteros en vez de decimales.
No olvidar marcar las constantes como static final.
Acompañar al modificador final con static ahorra memoria, aunque sólo con los tipos de datos básicos o de tipo String. Esto es así porque el compilador evita que se invoque al método de inicialización la primera vez que se utilice la clase a la que pertenezca la constante, utilizando directamente y en su lugar el valor de la misma.
- El típico método que devuelve parte de un String mayor y que crea un objeto intermedio temporal a retornar del mismo tipo, cuando directamente se puede devolver una parte como un «substring» ahorrándose la copia intermedia.
- Utilizar vectores para casi todo: Un vector ocupa menos espacio en memoria que, por ejemplo, un ArrayList. Si se necesita una matriz con dos filas o crear un objeto de tipo clave-valor compensa crear dos vectores que conjuntamente provean la misma funcionalidad.
Una imagen más pequeña siempre va a ocupar memor tamaño que una grande. Calcular el tamaño que se desea mostrar (redimensionando la imagen si procede) para tener en memoria la imagen que menos ocupe.
Supervisión del uso de memoria de una aplicación.
Existen varios métodos para supervisar la memoria: interpretar los mensajes del log, visualizar las actualizaciones de la pila, realizar un seguimiento de la asignación de memoria y utilizar la herramienta MAT que proporciona el IDE Eclipse.
INTERPRETACIÓN DE LOGS.
Al correr una aplicación en el AVD o en un dispositivo conectado al equipo de desarrollo (con el modo de depuración USB activado), desde Eclipse se nos muestra una pestaña con el LogCat. En dicho log se muestra toda la información relativa que proporciona Android durante la depuración de una app, entre ella qué pasa cuando el recolector de basura entra en acción. Los distintos tipos de mensaje que proporciona el GC son:
- GC_CONCURRENT: indica que una nueva estancia concurrente del recolector de basura ha iniciado su ejecución para liberar memoria de la aplicación ya que la pila se encuentra casi completa al máximo.
- GC_FOR_MALLOC/GC_FOR_ALLOC: El recolector se ha lanzado cuando tu aplicación intenta que se le asigne más memoria y ya posee la pila completa. El SO puede detener la aplicación. Existen dos posibles mensajes porque el primero fue renombrado al segundo, según con que versión de la API se trabaje (aunque significan lo mismo).
- GC_HPROF_DUMP_HEAP: El recolector de basura es lanzado para crear un volcado de la pila a un fichero HPROF, respondiendo así a la petición del desarrollador.
- GC_EXPLICIT: El GC es invocado mediante una llamada explícita en el código de la aplicación (su uso es poco recomendable).
- GC_EXTERNAL_ALLOC: El GC es invocado para limpiar una zona externa de memoria a la de la aplicación (está en deshuso y sólo funciona con la API 10 o anteriores).
- GC_BEFORE_OOM: Lo peor de lo peor sucede al leer esto. El GC es iniciado porque no queda memoria disponible en la pila y al intertar desbordarla la app al asignar más memoria se intenta liberar la máxima posible. Si la memoria liberada es inferior a la nueva que se intenta asignar se produce el fatídico OutOfMemoryError.
Junto con el tipo de mensaje, en la salida del LogCat, se encuentra información adicional como la cantidad de memoria liberada por el recolector de basura, el estado actual de la pila, el estado de la asignación de memoria externa (API 10 o inferior) y el tiempo en que se detuvo la pila antes y después del GC. Por ejemplo, esta es la salida de una aplicación que se está desarrollando:
Captura del LogCat bajo Eclipse en un proyecto real. |
Hay tres líneas en este log que son clave:
D/dalvikvm(3346): GC_CONCURRENT freed 6560K, 39% free 29237K/47331K, paused 2ms+29ms D/dalvikvm(3346): GC_FOR_ALLOC freed 6632K, 40% free 28845K/47331K, paused 99ms D/dalvikvm(3346): GC_BEFORE_OOM freed 9K, 40% free 28836K/47331K, paused 73ms
Todas tienen el mismo formato. En la primera (no sale en la captura de pantalla) se indica que la máquina virtual Dalvik (la de Android) ha iniciado el recolector para la aplicación ya que la pila de la misma está bastante llena. Se indica que se liberaron 6560K de memoria, que ahora se dispone de un 39% de espacio libre en la pila (29237K es lo que ocupan los objetos y 47331K el tamaño total de la pila) y finalmente los tiempos de espera: 2ms estuvo sin ejecutarse la el proceso de la aplicación esperando que el GC actuase y, tras su limpieza, se tardaron 29ms más en continuar con su ejecución. Las unidades son K, supongo que Kibibytes pero no puedo asegurarlo.
La siguiente ejecución del GC se remite a una de tipo GC_FOR_ALLOC, en la cual la aplicación necesita que se le asigne más memoria pues no llega con la que hay libre en la pila. Se libera una poca pero no es suficiente. Al reclamar la aplicación más memoria con la pila llena, se lanza de nuevo el GC con un tipo de llamada GC_BEFORE_OOM, el cual consigue liberar un 40% de la pila pero no es suficiente: A continuación se produce un OutOfMemoryError, pues la aplicación requería más espacio nuevo del que ha sido liberado.
Tras seguir las trazas de depuración, justo se da con la posible causa del desbordamiento de pila de la app: un ByteArrayOutputStream ocupó excesiva memoria al devolver un vector de bytes. |
Llegados a este punto se podría pensar en ampliar explícitamente el tamaño del heap para la aplicación. Aunque es posible, es totalmente desaconsejable hacerlo, ya que el tamaño seguiría realmente variando en función del dispositivo (Android se encarga de hacerlo a partir del tamaño anteriormente preestablecido) y tan sólo se conseguirían más ejecuciones del recolector de basura lo que ralentizaría la ejecución de los procesos asociados y una disminución del rendimiento.
A partir de los mensajes de log es posible deducir hasta qué punto está bien implementada (o no) una aplicación a pesar del hecho de que aparentemente funcione correctamente. Por ejemplo: la mayoría de mensajes pueden ser de tipo GC_CONCURRENT y el heap, por tanto, nunca llega a llenarse. La aplicación finaliza su ejecución correctamente pero ha estado trabajando al límite, pues la gestión que hace del uso de memoria está mal optimizada. Si la aplicación corriera sobre un dispositivo con menos memoria RAM disponible, o en el mismo dispositivo con más procesos en ejecución, seguramente se recibirían mensajes de tipo GC_FOR_ALLOC pudiendo llegar al punto de un GC_BEFORE_OOM o, lo que es peor, a que nuestra aplicación finalice su ejecución por petición del SO. Por tanto, todo se resume en la máxima de «ser eficientes con la memoria maximiza la posibilidad de supervivencia de la ejecución de la aplicación en cualquier entorno multi-tarea Android».
Como consejo adicional, indicar que es sumamente útil crear un filtro en el LogCat para depurar asuntos de memoria relacionados con el recolector de basura. De este modo, seleccionando sólo el filtro tendríamos todas las notificaciones del GC sin tener que buscarlas a mano o con Ctrl+f. Para crear el filtro, en la pestaña LogCat de Eclipse, pulsar sobre el botón cruz verde Add a new logcat filter y rellenar el formulario.
Crear un filtro de LogCat para visualizar cómodamente las salidas del GC agiliza la depuración. |
SUPERVISAR LA MEMORIA DESDE DDMS.
La perspectiva DDMS (Dalvik Debug Monitor Server) en Eclipse ofrece dos pestañas de valiosa utilidad: Heap y Allocation Tracker.
En la primera de ellas, se nos muestra en tiempo real el estado de la pila durante la ejecución de la aplicación como cuánta esta libre y ocupada, porcentajes de uso, tipos de elementos en memoria, etc… Incluso se proporciona el botón Cause GC el cual realiza una invocación explícita sobre el recolector de basura, algo similar a invocar la función gc() desde el código.
Para que la monitorización del heap funcione es necesario que en la pestaña Devices se despliegue el menú del dispositivo de prueba escogido, luego seleccionar el proceso a monitorizar y pulsar sobre el botón verde superior Update Heap. Importante resaltar que la información sólo se mostrará en pantalla durante la ejecución del proceso, desapareciendo cuando éste finalice.
Monitorización de la pila de un proceso mediante la perspectiva DDMS en Eclipse. |
La segunda pestaña hace referencia al seguimiento de las asignaciones de memoria en la pila. De este modo se facilita la labor de localizar qué objetos son los problemáticos a la hora de ocupar memoria para poder optimizar y actuar en consecuencia. En la pestaña Allocation Tracker > Start Tracking (con el proceso de la aplicación seleccionado en la sección Devices) y pulsar en Get Allocations para conseguir las asignaciones de memoria en un momento dado. Al igual que con la monitorización de la pila, una vez finalice la ejecución del proceso se borrará toda la información de la pantalla.
Monitorización de las asignaciones de memoria mediante DDMS. |
En caso de que estas dos monitorizaciones no fuesen suficientes, siempre se puede realizar un volcado de la pila a un fichero HPROF, el cual contendría toda la información de la pila en el momento del volcado así como su contenido desglosado junto a todo tipos de datos para una depuración más exhaustiva. Para visualizar el contenido del fichero HPROF es necesario el uso de Eclipse MAT.
Conclusiones.
La optimización del uso que hace una aplicación de la memoria principal de un sistema puede considerarse una disciplina aparte. Son requisitos indispensables una elevada experiencia y conocimientos a todos los niveles, tanto de APIs, lenguajes de programación, sistema operativos y hardware.
En aplicaciones de escritorio o pequeñas apps de gestión para dispositivos móviles, las optimizaciones pueden no ser necesarias. Aún así, al final todo desarrollador acabará enfrentándose a una optimización seria de memoria antes o después. Para cuando eso suceda, espero que este humilde artículo les sirva de guía y les ayude en la difícil labor de optimización.
Referencias web:
- http://developer.android.com/training/articles/memory.html
- http://java-performance.info/overview-of-memory-saving-techniques-java/
- http://java-performance.info/memory-consumption-of-java-data-types-1/
- https://sites.google.com/site/pyximanew/blog/androidunderstandingddmslogcatmemoryoutputmessages
- http://stackoverflow.com/questions/7774723/outofmemory-error-though-free-memory-is-available
- http://stackoverflow.com/questions/4976566/what-do-gc-for-malloc-gc-explicit-and-other-gc-mean-in-android-logcat