Fuite de mémoire (memory leak) — situation dans laquelle une application ne libère pas la mémoire occupée par des objets qui ne sont plus nécessaires. Dans le développement mobile, c'est particulièrement critique : le heap limité et l'absence de swap entraînent une OutOfMemoryError et un plantage de l'application. Selon Purdue University (2022), 35% des applications Android sur Google Play contiennent au moins une fuite de mémoire. Analysons les scénarios typiques, les outils de diagnostic et les méthodes de correction.
Points clés
Fuite de mémoire — situation dans laquelle la mémoire allouée n'est pas restituée au système après que l'objet n'est plus nécessaire au programme. Le ramasse-miettes considère cet objet comme vivant car une chaîne de références active depuis une GC Root pointe vers lui.
En Java/Kotlin, le ramasse-miettes fonctionne automatiquement, mais il ne peut pas déterminer qu'un objet est logiquement inutile s'il existe une référence technique vers lui. Le développeur doit rompre explicitement les connexions inutiles. En Swift/Objective-C, ARC compte automatiquement les références, mais les cycles de rétention bloquent la diminution du compteur.
Le principal danger des fuites est l'effet cumulatif. Chaque fuite consomme une petite quantité de mémoire, mais avec des transitions répétées entre écrans (rotations d'écran, ouverture/fermeture d'Activities), les fuites s'accumulent jusqu'à épuiser la limite du heap.
Fuite — l'objet est inaccessible au code mais n'est pas supprimé par le GC. Gonflement — l'objet est logiquement nécessaire mais stocké en quantité excessive. Exemple de gonflement : un cache d'images de 100 Mo avec un ensemble de travail de 30 Mo. Les deux problèmes mènent à OOM, mais les causes et les méthodes de traitement diffèrent.
ART (Android Runtime) utilise le ramasse-miettes générationnel avec compactage concurrent. La mémoire est divisée en génération jeune (Young), génération âgée (Old) et objets volumineux (Large). Les objets qui survivent à plusieurs cycles GC sont déplacés vers la génération Old, où la collecte a lieu moins fréquemment — cela accélère les cycles normaux.
Le GC démarre lorsque le heap atteint un certain seuil d'occupation (généralement 75-85%). Pendant le GC, tous les threads de l'application sont suspendus (STW — Stop The World). Plus il y a d'objets vivants, plus la pause est longue. Les fuites augmentent le nombre d'objets vivants, allongeant les pauses du GC.
Le collecteur détermine les objets vivants en parcourant le graphe à partir des GC Roots : champs statiques, variables de pile des threads actifs, références JNI. Tout objet atteignable via des références depuis ces racines est considéré comme vivant — même si le développeur sait qu'il n'est plus nécessaire.
// Exemple : collection statique comme GC Root — fuite 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 n'empêche pas le GC — comportement correct
}
}
WeakReference résout le problème : le GC ignore les références faibles lors de la détermination des objets vivants. S'il ne reste que des références faibles vers un objet, il sera collecté lors du cycle GC le plus proche.
Activity Context — le scénario de fuite le plus massif dans Android. Si un singleton, un champ statique ou un service de longue durée stocke une référence à un Activity Context, toute l'Activity avec toutes ses vues ne peut pas être collectée par le GC. Solution : utilisez Application Context pour les objets de longue durée.
Handler et messages envoyés — Handler.postDelayed(runnable, delay) place un message dans la file d'attente du Main Looper. Si l'Activity est détruite avant l'expiration du délai, le message est toujours dans la file et maintient une référence via Runnable → classe anonyme → classe externe (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) // obligatoire : vider la file d'attente
super.onPause()
}
}
Classes internes — une classe interne non statique a une référence implicite à l'instance de la classe externe. Si la classe externe est une Activity et que la classe interne est passée à l'extérieur (par exemple, à RecyclerView.Adapter), l'Activity ne peut pas être collectée.
Android Studio Memory Profiler — outil intégré de surveillance du heap en temps réel. Affiche un graphique de la mémoire utilisée, le nombre d'allocations et d'objets par type. Permet d'enregistrer un heap dump et de l'exporter au format HPROF pour analyse dans MAT.
Eclipse MAT (Memory Analyzer Tool) — analyseur de bureau de heap dump. Construit automatiquement des rapports Leak Suspects, mettant en évidence les objets avec le plus grand retained size et suggérant la chaîne GC Root suspectée pour chaque objet suspect.
Xcode Memory Graph Debugger — pour iOS. Suspend l'application et visualise le graphe d'objets. Les cycles de rétention sont surlignés en rouge ; vous pouvez cliquer sur n'importe quel objet pour voir son retain count et ses références.
| Outil | Capacités | Complexité |
|---|---|---|
| Memory Profiler | Graphique en temps réel, heap dump, Object Allocation Tracking | Faible |
| Eclipse MAT | Dominator tree, Leak Suspects, requêtes OQL | Moyenne |
| LeakCanary | Détection automatique, trace de fuite dans la notification | Minimale |
| Xcode Memory Graph | Graphe visuel des cycles de rétention, liste d'objets vivants | Faible |
Selon le Uber Engineering Blog, l'intégration du profilage automatique de la mémoire (LeakCanary + analyse de heap dump) dans le pipeline CI/CD réduit les incidents liés à la mémoire en production de 60% en 3 mois.
Remplacer Context — si un objet survit à l'Activity, utilisez applicationContext. Tous les objets de longue durée (singletons, repositories, helpers de base de données) doivent recevoir Application Context, pas Activity Context. Exception : les composants d'interface utilisateur qui ont besoin d'accéder au thème ou aux ressources spécifiques à l'Activity.
Composants sensibles au cycle de vie — l'utilisation de LifecycleObserver, DefaultLifecycleObserver ou d'extensions réactives annule automatiquement les abonnements à onDestroy. Android Jetpack fournit lifecycleScope et viewModelScope, qui sont nettoyés par l'événement de cycle de vie correspondant.
Classe interne statique — si la classe interne n'a pas besoin d'accéder aux champs de la classe externe, rendez-la static. Une classe interne statique n'a pas de référence implicite à la classe externe. Si l'accès est nécessaire, utilisez WeakReference pour la référence explicite.
class MyActivity : AppCompatActivity() {
// ❌ Classe interne non statique — référence implicite à MyActivity
inner class BadListener : SomeListener {
override fun onEvent() { /*...*/ }
}
// ✅ Classe interne statique — pas de référence implicite
class GoodListener(private val activityRef: WeakReference<MyActivity>) : SomeListener {
override fun onEvent() { /*...*/ }
}
}
Sous iOS, utilisez des listes de capture : [weak self] dans les fermetures qui peuvent survivre à leur créateur. Pour les délégués, utilisez des références faibles (weak var delegate). Pour les fermetures dont l'appel est garanti uniquement pendant la durée de vie de self, [unowned self] peut être utilisé, mais avec prudence — l'accès à un objet désalloué provoquera un plantage.
Foire aux questions
Sous Android, effectuez plusieurs transitions entre écrans (Activity A → B → A → B) et vérifiez adb shell dumpsys meminfo package_name. Si le Total PSS augmente régulièrement et ne revient pas à la valeur d'origine — il y a une fuite. Sous iOS, de même : utilisez Debug Memory Graph dans Xcode pour une inspection visuelle.
Oui, si le CoroutineScope n'est pas annulé lors de la destruction du composant. Une coroutine lancée dans GlobalScope continue de s'exécuter même après finish() de l'Activity. Solution : utilisez viewModelScope (annulé dans onCleared) ou lifecycleScope (annulé dans onDestroy). Pour les scopes personnalisés, créez des scopes sensibles au cycle de vie via LifecycleOwner.
Bitmap stocke les données de pixels dans le heap natif, pas dans le heap Java. Cela signifie que le GC Java ne voit pas la taille réelle du Bitmap. Si Bitmap n'est pas recyclé avec recycle() ou si la référence n'est pas mise à null, la mémoire native n'est pas libérée. Utilisez BitmapFactory avec inSampleSize pour charger des copies réduites et Glide/Coil pour la gestion automatique du cache.
Un champ statique est une GC Root. Il vit tant que la classe est chargée (sous Android — tant que le Process est vivant). Si un champ statique référence une Activity, Bitmap, View ou tout autre objet lourd, cet objet ne sera jamais collecté par le GC. Un champ statique est une référence éternelle. Solution : stockez uniquement WeakReference ou mettez le champ statique à null dans onDestroy.
ARC libère automatiquement les objets lorsque le compteur de références fortes tombe à zéro. Un cycle de rétention est la seule façon de fuir sous ARC. Utilisez toujours weak pour les références parent→enfant où l'enfant peut survivre au parent (délégués, sources de données). Pour les fermetures, utilisez la liste de capture [weak self] et vérifiez que self n'est pas nil à l'intérieur de la fermeture.
Résumé
Nous développerons une application mobile clé en main
IT Sectr crée des applications iOS et Android pour les startups et les entreprises depuis 2017. Nous vous conseillerons et vous proposerons la meilleure solution.