Tutoriel sur les fuites de mémoire sous Android

Android2ee

Florian Fournier

Il est important au sein d'une application de toujours vérifier que nous n'avons pas de fuites mémoire générées. Pour cela nous avons quelques outils sous Android.

N'hésitez pas à commenter cet article ! Commentez

- Titre : Tutoriel sur les fuites de mémoire sous Android

- Auteur : Florian Fournier

- Parution : 8 juin 2015

- Licence : Les sources présentées sur cette page sont libres de droits et vous pouvez les utiliser à votre convenance. Par contre, la page de présentation constitue une œuvre intellectuelle protégée par les droits d'auteur. Copyright ® 2015 Feanorin. Aucune reproduction, même partielle, ne peut être faite de ce site et de l'ensemble de son contenu : textes, documents, images, etc. sans l'autorisation expresse de l'auteur. Sinon vous encourez selon la loi jusqu'à trois ans de prison et jusqu'à 300 000 € de dommages et intérêts.

Sommaire

- I. Introduction

- I-A. La mémoire native

- I-B. La mémoire et Java

- I-B-1. La pile (stack)

- I-B-2. Le tas (heap)

- I-B-3. Concept

- I-B-4. Les autres espaces

- I-C. Représentation

- I-D. Fuites de mémoire

- II. Analyse de la mémoire

- II-A. Les logs

- II-B. En ligne de commande

- II-C. Le Heap Dump

- II-C-1. Génération du fichier

- II-C-1-a. Code

- II-C-1-b. DDMS

- II-C-2. Analyse du fichier HPROF

- II-C-2-a. Fonctionnement de MAT

- III. Exemple

- IV. Conclusion

- V. Remerciements Developpez

I - Introduction

I-A - La mémoire native

La mémoire native permet de stocker des données depuis la mémoire physique ou autres dispositifs physiques (exemple : disques, mémoire flash, etc.). De ce fait la JVM se basera sur cette mémoire en premier lieu.

I-B - La mémoire et Java

Depuis la JVM, le stockage des données s'opère dans deux grandes zones de mémoire :

- les zones mémoire qui ont une existence de vie très courte, par exemple celles liées à un thread ;

- et les zones mémoire qui ont une vie longue, soit celles créées au lancement de l'application.

La JVM utilise ces espaces mémoire que nous verrons par la suite :

- le tas (heap) ;

- la pile (stack) ;

- les registres, uniquement lors de l'exécution des instructions du byte code ;

- des zones de méthode.

I-B-1 - La pile (stack)

Chaque thread possède sa propre pile, seules des données de types primitifs peuvent être stockées dans celle-ci, seul le thread propriétaire de cette pile pourra accéder à ces données. De ce fait logiquement la taille de la pile restera faible. Si vous arrivez à faire déborder cette zone mémoire, une exception sera levée de type StackOverflowError.

Par exemple, nous retrouvons les appels des procédures et classes dans la pile propre au thread. Pour ce faire, nous pouvons les récupérer directement par la méthode getStackTrace() de la classe Thread ou plus facilement, si vous voulez afficher la pile d'appels dans la console, via la méthode statique Log.getStackTraceString(Throwable t);

Stack 1 <----> 1  Thread, que les types primitifs.

I-B-2 - Le tas (heap)

Cette zone mémoire est partagée par l'ensemble de l'application, celle-ci contient les objets. Par conséquent tout objet créé depuis l'application est sauvegardé dans cet espace mémoire et est accessible à l'ensemble de l'application. La libération de cet espace mémoire se fait par le ramasse-miette  (Garbage Collector). Elle est réalisée lorsque l'objet en question n'a pas de raison valable d'être gardé en mémoire. Celle-ci étant déterminée par la non-utilisation de la donnée en mémoire.

On pourrait décomposer cette zone mémoire comme ceci :

- la Young Generation

- Eden Space : l'espace depuis lequel la mémoire est initialisée pour allouer la plupart des objets ;

- le Survivor Space : l'espace qui contient les objets qui ont survécu au garbage de l'Eden Space ;

- la Old Generation

- Tenured Generation : l'espace qui contient les objets qui ont existé pendant un moment dans la Survivor Space.

Si vous arrivez à faire déborder cette zone mémoire, une exception sera levée de type OutOfMemoryError.

Le ramasse-miette pourra de ce fait bouger la mémoire des objets comme bon lui semble, ne vous inquiétez pas, cela est complètement transparent pour le développeur Java.

Par exemple si nous appelons la méthode hashCode d'un objet qui nous retourne l'adresse interne de l'objet, celui-ci ne bougera pas, car sa valeur est sauvegardée lors d'une réallocation faite par le ramasse-miette.

Partagé à l'ensemble de l'application, contient les objets.

I-B-3 - Concept

Nous avons deux concepts pour la mémoire sous Java :

- ShallowHeap : est la mémoire prise par un objet simple, par exemple pour un Integer cela sera 4 octets, pour un long 8, etc. Bien sûr pour les objets complexes, la taille de l'objet sera ajustée par un alignement de 8 octets ;

- RetainedHeap : est la somme de la mémoire des objets retenue par un objet. En d'autres termes, quand l'objet sera supprimé, il libérera la retainedHeap.

Pour plus de compréhension, imaginez le graphe d'objets suivants, où chaque objet possède une shallowHeap de 100 et pointe les uns vers les autres selon le schéma ci-dessous. Nous mettons alors la valeur (R) de la retainedHeap de chaque objet.

I-B-4 - Les autres espaces

Il existe deux autres espaces qui ne sont pas présents dans la heap, mais qui font partie de la gestion de la mémoire de Java (HotSpot Java JM) :

- la Permanent Generation : elle contient la représentation des données comme les classes et les méthodes des objets. Elle contient le byteCode et les données lues du fichier .class. C'est sur cet espace que s'effectue la réflexion ;

- la Code Cache : elle contient la mémoire qui est utilisée pour la compilation et le stockage du code natif.

Attention : depuis Java 8 , nous avons une nouvelle représentation de la HotSpot, la Permanent Generation a disparu pour laisser place à la MetaSpace. Voici les améliorations :

http://mail.openjdk.java.net/pipermail/hotspot-dev/2012-September/006679.html

I-C - Représentation

I-D - Fuites de mémoire

La libération de la mémoire par le ramasse-miette se fait lorsqu'il n'y a plus de référence sur un objet alloué. Si on oublie de bien déréférencer l'objet, le ramasse-miette ne pourra pas libérer cette mémoire, du coup une fuite mémoire sera générée.  Un exemple typique serait de lancer un thread qui tournerait en boucle et de ne jamais l'arrêter.

Voici un schéma représentant une fuite mémoire :

Illustration 1:http://blog.naviso.fr/wordpress/?p=1080

II - Analyse de la mémoire

Justement pour détecter ce genre de cas, nous avons à notre disposition différents outils.

II-A - Les logs

Les logs nous permettent de récupérer à un instant « t » la représentation de la mémoire. Pour ce faire, nous avons plusieurs moyens de la récupérer que nous pourrons déclarer au sein d'une classe statique qui pourra être accessible depuis tout endroit de notre application (par exemple depuis une classe statique Log), ces trois méthodes renvoient les mêmes données de base :

- via la classe ActivityManager (une classe qui interagit avec toutes les activités exécutées sur le système, nous avons besoin ici du contexte de l'application) :

1. /**

2. * log heap by the ActivityManager

3. * @param context : Context to have access to the function getSystemService

4. * @param df : the format display

5. */

6. private static void logActivityManagerHeap(Context context, DecimalFormat df) {

7. Log.d("mytag", "ActivityManager. logHeap =================================");

8. ActivityManager actManager = (ActivityManager) context.getSystemService(ACTIVITY_SERVICE);

9. MemoryInfo memInfo = new ActivityManager.MemoryInfo();

10. actManager.getMemoryInfo(memInfo);

11. if (memInfo != null) {

12. Log.w("mytag", "ActivityManager.heap : total " +       df.format(memInfo.totalMem / 1048576.0) + "MB of " +

13. df.format(memInfo.availMem / 1048576.0) + "MB (" +

14. df.format(memInfo.threshold / 1048576.0) + "MB threshold  and " +

15. (memInfo.lowMemory ? "is low memory" : "is not low memory"));

16. }

17. }

- via la classe Runtime (permet d'accéder à l'environnement sur lequel l'application est exécutée) :

1. /**

2. * log heap by Runtime

3. * @param df : the format display

4. */

5. private static void logRuntimeHeap(DecimalFormat df) {

6. Log.d("mytag", "Runtime. logHeap =================================");

7. Log.d("mytag", "Runtime.Memory: allocated: " + df.format(new Double(Runtime.getRuntime().totalMemory()/1048576)) + "MB of " +

8. df.format(new Double(Runtime.getRuntime().maxMemory()/1048576))+ "MB (" +

9. df.format(new Double(Runtime.getRuntime().freeMemory()/1048576)) +"MB free)");

10. }

- via la classe Debug (une classe permettant d'accéder à plusieurs méthodes de débogage, ainsi que les allocations et traces) :

1. /**

2. * log heap by Debug

3. * @param df : the format display

4. */

5. private static void logDebugHeap(DecimalFormat df) {

6. Log.d("mytag", "Debug. logHeap =================================");

7. Double allocated = new Double(Debug.getNativeHeapAllocatedSize())/ 1048576.0;

8. Double available = new Double(Debug.getNativeHeapSize())/1048576.0;

9. Double free = new Double(Debug.getNativeHeapFreeSize())/1048576.0;

10. Log.d("mytag", "Debug.heap native: allocated " + df.format(allocated) + "MB of " +

11. df.format(available) + "MB (" +

12. df.format(free) + "MB free)");

14. // can get  the global allocation by function Debug.getGlobal... or binder by Debug.getBinder..., thread by Debug.getThread...

16. // get memInfo

17. android.os.Debug.MemoryInfo memInfoDebug = new Debug.MemoryInfo();

18. Debug.getMemoryInfo(memInfoDebug);

19. if (memInfoDebug != null) {

20. Log.w("mytag", "Debug.heap : totalPrivateDirty " + df.format((memInfoDebug.getTotalPrivateDirty()/ 1024.0) + "MB"));

21. Log.w("mytag", "Debug.heap : totalPss " + df.format((memInfoDebug.getTotalPss()/ 1024.0) + "MB"));

22. Log.w("mytag", "Debug.heap : totalSharedDirty " + df.format((memInfoDebug.getTotalSharedDirty()/ 1024.0) + "MB"));

23. int currentapiVersion = android.os.Build.VERSION.SDK_INT;

24. if (currentapiVersion >= android.os.Build.VERSION_CODES.KITKAT){

25. Log.w("mytag", "Debug.heap : totalSharedClean " + df.format((memInfoDebug.getTotalSharedClean()/ 1024.0) + "MB"));

26. Log.w("mytag", "Debug.heap : totalPrivateClean " + df.format((memInfoDebug.getTotalPrivateClean()/ 1024.0) + "MB"));

27. Log.w("mytag", "Debug.heap : totalSwappablePss " + df.format((memInfoDebug.getTotalSwappablePss()/ 1024.0) + "MB"));

28. }

29. }

30. }

Ces méthodes nous permettent de nous donner une représentation générale de la mémoire actuelle,  mais cela nous ne donne pas assez d'informations sur réellement ce que nous avons. Cela nous permet par exemple de savoir rapidement si une fuite mémoire importante est présente ou pas (style les bitmaps).

Astuce : les trois méthodes peuvent être utilisées, seule la classe ActivityManager a besoin d'un contexte. La classe Debug étant la méthode qui nous renverra le plus d'informations sur l'utilisation de la mémoire en cours.

Les données seront bien entendu récupérées depuis le logcat sous cette forme :

11-01 16:13:37.774: D/mytag(875): Debug. logHeap =================================

11-01 16:13:37.794: D/mytag(875): Debug.heap native: allocated 6.806MB of 6.844MB (0.038MB free)

11-01 16:13:37.844: D/mytag(875): Runtime. logHeap =================================

11-01 16:13:37.903: D/mytag(875): Runtime.Memory: allocated: 2.000MB of 32.000MB (0.000MB free)

11-01 16:13:37.903: D/mytag(875): ActivityManager. logHeap =================================

11-01 16:13:37.913: W/mytag(875): ActivityManager.heap : total 336.359MB of 218.617MB (46.000MB threshold  and is not lowmemory

II-B - En ligne de commande

Vous pouvez depuis la console accéder aux informations mémoire de votre appareil.

Pour cela, il vous suffit d'exécuter cette ligne de commande :

adb shell dumpsys meminfo <processid>

Cela vous retournera sous format de tableau les informations mémoire relatives au processus demandé.

II-C - Le Heap Dump

Un dump de la mémoire est une capture de l'image complète de l'utilisation de la mémoire en cours. Cela se réalise via la génération d'un fichier .hrpof contenant les données sous format binaire de l'image que nous analyserons ensuite.

II-C-1 - Génération du fichier

Pour générer ce fichier, il existe plusieurs méthodes que nous allons voir.

II-C-1-a - Code

Cette méthode est très utile pour générer le fichier lorsque vous aurez une exception OutOfMemoryError qui sera levée. De ce fait, vous serez capable d'analyser plus rapidement les causes de cette exception.

Pour cela, il vous faut rajouter dans votre classe Application :

@Override
public void onCreate() {
super.onCreate();
/**
* Full path to a directory assigned to the package for its    persistent data.
* catch any error in the current Thread and dump the memory if an    outofmemoryerror
* can save the dump file in other path, as sdcard
*/
Thread.currentThread().setUncaughtExceptionHandler(
new HeapDumpingUncaughtExceptionHandler(getApplicationInfo().dataDir));
}

De ce fait, toutes exceptions levées depuis l'application seront interceptées via cette méthode.

Ensuite la classe HeapDumpingUncaughtExceptionHandler :

1. /**

2. * Save the dump file in the given path

3. *

4. */

5. public class HeapDumpingUncaughtExceptionHandler implements Thread.UncaughtExceptionHandler {

7. private static final String HPROF_DUMP_BASENAME = "Android2ee.dalvik-hprof";

8. private final String dataDir;

10. public HeapDumpingUncaughtExceptionHandler(String dataDir) {

11. this.dataDir = dataDir;

12. }

14. @Override

15. public void uncaughtException(Thread thread, Throwable ex) {

16. Log.w(getClass().getSimpleName(), "uncaughtException :" + dataDir);

17. String absPath = new File(dataDir,       HPROF_DUMP_BASENAME).getAbsolutePath();

18. // Dump the memory when we have an OutOfMemoryError

19. if(ex.getClass().equals(OutOfMemoryError.class)) {

20. try {

21. // Dump "hprof" data to the specified file. This may cause a GC.

22. Debug.dumpHprofData(absPath);

23. } catch (IOException e) {

24. Log.e(getClass().getSimpleName(),e.getMessage()) ;

25. }

26. }

27. }

28. }

Cette classe vous permet de ne faire un dump de la mémoire que si l'exception est une OutOfMemory, ce dump est réalisé par la fonction Debug.dumpHprofData. Dans cet exemple, nous avons pris le chemin de getApplicationInfo().dataDir qui est le chemin des données persistantes, nous aurions pu prendre un autre chemin relatif, soit la sdcard, pour faciliter la récupération du fichier depuis l'appareil.

Suite à la récupération de ce fichier, vous devrez utiliser l'outil de conversion fourni par la SDK d'Android, pour pouvoir lire le fichier hprof par la suite. Voici la commande :

platform-tools/hprof-conv infile outfile

II-C-1-b - DDMS

Une méthode plus simple existe et consiste à utiliser le DDMS fourni par le SDK d'Android. Depuis le DDMS, vous trouverez deux actions qui sont Update Heap et Dump HPROF File. Il vous suffira alors de d'abord mettre à jour la Heap (via le bouton Update Heap) puis de cliquer sur le bouton Dump HPROF File. Ce fichier sera alors généré , pas besoin de le convertir pour ce cas-là.

Avant de récupérer la capture d'image de votre mémoire, vous pouvez provoquer un GC grâce au bouton « Cause GC » que vous trouverez depuis le DDMS.

II-C-2 - Analyse du fichier HPROF

Pour analyser ce fichier, vous aurez besoin de l'outil MAT (Memory Analyser Tool), qui est un plugin pour Eclipse.

Pour installer cet outil, il vous faudra récupérer le lien de la dernière release ici.

Ensuite depuis votre Eclipse, allez dans Help→ Instal New Software… Entrez ensuite cette URL http://download.eclipse.org/mat/1.4/update-site/.

Vous aurez également besoin de BIRT pour pouvoir afficher les graphiques depuis MAT. Pour cela, vous devrez entrer cette URL : http://download.eclipse.org/birt/update-site/4.3. BIRT est un outil open source pour créer des visualisations de données et des rapports qui peuvent être intégrés dans des applications client enrichi et Web.

II-C-2-a - Fonctionnement de MAT

Il vous suffit d'ouvrir le fichier hprof (généré ou non généré : MAT se chargera de cela pour vous) depuis Eclipse. Vous aurez alors une demande du type de rapport que vous voulez avoir comme ceci :

Leak Suspects Report : permet de vérifier que vous n'avez pas de fuite mémoire.

Component Report : permet d'analyser un ensemble de mémoires (duplication, collection , etc.).

Pas de stress, vous pourrez basculer entre les types depuis MAT.

MAT vous générera ensuite les données et vous obtiendrez quelque chose du genre :

Depuis cette page, vous avez différentes options accessibles depuis le menu :

- OverView : permet de voir une vue générale sur le rapport généré.

- Histogram : permet de voir l'ensemble des objets alloués sous forme de tableau.

- Dominator Tree : permet de voir l'ensemble des classes présentées dans le tas sous forme de tableau.

- Open Object Query Language : permet sous forme de requêtes SQL de rechercher des objets.

- Thread name, etc. : si possible, nous renvoie sous forme de tableau la liste des threads vivants.

- Run Expert System : est un sous-menu disponible vous permettant de générer d'autres rapports, les ouvrir,  ou les exécuter.

- Open Query Browser : des requêtes SQL prédéfinies pour vous faciliter la vie dans votre recherche dans le tas.

III - Exemple

Prenons un exemple de fuite de mémoire :

1. private static Drawable sBackground;

3. @Override

4. protected void onCreate(Bundle state) {

5. super.onCreate(state);

6. TextView label = new TextView(this);

7. label.setText("test");

8. if (sBackground == null) {

9. sBackground = getDrawable(R.drawable.large_bitmap);

10. }

11. label.setBackgroundDrawable(sBackground);

12. setContentView(label);

13. }

Nous pouvons voir ici que nous chargeons une TextView au démarrage de l'Activity. Ce chargement se fait dynamiquement avec une image de fond assignée à celle-ci. Lors d'une rotation de l'écran, le système détruira l'Activity pour la reconstruire.  De ce fait, il repassera dans le onCreate de l'Activity et recréera une TextView avec une image de fond, nous avons gardé l'image de fond dans une statique pour ne pas recharger celle-ci à chaque fois. Nous avons généré une fuite mémoire via l'image de fond, car la fonction setBackgroundDrawable attache le callback du drawable à sa propre vue. Cela implique donc que l'image possède une référence sur la vue, ce qui provoquera une fuite mémoire. Cette fuite de mémoire explosera la mémoire du smartphone qui est actuellement de 64 Mo pour les récents (dont une grande partie est déjà réservée au démarrage de l'application pour avoir accès aux ressources plus rapidement depuis les 4.x).

Illustration: Nous pouvons voir par le schéma que l'objet drawable gardera en mémoire l'objet TextView depuis sa callBack.

Regardons maintenant le fichier hprof que nous aurons généré suite à plusieurs rotations du smartphone.

Voici la fuite que nous détectons via l'outil MAT, nous voyons qu'il nous informe que nous avons une fuite de mémoire sur une classe Bitmap. Nous pouvons retrouver ces instances dans le dominator tree :

On retrouve bien nos onze instances que l'outil nous avait informées.

Attention l'outil est un analyseur et peut rater également des fuites de mémoire, par exemple si nous exécutons un thread depuis l'Activity sans l'annuler lorsque l'Activity est détruite alors nous aurons une succession de thread lancés.

1. @Override

2. protected void onResume() {

3. super.onResume();

4. // do a leak that's no detect by MAT

5. myThread thread = new myThread();

6. thread.start();

7. }

9. /**

10. * Thread to create a leak

11. *

12. */

13. private class myThread extends Thread {

14. @Override

15. public void run() {

16. super.run();

17. while (true) {

18. try {

19. sleep(1000);

20. } catch (InterruptedException e) {

21. Log.e(“mytag”,e.getMessage();

22. }

23. }

24. }

25. }

Sans annuler le thread depuis l'Activity nous aurons x threads lancés qui ne se termineront jamais à cause de la condition while(true). L'outil Mat ne trouvera pas cette fuite de mémoire, mais vous pourrez retrouver cette fuite depuis la page des threads comme cela :

nous pouvons voir ici que le thread myThread a été lancé quatre fois !

IV - Conclusion

N'ayez jamais une confiance absolue au GC (Garbage Collector), il n'évite pas entièrement les fuites de mémoire.

- Respectez les cycles de vie (Activity, Fragment, etc.) où vos objets ont été alloués.

- Si vous créez des inner classes, pensez bien au WeakReference si vous gardez un lien sur l'Activity, Fragment.

- Essayez d'utiliser au possible le contexte de l'application et non celui de l'activité.

- Vérifiez bien que vos images sont bien recyclées.

- Abusez des rotations d'écran pour contrôler votre mémoire.

- Faites extrêmement attention à l'usage des variables statiques !

Le projet sous github : https://github.com/ffournier/MemoryLeak

Vous pouvez aussi regarder une conférence de Romain Guy au PAUG qui parle des fuites de mémoire : ici

Vous pouvez aussi lire cet article sur les Handler, les Asynctask et les fuites mémoire http://www.android2ee.com/Tutoriaux/handlers-lifecycle.html.

V - Remerciements Developpez

Mes remerciements à Claude Leloup pour sa relecture orthographique et également à Mickael Baron pour sa relecture technique

N'hésitez pas à commenter cet article ! Commentez