Conceptos fundamentales

Para comprender el uso de memoria en Android, debes analizar el sistema desde varias perspectivas diferentes, que van desde objetos de Java de alto nivel hasta páginas de kernel de bajo nivel.

El precipicio de rendimiento

El uso de memoria en sí no es algo bueno ni malo; lo que importa es para qué la usas. Sin embargo, a medida que te acercas al límite de memoria disponible en un dispositivo, con el tiempo, "caes en un abismo de rendimiento".

Un gráfico que muestra un rendimiento estable hasta un umbral de memoria determinado, seguido de una disminución pronunciada a medida que el sistema comienza a realizar un intercambio excesivo de páginas y a cerrar procesos

Cuando estás lejos del límite, agregar una pequeña cantidad de uso de memoria (p.ej., 50 MB) puede no tener un impacto perceptible en el rendimiento. Sin embargo, se alcanza un límite cuando se agota la RAM disponible. En este punto, el sistema operativo debe comenzar a paginar la memoria y cerrar procesos para liberar espacio. Más allá de este límite, incluso un pequeño aumento en el uso de memoria puede provocar una "sobrecarga" significativa, en la que el dispositivo deja de responder o parece reiniciarse porque se cierran procesos críticos.

La pila de Android

Cada capa de la pila de Android tiene su propia vista de la memoria:

Un diagrama de bloques de la pila de memoria de Android, que muestra las aplicaciones de Java en la parte superior, ART debajo de ellas y el kernel de Linux con páginas físicas en la parte inferior

  1. Aplicaciones (Java/Kotlin): Los desarrolladores ven principalmente objetos Java asignados en el montón de Java.
  2. Android Runtime (ART): ART administra el montón de Java o Kotlin con páginas de memoria virtual del kernel. Anteriormente conocido como Dalvik (a veces, los términos se usan indistintamente).
  3. Kernel de Linux: El kernel ve la memoria en términos de páginas físicas y virtuales. Tradicionalmente, son de 4 KB, pero Android también admite tamaños de página de 16 KB. El kernel no sabe nada sobre los "objetos de Java".

Si deseas optimizar la memoria, debes hacerlo dentro de tu propia capa (p.ej., cargando menos mapas de bits) o comprender las capas inferiores para ver cómo se traducen tus asignaciones de alto nivel en el uso de páginas físicas.

Tipos de memoria: anónima y respaldada por archivos

Antes de profundizar en cómo el SO recupera memoria, debes comprender las dos categorías fundamentales de páginas de memoria en Linux:

  1. Memoria anónima (anon): Es la memoria que no está respaldada por un archivo en el almacenamiento. Esto incluye la asignación de memoria por malloc de C/C++ (como el asignador Scudo) y la asignación de memoria para objetos Java en el montón de Java/Kotlin. Como esta memoria no tiene un archivo de origen al que volver, el SO debe mantenerla en la RAM o comprimirla y cambiarla a ZRAM cuando esté bajo presión.
  2. Memoria respaldada por archivos (file): Es la memoria que se asigna directamente desde un archivo en el almacenamiento. Esto incluye código ejecutable (archivos DEX, bibliotecas nativas .so) y fuentes.

Archivos asignados a la memoria (mmap)

Android usa mmap para asignar memoria anónima y respaldada por archivos al espacio de direcciones de un proceso.

Cuando se trata de memoria respaldada por archivos, las páginas se clasifican en las siguientes categorías:

  • Memoria limpia: Son las páginas asignadas desde un archivo que no se modificaron. Si el sistema necesita más memoria, el kernel puede simplemente descartar estas páginas, ya que se pueden volver a cargar directamente desde el archivo en el almacenamiento más adelante.
  • Memoria sucia: Son las páginas que modificó el proceso. No se pueden descartar, se comportan como memoria anónima y se deben mantener en la RAM o mover a la ZRAM.

Android prefiere los formatos de archivo (como DEX) que se adaptan a la asignación de memoria, lo que permite que el sistema recupere fácilmente la memoria limpia cuando hay presión. Debido a que la memoria anónima y no sincronizada no se puede descartar, la memoria privada no sincronizada o anónima alta es la causa principal de la hiperpaginación del sistema y las intervenciones del eliminador de memoria insuficiente (LMK). Si tu aplicación usa mucha memoria privada modificada, contribuyes directamente al límite de rendimiento.

El modelo de proceso Zygote

Android minimiza el costo de iniciar aplicaciones nuevas con un proceso llamado Zygote.

  1. Zygote se inicia en el arranque y precarga clases y recursos comunes del framework en su memoria. Desde la perspectiva del kernel, se convierte en memoria sucia anónima, pero es exclusiva del proceso de Zygote.
  2. Cuando se inicia una aplicación nueva, el sistema bifurca el proceso Zygote.
  3. El nuevo proceso secundario hereda la memoria del Zygote con una asignación compartida con semántica de copia en escritura (COW).

Diagrama que ilustra el proceso Zygote que comparte páginas de memoria con procesos secundarios recién bifurcados con la semántica de copia en escritura

Siempre que el proceso secundario solo lea la memoria heredada de Zygote, las páginas de memoria física se compartirán entre todos los procesos. Cuando un proceso secundario modifica una página compartida, el kernel crea de forma transparente una copia privada de esa página para el proceso. Este modelo permite que muchos procesos compartan una gran parte de su memoria, en especial el código y los recursos del framework, lo que reduce significativamente el espacio en memoria general del sistema.

RSS, PSS, USS y memcg

Debido a que la memoria se comparte en gran medida entre los procesos, principalmente a través del modelo de Zygote, existen varias formas de contabilizar el uso de memoria de un proceso. RSS, PSS y USS cuentan las páginas que se asignan al espacio de direcciones de un proceso:

Diagrama que visualiza la diferencia entre el tamaño del conjunto residente (RSS), el tamaño del conjunto proporcional (PSS) y el tamaño del conjunto único (USS), y muestra cómo se contabilizan las páginas de memoria compartida

  • RSS (tamaño del conjunto residente): Es la cantidad total de páginas que tiene el proceso en la RAM. Esto sobreestima el uso, ya que cuenta las páginas compartidas varias veces (una vez por cada proceso que las comparte).
  • PSS (tamaño del conjunto proporcional): Es la cantidad total de memoria exclusiva del proceso, más su parte proporcional de la memoria compartida. Si 5 procesos comparten una página, se cobra a cada proceso 1/5 de esa página.
  • USS (tamaño del conjunto único): Es la cantidad de memoria que es única para el proceso. Esta es la memoria que se devolvería al sistema si se finalizara el proceso.

memcg: la contabilidad propia del kernel

El kernel también mantiene su propio total acumulado de la memoria que usa cada proceso, independientemente de si esa memoria está asignada o no. En Android, cada proceso de la app pertenece a su propio grupo de control de memoria (memcg), anidado dentro de un memcg para toda la app (consulta La jerarquía de memcg de Android). Cada vez que se asigna una página de memoria, el kernel la carga en el memcg del proceso que la asignó. El cargo total se encuentra en el archivo memory.current del memcg.

La contabilización de memcg difiere de RSS, PSS y USS en algunos aspectos importantes:

  • Cada página se cobra exactamente una vez al memcg del proceso que la llevó por primera vez a la memoria. El cargo permanece allí, incluso si otros procesos asignan la misma página más adelante. Las páginas que Zygote precarga se cobran a Zygote, no a tu app. Cuando tu app escribe en una de estas páginas, la copia privada se cobra a tu app.
  • Incluye memoria que las métricas de la tabla de páginas no pueden ver. memory.current cuenta las páginas de archivos que lee tu app sin asignarlas (por ejemplo, las páginas de bases de datos que se leen con read o pread).
  • Realiza un seguimiento del intercambio por separado. La memoria que se comprime en ZRAM sale de memory.current y entra en memory.swap.current, por lo que puedes ver ambas sin adivinar.
  • La lectura es económica. Los contadores siempre están actualizados, y leerlos no bloquea ni analiza el espacio de direcciones de tu app.
  • Es lo que limita el sistema. Los límites de memoria y los presupuestos de memoria que Android aplica a las apps actúan sobre la carga de memcg.

Por lo general, la memoria de gráficos, como los búferes detrás de los mapas de bits de hardware, no se carga en el memcg de tu app.

¿Qué métricas usar y cuándo?

Elegir la métrica de memoria adecuada depende de lo que intentes medir y del entorno en el que lo hagas. En dispositivos que ejecutan Android 17 QPR2 (nivel de API 37.2) y versiones posteriores, comienza con memcg cuando hagas un seguimiento de tu propia app.

Métrica Ideal para Fortaleza clave Desventaja principal
memcg Realizar un seguimiento de una app individual a lo largo del tiempo y compararla con los presupuestos Cuenta cada página una vez, incluye la caché de páginas sin asignar y hace un seguimiento del intercambio por separado. Lectura económica No está disponible en todos los dispositivos anteriores a Android 17 QPR2. Las páginas compartidas se cobran al proceso que las tocó primero.
PSS Instantáneas de memoria de todo el sistema Proporciona con precisión la memoria compartida. La suma de PSS equivale a la memoria total utilizada. Fluctúa según otros procesos en ejecución. No es recomendable para comparar una app específica a lo largo del tiempo.
RSS Seguimiento de una app individual a lo largo del tiempo, telemetría de campo Estable, independiente de otros procesos. Son rápidos y económicos de recopilar. Sobreestima el uso total porque cuenta la memoria compartida varias veces.
USS Evalúa el impacto de finalizar un proceso Muestra exactamente cuánta memoria se liberaría si se cerrara la app. Ignora por completo la memoria compartida.

memcg (carga del grupo de control de memoria)

  • Ideal para: Hacer un seguimiento del uso de memoria de una app individual a lo largo del tiempo, en los recorridos críticos del usuario (CUJ) o entre versiones de la app, en dispositivos que ejecutan Android 17 QPR2 o versiones posteriores. También es la métrica que debes observar cuando calibras los presupuestos de memoria, ya que es el número que limitan los presupuestos.
  • Por qué: Al igual que el RSS, la carga de memcg no cambia cuando otras apps comienzan o dejan de compartir páginas con tu app. A diferencia del RSS, nunca cuenta una página dos veces, por lo que puedes sumar la carga de todos los procesos en ejecución de tu app. El memcg de toda la app puede ser mayor que esa suma, ya que mantiene las páginas de archivos almacenadas en caché de los procesos que se cerraron hasta que el kernel las reclama. También cuenta la memoria que RSS, PSS y USS omiten, como la caché de archivos no asignados. Leerlo es económico, ya que el kernel actualiza los contadores a medida que carga las páginas.
  • Cómo leerlo: Lee memory.current, memory.swap.current y memory.stat en el directorio memcg de un proceso (consulta las estadísticas de memcg). En Android 18 y versiones posteriores, dumpsys meminfo también los imprime. Desde tu app, usa MemoryBudgetManager.
  • Prácticas recomendadas para la medición de campos: Por los mismos motivos que se explican en RSS, haz un seguimiento de la memoria anónima + el intercambio: el campo anon de memory.stat más memory.swap.current. El campo file de memory.stat incluye páginas de archivos que tu app leyó una vez y que el kernel aún no reclamó, por lo que memory.current puede permanecer alto durante un tiempo después de que tu app deje de usar esa memoria.
  • Cuándo tener cuidado: Debido a que las páginas se cargan en el proceso que las tocó primero, se le cobra a tu app por una página de archivo compartido (como una fuente o una biblioteca compartida) que lee antes que cualquier otro proceso. Por el contrario, es posible que se cobre a otro proceso por las páginas que también usa tu app. Espera pequeñas diferencias entre los dispositivos y las ejecuciones de la memoria respaldada por archivos, y confía en anon y el intercambio cuando realices comparaciones.

PSS (tamaño del conjunto proporcional)

  • Ideal para: Tomar una instantánea completa de la memoria del sistema de un dispositivo determinado en un momento específico.
  • Por qué: El PSS tiene la propiedad matemática útil de que la suma del PSS para todos los procesos en ejecución es igual a la memoria total que usan los procesos en el sistema. Asigna perfectamente la memoria compartida sin registros duplicados.
  • Cuándo evitarlo: No debes usar PSS para comparar un proceso específico en dos instantáneas diferentes, en momentos diferentes o en dispositivos diferentes. Debido a que el PSS depende de cuántos otros procesos comparten páginas con tu proceso, el PSS de tu app puede fluctuar incluso si el comportamiento real de tu app no cambia en absoluto.

RSS (tamaño del conjunto residente)

  • Ideal para: Hacer un seguimiento del uso de memoria de una app individual a lo largo del tiempo, en diferentes recorridos críticos del usuario (CUJ) o comparar entre versiones de la app. También es la opción más práctica para la telemetría de campo.
  • Por qué: El RSS es más estable que el PSS. Te indica la memoria asignada a tu proceso, independientemente de lo que hagan otras apps. Además, la RSS es económica de recopilar. Para medir el PSS o el USS, se deben analizar estructuras complejas de administración de la memoria del kernel y obtener bloqueos, lo que puede causar problemas de rendimiento del sistema (como bloqueos de la IU) si se recopilan con demasiada frecuencia.
  • Prácticas recomendadas para la medición de campo: Cuando recopiles datos de dispositivos en el campo, suele ser mejor medir el RSS anónimo + el intercambio (ZRAM).
    • ¿Por qué solo de forma anónima? El kernel puede expulsar las páginas respaldadas por archivos (como código, recursos de la app, fuentes o cualquier otro archivo asignado a la memoria) en cualquier momento debido a la presión de la caché de páginas en todo el sistema. Incluir páginas de archivos en las métricas para hacer un seguimiento de una app en particular genera ruido a partir de las decisiones de administración de la memoria del SO que pueden deberse a la presión de memoria de otras apps o del sistema. Por el contrario, la memoria anónima (como los montones de Java y nativos) se controla directamente con tu app.
    • ¿Por qué agregar intercambio? Cuando la memoria esté bajo presión, el SO comprimirá las páginas anónimas y las moverá a ZRAM (intercambio). Si solo mides la memoria anónima residente, es posible que tus métricas muestren de forma errónea una "disminución" en el uso de memoria simplemente porque el sistema estaba bajo presión y se intercambiaron tus páginas. Agregar la memoria intercambiada garantiza que tengas en cuenta toda la memoria anónima que asignó tu app, ya sea que resida actualmente en la RAM o en la ZRAM.
    • Prefiere memcg cuando puedas. En los dispositivos que ejecutan Android 17 QPR2 y versiones posteriores, anon en memory.stat más memory.swap.current te brinda una medición similar que omite las páginas anónimas compartidas con Zygote y es más económica de recopilar (consulta memcg).

USS (tamaño del conjunto único)

  • Uso ideal: Determinar el impacto inmediato en el sistema de finalizar un proceso.
  • Por qué: El USS representa con precisión la cantidad exacta de memoria que se recuperaría y se devolvería al sistema de inmediato si se finalizara el proceso (por ejemplo, con el daemon Low Memory Killer, lmkd).
  • Cómo usarla: Esta métrica es muy valiosa cuando se evalúan procesos persistentes o servicios en segundo plano. Si evalúas el costo de un proceso en segundo plano en todo el sistema, el USS te indica exactamente cuánta memoria está sacrificando el sistema solo para mantener activo ese proceso específico.

↑ Arriba | Herramientas →