La gestion de la mémoire en Java

La JVM dispose de plusieurs zones mémoire distinctes :

  • La pile (ou heap), est l'espace mémoire où tous les objets et tableaux sont stockés. Lorsque la JVM démarre, la pile est initialisée à la valeur spécifiée par -Xms et elle grandira dynamiquement quand les objets seront crées, jusqu'à ce qu'elle atteigne sa taille maximale spécifiée par le paramètre -Xmx. Si la taille maximale est atteinte, et que plus de mémoire est nécessaire, la JVM générera une java.lang.OutOfMemoryError: Java heap space.
  • Permgen space : il s'agit de l'espace mémoire qui est alloué pour le chargement des classes (pas les objets). A la différence de la pile, la taille de cette région est fixe et ne varie plus après le démarrage de la JVM. Pour la JVM de Sun, la taille par défaut du permGen space est de 64M. Vous pouvez changer cette valeur grâce au paramètre -XX:PermSize. Rappelons tout de même que les options commençant par XX ne sont pas des paramètres HotSpot stables et que leurs compatibilités dépendent du vendeur et de la version de la JVM. Si la mémoire du permgen est insuffisante une java.lang.OutOfMemoryError: PermGen space sera renvoyée
  • il existe un troisième type d'erreurs : les erreurs natives. Elles apparaissent lorsque le système hôte ne peut plus fournir de mémoire (plus assez de place en mémoire vive, ou plus assez de swap). Dans ce cas la JVM génère une Erreur java.lang.OutOfMemoryError: request <size> bytes for <reason>.

Cas des champs statiques : Les champs statiques ne peuvent être garbage collectés tant que la classe qui les référence est chargée. il ne peuvent être garbage collectés que lorsque le class loader qui est responsable du chargement de la classe est lui même garbage collecté.

Chaque instance de classe a une taille en mémoire, cette taille appelée "Shallow Heap" est constituée de types primitifs (int, char, ...). Une instance peut avoir également des références sur d'autres objets qui ont eux même une taille. Les objets ainsi liés entre eux constituent un graphe.

Tous les graphes disposent d'une racine d'attache, ces racines peuvent être, par exemple, des threads ou des class loaders qui tiennent des références sur les attributs statiques. Ces racines se nomment en anglais des "GC root". Toutes les instances en mémoire finissent par être attachées à une racine, si ce n'est pas le cas, le Garbage Collector peut supprimer ces instances du graphe, et libérer de la mémoire.

Les références peuvent être de plusieurs types. Il y avait les références fortes "StrongReference" et depuis java 5 il existe des références dites douces "SoftReference" : L'objet est référencé, mais si la JVM a besoin de mémoire, elle peut libérer cet objet. On trouve également les références légères "WeakReference": L'objet restera en mémoire, tant qu'il existe une référence forte ou douce sur lui. enfin il existe des PhantomReference : les objets peuvent être supprimés de la mémoire si aucune référence forte, douce, ou légère existe sur cette objet.

Empreinte de la mémoire (Heap dump)

Afin de permettre des diagnostiques et de voir ce que contient la mémoire de la JVM, il est possible de générer une "empreinte de la mémoire" qui correspond à tous les objets chargés à un instant précis. Ces empreintes sont plus connus sous le nom de "Heap dump".

Ces dumps contiennent :

  • Tous les objets : champs, références, et objet primitifs.
  • Toutes les classes : Class loader, super Class, champs statiques

En revanche il ne contient pas les informations sur qui et où sont crées les objets, ni quels sont les objets qui ont déjà été garbage collectés.

Il existe différentes méthodes pour générer ces dumps. Soit en spécifiant les options -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/tmp/dump.hprof au lancement de la JVM afin qu'un dump soit généré lorsqu'une java.lang.OutOfMemoryError survient. Notez toutefois que comme toutes les options commençant par XX, elles sont dépendantes du vendeur de JVM. La deuxième solution est de faire un dump à la demande, à l'aide du JDK 6 et de JConsole. les dumps à la demande sont utiles, par exemples, après des benchs.

Voici la procédure :

  • Lancez JConsole situé dans le répertoire "bin" de la JVM
  • Sélectionnez le processus Java pour lequel vous voulez faire le dump. Vérifiez sur l'onglet "VM summary" que c'est bien le bon processus.
  • Rendez vous sur l'onglet Mbeans
  • Sélectionnez l'item com.sun.management / hotspot diagnostic / operation / dumpheap
  • Spécifiez le chemin complet du fichier où vous voulez stocker le dump dans le premier champs de saisie (laissez le deuxième champs à true). Les dumps ont souvent l'extension hprof, et MAT ne verra pas le fichier s'il n'a pas la bonne extension.
  • Validez en cliquant sur le bouton "dumpHeap". Cela figera l'exécution de la JVM (donc à éviter en production), exécutera un garbage collector, et générera un fichier binaire avec le contenu de la mémoire.

jconsole

Si vous ne possedez pas JDK 6.0, il est possible d'utiliser jmap :

jmap -heap:format=b <PID_DU_PROCESS_JAVA>

Qu'est ce qu'une fuite mémoire

Une fuite mémoire est une dérive non contrôlée de l'utilisation de la mémoire. elle est souvent due à un objet qui garde des références à une multitude d'autres, ce qui empêche le garbage collector de les enlever de la mémoire. Cette dernière grandit alors de plus en plus et finit par saturer.

Pour comprendre une fuite mémoire il faut connaitre quelques termes (en anglais):

  • Dominator tree : Liste des plus gros objets et de qui les référencent.
  • Leak suspect : Objet ayant une grosse taille dans le dominator tree.
  • Accumulation point : Gros objet référençant beaucoup d'autres petits objets

Voici un représentation graphique des termes évoqués ci dessus :

IMAGE

Shallow Heap et retained Heap : il s'agit de la taille que prend un objet sur la pile. "shallow heap" représente la taille des éléments de l'objet sans inclure les objets qu'il référence, tandis que "retained Heap" les prend en compte : elle représente la taille qui sera libéré si cet objet est garbage collecté. Voici un exemple pour mieux comprendre :

Ci dessous ce trouve la représentation interne d'une string que j'ai pu obtenir avec le mode debug d'Eclipse :

retained size

On voit qu'une string est représentée par trois entiers : count, hash, et offset, qui représente la shallow heap, ainsi qu'une référence "value" à un tableau de char . count, hash, offset et value représente la retained heap car le char sera également garbage collecté si la string l'est aussi.

Eclipse Memory Analyser Tool

Il existe déjà des outils permettant d'analyser les dumps, mais ils sont soit difficiles d'approche comme jhat (Java Heap Analysis Tool) et jmap soit ils sont payants comme Jprofiler.

Eclipse a développé un outil permettant d'analyser les heap dumps. Cela permet d'aider à trouver où se situent les fuites mémoires et de détecter les gaspillages et améliorations possibles de l'utilisation de la mémoire. L'outil ne se contente pas de vous analyser la mémoire, il cherche également où peuvent se situer les fuites ainsi que les optimisations. c'est là le point fort de cet outil.

MAT peut être installé en tant que plugin dans Eclipse, ce qui a l'avantage de pouvoir regarder le code en même temps que de regarder le dump, ou en tant que client lourd (RCP) à part entière, des scripts sont alors disponibles en plus du plugins. Peu importe votre choix, vous pouvez vous rendre sur cette page pour le télécharger.

Utilisation de MAT avec un code d'exemple

Je vous propose de tester MAT avec du code simple (disponible en fichier attaché à ce billet) générant une fuite mémoire. Le code représentera plusieurs objets métiers : une entreprise, des salariés, et des adresses, ainsi qu'une classe permettant de générer quelques instances de ces objets et une HashMap statique servant de cache pour les objets Entreprise. Enfin une fois tous ces objets chargés, je provoquerai une fuite mémoire en remplissant un tableau avec des Double. Je dis bien "provoquerai", car à la différence des Exceptions, les Errors sont générées par la JVM et non par le code : si je fais

throw new OutOfMemoryError();

et que je spécifie les options HeapDumpOnOutOfMemoryError le dump ne sera pas généré.

je lance donc la classe Application avec les options -XX:+HeapDumpOnOutOfMemoryError -Xms5m -Xmx10m -XX:MaxPermSize=256m.

En l'absence de HeapDumpPath, c'est un fichier avec le PID qui sera généré, l'avantage étant qu'un nouveau fichier de dump sera généré à chaque java.lang.OutOfMemoryError (il ne sera pas écrasé puisque le nom sera différent à chaque fois)

Le lancement du programme me donne la sortie suivante :

java.lang.OutOfMemoryError: Java heap space
Dumping heap to java_pid2331.hprof ...
Heap dump file created [13486084 bytes in 0.401 secs]
Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
        at java.util.ArrayList.ensureCapacity(ArrayList.java:169)
        at java.util.ArrayList.add(ArrayList.java:351)
        at com.demo.mat.OutOfMemoryHelper.addDouble(OutOfMemoryHelper.java:18)
        at com.demo.mat.OutOfMemoryHelper.throwOutOfMemoryError(OutOfMemoryHelper.java:13)
        at com.demo.mat.Application.main(Application.java:74)

Il ne reste plus qu'a analyser le dump. vous pouvez l'ouvrir directement en lançant MAT, puis en allant dans File->Open Heap Dump et en sélectionnant le fichier java_pid2331.hprof. (seuls les fichiers .hprof apparaitrons). MAT générera alors plusieurs indexes et un rapport HTML. Les indexes permettent de naviguer plus rapidement car le format hprof n'est pas très exploitable en l'état. Cette génération n'aura lieu qu'une seul fois et lorsque vous rouvrirez ce dump, Mat utilisera les fichier déjà générés.

Dans notre cas le fichier sera de 10 méga octets (environ taille du Xmx), mais si vous voulez analyser de gros fichiers je vous conseille d'utiliser la ligne de commande:

./MemoryAnalyzer -consolelog -application org.eclipse.mat.api.parse java_pid2331.hprof

car MAT a des fois du mal à les digérer. L'ouverture par MAT sera alors plus rapide et facile.

vous pouvez aussi utiliser le scipt ParseHeapDump.sh, il vous faudra par contre remplacer les ^M, pour cela ouvrez le fichier en mod binaire avec VIM :

vim - b ParseHeapDump.sh
puis :%s/<CTRL+V><CTRL+M>//g pour remplacer le ^M par chaine vide

Prise en main de l'outil

Le rapport donne une première vue avec

  • les informations sur la taille du dump (8.7M) et le nombre de classe chargées (360)
  • un camembert représentant les plus gros objets en mémoire. En passant votre souris sur chaque partie du camembert, vous pouvez avoir des informations sur les attributs (références et champs natifs) de l'objet dans la colonne de gauche 'attributes'

mat report

Différentes actions menant vers différentes vues du dump :

  • Un histogramme : qui affiche une représentation de l'utilisation mémoire par classes (nombre d'objets instanciés par classe, ainsi que leurs tailles 'shallow' et 'retained'). Vous pouvez d'ailleurs voir que le nombre de char est sensiblement le même que le nombre de String ;)
  • Un dominator tree (voir définition ci dessus) : présente non plus une vue par classes mais par objets
  • Une vue graphique des objets du dominator tree groupés selon différents critères : class loaders, packages, classes. Le dominator tree travaille, si on ne spécifie pas de groupage, au niveau objet
  • Une liste des classes chargées plusieurs fois par différents class loader.

Les parties les plus intéressantes du rapport et les plus exploitables directement sont sans aucun doute les deux rapports suivants:

  • Liste des 'leak suspects', c'est à dire que l'outil vous suggère où peuvent se situer les responsables de fuites mémoires.
  • Une liste des objets gaspillant la mémoire (e.g : ArrayList surdimensionnées ayant beaucoup d'éléments vides) avec des suggestions d'optimisations de l'utilisation mémoire (à ne pas confondre avec le rapports des fuites mémoires.)

Voyons vue par vue, les choses que nous pouvons faire : Dans toutes les vues vous avez la possibilité de voir ce que contient l'objet sélectionné dans l'onglet "attributes".

Il est possible de calculer la taille de la 'retained heap' via l'icône représentant un calculatrice.

calculate retained size

Vous pouvez ainsi trier les objets par 'retained heap' pour identifier les 'leak supect'. Vous pouvez aussi les filtrés en rentrant des expressions régulières juste en dessous des libellés de colonnes (e.g : "regex" en dessous de 'Class Name'). Attention pour les regexp, elles sont sensibles à la casse.

histograme filtres

Une action qui révèle souvent des informations intéressantes, consiste à grouper les objets. Si vous les groupez par classe, vous pouvez par exemple obtenir le nombre d'objets et le pourcentage de la mémoire totale qu'occupe les objets de cette classe. Cela permet d'identifier les points d'accumulation plus facilement lorsque la fuite mémoire ne concerne pas un seul gros objet (ce qui n'est pas notre cas) mais une multitude d'objets de la même classe. Par défaut, la distribution de l'occupation de la mémoire ce fait par objet, en les groupant, les shallow / retained heap sont alors additionnés, laissant souvent voir une répartition inégale de l'utilisation de la mémoire selon les classes (en savoir plus). Vous pouvez aussi les grouper par class loader, ce qui peut se révéler intéressant pour des webapps, Tomcat, par exemple, utilise des class loader differents : webappClassLoader et un SystemClassLoader (en savoir plus). OSGI (voir l'article publié précédemment) utilise aussi beaucoup de class loader différents

Explication des menus

Lorsque vous cliquez droit sur les objets du dominator tree ou sur les classes de l'histogramme, vous obtenez un menu vous permettant d'avoir des informations sur l'élément sélectionné :

  • List objects=>with outgoing references: objets référencés par l'élément sélectionné. Si on additionne la taille de ces objets, on obtient la retained size.
  • List objects=>with incomming references : objets qui référencent l'élément sélectionné.


La liste des objets est contextuelle à l'élément sélectionné : si les objets sont groupés par classe, ou que l'élément est une classe, une ligne par objet de cette classe sera affichée, si l'élément est un objet, alors seuls les objets concernés par cet objet (référencé par / qui référence) seront affichés

  • List class => by outgoing references : les classes des objets référencés par l'élément.
  • List class => by Incoming references : Les classes des objets qui ont une référence sur l'élément


La liste des classes affichée est également contextuelle. Exemple pour outgoing référence : si l'élément sélectionné est une classe, alors la liste de toutes les classes référencées par tous les objets de cette classe sera listée, si l'élément est un objet, seule la liste des classes référencées par cet objet sera affichée.

  • Path to GC root => Affiche les chemins des objets qui référence cet objet jusqu'à leur élément racine (un peu comme les incomming objects mais moins en profondeur car s'arrête lorsque l'on arrive au sommet de la pile du thread auquel appartient l'élément). Il est possible via les sous menus de filtrer selon les weak / soft / hard références. ce qui peut être utile lorsque vous utilisez des caches d'objets avec des références soft, car vous pouvez uniquement inspecter l'élément en tenant compte ou pas du cache. Ce menu n'est applicable que pour un objet pas pour classe.


  • Merge Path to GC root : affiche le chemin le plus court parmi les références racines pour chaque instance de la classe ou de l'objet sélectionné


Java basics permet d'avoir des renseignements divers sur l'objet (e.g : chercher une String). Depuis la version 0.8, il est possible d'analyser les stacktraces. Ces informations sont disponibles via le menu java basics=>threads stacks

  • Java collections sert plus à détecter les gaspillages de mémoire. il permet par exemple d'avoir le ratio de remplissage d'un tableau.
  • Leak identification=> Component Report : permet d'avoir des informations sur la mémoire occupée par cette objet : taille que l'objet ou que les objets d'une classe occupent dans la pile, leurs soft et leurs hard références,
  • Leak identification=> Top consumer : les plus gros éléments regroupés et triés selon différents critères : Objet, Classe, Class loader, Package. il permet d'avoir une répartition des objets par classe (e.g : trouver quels sont les plus gros objets de la classe Salariés). ce menu est utile lorsque vous l'appliquez sur une classe, car il montre quels sont les plus gros objets de cette classe et leurs répartitions
  • Immediate dominator : Donne les classes des objets qui font que l'élément est gardé en mémoire (e.g : les immediate dominators de char sont bien souvent des String). Il est souvent utile de filtrer les classes des package com.sun.* et java.* car on peut supposer que la fuite ne vienne pas de là. Dans l'exemple ci dessous, pour les immediate dominator de char, si on considère la ligne concernant Entreprise : La première colonne donne le nombre d'objets de la classe listée (1), la deuxième donne le nombre d'objets référencés par cette classe (3) (un objet Entreprise référence 3 char correspondant aux trois commentaires).

immediate dominator

  • Show retained set : donne le nombre d'objets que référence l'élément, groupés par classe. le retained set représente les objets qui seront libérés si l'élément est garbage collecté. Pour un objet Entreprise, il contient 3 char, 3 Strings et une Entreprise (les trois commentaires+ la class ). vous pouvez filtrer le retained set par le ou les noms des champs. On peut alors se demander "Pourquoi les objets "raison social", "salariés" ne sont pas dans la retained set alors qu'on les voit si on liste les outgoing objects de Entreprise. La réponse est que ces objets ne seront pas libérés si Entreprise disparait. Pour le prouver faites un incomming référence sur les objet de Entreprise : les commentaires ne sont uniquement référencés par Entreprise, alors que raison social par exemple est également référencé comme clé pour la Hashmap de cacheEntreprise. De même pour "raison social" qui est une constante car déclarée dans le code, alors que les 3 commentaires sont générés au runtime.

Savoir lire les graphes

Lorsque l'on est devant un graphe, il est important de savoir le lire. prenons l'exemple pour des 'outgoing références' :

outgoing

La classe Entreprise à des référence vers 4 objets de type String qui se nomme 'commentaire', 'commentaire2', commentaire3, et 'raison social'. et une ArrayList qui se nome salarié. si l'un des objet avait une valeur null, il n'aurait pas été listé.

Voyons maintenant comment lire des 'incoming références' :

incoming

L'objet entreprise (je dis bien objet et pas classe) est référencé par :

  • Le champs 'entreprise' de 3 objets de type Salarié,
  • Le thread lui même , correspondant à la déclaration de la variable (Entreprise entreprise = new Entreprise() )
  • Le champs 'value' d'une HashMap, correspondant à la HashMap de cache nommée entreprise et appartenant à la classe CacheEntreprise .

Lorsque l'on analyse des dumps mémoire, il est fortement conseillé de connaitre la représentation interne des objets Map, Collections, String, etc. Cela permet de mieux naviguer. Une HashMap est par exemple constitué d'un tableau de hashMapEntry, lui même constitué de champs key, value, et hash. Si vous voulez explorer un objet, allez sur l'onglet 'attributes', cliquez droit, puis sélectionnez 'Go into' :

attribut

Les rapports de fuites mémoires

Entrons maintenant dans le vif du sujet. Le rapport proposant des responsables des fuites mémoires. Comment cela fonctionne ? En fait l'outil analyse le dump et recherche les 'Leak suspects', Il commence par chercher les points d'accumulation en analysant les écarts significatifs dans la retained size entre deux objets du graphe, puis remonte les 'incomming references', prend le plus court chemin, et en déduit le suspect.

Un rapport HTML comprenant plusieurs sections est alors généré :

  • les points d'accumulation : donnent les informations sur la taille et le type d'objets, le thread incriminé, et le nombre d'objets contenus.

Le rapport génère aussi une liste de mots clés permettant de le googliser ou de les taper dans un bug tracker. L'idée est à mon sens excellente : Lorsque vous créez le bug, vous y ajoutez ces mots clés, et une personne qui désire retrouver ce bug ou voir si une résolution a déjà été trouvée, tape ces mots clés, retrouve le bug et une potentielle résolution éprouvée.

leak report

Deux questions se posent alors :

  • Qui garde une référence à cet objet ?
  • Pourquoi l'objet est si gros et que contient il ?

Pour la première question on va chercher quel est le plus court chemin du GC root jusqu'à l'objet du point d'accumulation :

leak report shortest path

Le graphe ce lit 'ligne du haut' est contenu par 'ligne du dessous'. Dans l'exemple ci dessus : le tableau d'objet est référencé par le thread (Chemin le plus court) et par une ArrayList

Pour la deuxième question MAT nous donne un aperçu des objets référencés par le point d'accumulation. Cela permet en général d'avoir une idée plus précise de la cause de la fuite.

leak report accumulated object

Le rapport fournit également une répartition des objets référencé par le point d'accumulation par classes. Dans notre cas, le tableau d'objet ne contient que des Double, ce qui laisse à penser qu'une méthode remplit le tableau.

leak suspect by class

Une autre information intéressante est l'environnement d'exécution. Dans quel contexte a eu lieu le dump. Cela à une importance quand vous utilisez -XX:HeapDumpOnOutOfMemoryError . Les informations importantes sont entre autres :

  • Les propriétés de la JVM (Xms, Xmx). Peut être un mauvais dimensionnement de la mémoire ?
  • Le nombre de threads et leurs tailles permettent de voir si un seul thread est incriminé, et dans ce cas, selon la nature de traitement qu'à le thread, vous pouvez plus cibler la fuite (exemple : un thread de purge d'un cache)
  • L'histogramme des classes peut également donner des informations sur la cause. S'il existe un grand nombre d'objets appartenant à un classe d'un de vos package, il est probable (pas à 100%, mais vous disposez déjà d'une piste) que la fuite est causée par du code vous appartenant.

Informations sur les threads

MAT fournit des informations sur le context d'exécution des threads. il inspecte les variables threadLocal et selon ce qu'il y trouve, nous donne des informations supplémentaires. exemple :

  • s'il trouve des objets sur des requêtes HTTP il inspectera l'objet et en extraira les headers, URL, status code, etc.
  • s'il trouve des connections JDBC, il extraira les resultsets, query,...on peut par exemple voir qu'il manque un clause where, ce qui fait que beaucoup trop d'objets sont rapatriés, et cause une trop grande consommation mémoire
  • s'il trouve des contexts OSGI, il explorera leurs contenus.

Voici deux exemples : le premier pour un thread exécutant une requête SQL :

thread sql


le deuxième pour un thread exécutant une requête soap :

thread soap

On peut légitimement se poser la question "comment sont déterminer ces informations ? " en fait si le suspect est un thread, il explore son nom (exemple "HTTP-processor" pour un thread Tomcat, "RMI TCP Connection(8)-127.0.0.1 pour un connecteur jmx, "Timer-3" pour un timerThread), il inspecte les variables threadLocal et selon ce qu'il y trouve nous donne des informations supplémentaires. Ce système d'exploration est basé sur un système de plugins, vous pouvez donc en créer vous même selon vos besoins

OQL ou Object Query Language

Pour permetre de rechercher des objets facilement, vous pouvez les rechercher avec une syntaxe SQL like. Exemple :

select e.nom from com.demo.mat.Entreprise e

Vous pouvez utiliser des clauses WHERE, des UNION, des projections (avg, min, max,...). vraiment très pratique pour trouver, compter, avoir des statistiques sur des objets en particulier. (en savoir plus)

Voilà pour la théorie :) j'espère que vous maitrisez les différents concepts de l'outil et que vous savez désormais aller chercher les informations pertinentes.

Le prochain post sera basé sur un cas concret et expliquera comment j'ai résolu une fuite mémoire sur Gisgraphy, en 15 minutes à l'aide de MAT.