Fuga de memoria (memory leak) — situación en la que una aplicación no libera la memoria ocupada por objetos que ya no son necesarios. En el desarrollo móvil esto es especialmente crítico: el heap limitado y la ausencia de swap provocan OutOfMemoryError y el cierre de la aplicación. Según Purdue University (2022), el 35% de las aplicaciones Android en Google Play contienen al menos una fuga de memoria. Analicemos escenarios típicos, herramientas de diagnóstico y métodos de corrección.
Puntos clave
Fuga de memoria es una situación en la que la memoria asignada no se devuelve al sistema después de que el objeto ya no es necesario para el programa. El recolector de basura considera que dicho objeto está vivo porque existe una cadena de referencias activa desde un GC Root que apunta a él.
En Java/Kotlin, el recolector de basura funciona automáticamente, pero no puede determinar que un objeto es lógicamente innecesario si existe una referencia técnica hacia él. El desarrollador debe romper explícitamente las conexiones innecesarias. En Swift/Objective-C, ARC cuenta las referencias automáticamente, pero los retain cycles bloquean el decremento del contador.
El peligro principal de las fugas es el efecto acumulativo. Cada fuga consume una pequeña cantidad de memoria, pero con las transiciones repetidas entre pantallas (rotaciones de pantalla, apertura/cierre de Activities) las fugas se acumulan hasta agotar el límite del heap.
Fuga — el objeto es inaccesible para el código pero no es eliminado por el GC. Hinchazón — el objeto es lógicamente necesario pero se almacena en cantidad excesiva. Ejemplo de hinchazón: una caché de imágenes de 100 MB con un conjunto de trabajo de 30 MB. Ambos problemas llevan a OOM, pero las causas y los métodos de tratamiento son diferentes.
ART (Android Runtime) utiliza recolección de basura generacional con compactación concurrente. La memoria se divide en generación joven (Young), generación vieja (Old) y objetos grandes (Large). Los objetos que sobreviven varios ciclos de GC se mueven a la generación Old, donde la recolección ocurre con menos frecuencia — esto acelera los ciclos normales.
El GC se inicia cuando el heap alcanza un cierto umbral de ocupación (normalmente 75-85%). Durante el GC, todos los hilos de la aplicación se pausan (STW — Stop The World). Cuantos más objetos vivos, más larga es la pausa. Las fugas aumentan la cantidad de objetos vivos, alargando las pausas del GC.
El recolector determina los objetos vivos recorriendo el grafo desde las GC Roots: campos estáticos, variables de pila de hilos activos, referencias JNI. Cualquier objeto alcanzable mediante referencias desde estas raíces se considera vivo — incluso si el desarrollador sabe que ya no es necesario.
// Ejemplo: colección estática como GC Root — fuga permanente
object GlobalHolder {
val listeners = mutableListOf<WeakReference<Any>>()
}
class LeakingFragment : Fragment() {
override fun onCreate(savedInstanceState: Bundle ?= null) {
super.onCreate(savedInstanceState)
GlobalHolder.listeners.add(WeakReference(this))
// WeakReference no impide el GC — comportamiento correcto
}
}
WeakReference resuelve el problema: el GC ignora las referencias débiles al determinar objetos vivos. Si solo quedan referencias débiles a un objeto, será recolectado en el ciclo de GC más cercano.
Activity Context — el escenario de fuga más masivo en Android. Si un singleton, un campo estático o un servicio de larga duración almacena una referencia a un Activity Context, toda la Activity con todas sus Views no puede ser recolectada por el GC. Solución: use Application Context para objetos de larga duración.
Handler y mensajes enviados — Handler.postDelayed(runnable, delay) coloca un mensaje en la cola de Main Looper. Si la Activity se destruye antes de que expire el retraso, el mensaje sigue en la cola y mantiene una referencia a través de Runnable → clase anónima → clase externa (Activity).
class SafeActivity : AppCompatActivity() {
private val mainHandler = Handler(Looper.getMainLooper())
private val callback = Runnable { /* update UI */ }
override fun onResume() {
super.onResume()
mainHandler.postDelayed(callback, 5000)
}
override fun onPause() {
mainHandler.removeCallbacks(callback) // obligatorio: limpiar cola
super.onPause()
}
}
Clases internas — una clase interna no estática tiene una referencia implícita a la instancia de la clase externa. Si la clase externa es una Activity y la clase interna se pasa a algún lugar externo (por ejemplo, a RecyclerView.Adapter), la Activity no puede ser recolectada.
Android Studio Memory Profiler — herramienta integrada para monitorización del heap en tiempo real. Muestra un gráfico de memoria ocupada, cantidad de asignaciones y objetos por tipo. Permite grabar un heap dump y exportarlo en formato HPROF para su análisis en MAT.
Eclipse MAT (Memory Analyzer Tool) — analizador de escritorio de heap dumps. Construye automáticamente informes de Leak Suspects, que destacan los objetos con mayor retained size y sugieren la posible cadena de GC Root para cada objeto sospechoso.
Xcode Memory Graph Debugger — para iOS. Pausa la aplicación y visualiza el grafo de objetos. Los retain cycles se resaltan en rojo; se puede hacer clic en cualquier objeto para ver su retain count y referencias.
| Herramienta | Capacidades | Complejidad |
|---|---|---|
| Memory Profiler | Gráfico en tiempo real, heap dump, Object Allocation Tracking | Baja |
| Eclipse MAT | Dominator tree, Leak Suspects, consultas OQL | Media |
| LeakCanary | Detección automática, trace de fuga en notificación | Mínima |
| Xcode Memory Graph | Grafo visual de retain cycles, lista de objetos vivos | Baja |
Según el Uber Engineering Blog, la integración del perfilado automático de memoria (LeakCanary + análisis de heap dump) en el pipeline CI/CD reduce los incidentes relacionados con memoria en producción en un 60% en 3 meses.
Reemplazar Context — si un objeto sobrevive a la Activity, use applicationContext. Todos los objetos de larga duración (singletons, repositorios, helpers de base de datos) deben recibir Application Context, no Activity Context. Excepción: componentes UI que necesitan acceso al tema o recursos específicos de la Activity.
Componentes conscientes del ciclo de vida — el uso de LifecycleObserver, DefaultLifecycleObserver o extensiones reactivas cancela automáticamente las suscripciones en onDestroy. Android Jetpack proporciona lifecycleScope y viewModelScope, que se limpian con el evento de ciclo de vida correspondiente.
Clase interna estática — si la clase interna no necesita acceso a los campos de la clase externa, hágalo static. Una clase interna estática no tiene referencia implícita a la clase externa. Si se necesita acceso, use WeakReference para la referencia explícita.
class MyActivity : AppCompatActivity() {
// ❌ Clase interna no estática — referencia implícita a MyActivity
inner class BadListener : SomeListener {
override fun onEvent() { /*...*/ }
}
// ✅ Clase interna estática — sin referencia implícita
class GoodListener(private val activityRef: WeakReference<MyActivity>) : SomeListener {
override fun onEvent() { /*...*/ }
}
}
En iOS, use listas de captura: [weak self] en clausuras que puedan sobrevivir a su creador. Para delegados, use referencias débiles (weak var delegate). Para clausuras que se garantiza que solo se invocarán durante la vida de self, se puede usar [unowned self], pero con cuidado — acceder a un objeto desasignado provocará un crash.
Preguntas frecuentes
En Android, realice varias transiciones entre pantallas (Activity A → B → A → B) y verifique adb shell dumpsys meminfo package_name. Si el Total PSS crece de forma estable y no vuelve al valor original — hay una fuga. En iOS, de forma similar: use Debug Memory Graph en Xcode para inspección visual.
Sí, si el CoroutineScope no se cancela al destruirse el componente. Una corrutina lanzada en GlobalScope continúa ejecutándose incluso después de finish() de la Activity. Solución: use viewModelScope (se cancela en onCleared) o lifecycleScope (se cancela en onDestroy). Para scopes personalizados, cree scopes conscientes del ciclo de vida mediante LifecycleOwner.
Bitmap almacena datos de píxeles en el heap nativo, no en el heap de Java. Esto significa que el GC de Java no ve el tamaño real del Bitmap. Si no se llama a recycle() en el Bitmap o no se anula la referencia, la memoria nativa no se libera. Use BitmapFactory con inSampleSize para cargar copias reducidas y Glide/Coil para la gestión automática de caché.
Un campo estático es una GC Root. Vive mientras la clase esté cargada (en Android — mientras el Process esté vivo). Si un campo estático referencia una Activity, Bitmap, View o cualquier otro objeto pesado, ese objeto nunca será recolectado por el GC. Un campo estático es una referencia eterna. Solución: almacene solo WeakReference o anule el campo estático en onDestroy.
ARC libera objetos automáticamente cuando el contador de referencias fuertes llega a cero. Un retain cycle es la única forma de fuga con ARC. Use siempre weak para referencias padre→hijo donde el hijo pueda sobrevivir al padre (delegados, data sources). Para clausuras, use la lista de captura [weak self] y verifique que self no sea nil dentro de la clausura.
Resumen
Desarrollaremos una aplicación móvil llave en mano
IT Sectr crea aplicaciones para iOS y Android para startups y empresas desde 2017. Le asesoraremos y le propondremos la mejor solución.
Lea también