Chasse aux fuites de mémoire Java
Publié: 2022-03-11Les programmeurs inexpérimentés pensent souvent que le ramasse-miettes automatique de Java les libère complètement des soucis de gestion de la mémoire. Il s'agit d'une perception erronée courante : alors que le ramasse-miettes fait de son mieux, il est tout à fait possible que même le meilleur programmeur soit la proie de fuites de mémoire paralysantes. Laisse-moi expliquer.
Une fuite de mémoire se produit lorsque des références d'objet qui ne sont plus nécessaires sont inutilement conservées. Ces fuites sont mauvaises. D'une part, ils exercent une pression inutile sur votre machine car vos programmes consomment de plus en plus de ressources. Pour aggraver les choses, la détection de ces fuites peut être difficile : l'analyse statique a souvent du mal à identifier précisément ces références redondantes, et les outils de détection de fuites existants suivent et rapportent des informations détaillées sur des objets individuels, produisant des résultats difficiles à interpréter et manquant de précision.
En d'autres termes, les fuites sont soit trop difficiles à identifier, soit identifiées en des termes trop spécifiques pour être utiles.
Il existe en fait quatre catégories de problèmes de mémoire avec des symptômes similaires et qui se chevauchent, mais des causes et des solutions variées :
Performances : généralement associées à une création et une suppression excessives d'objets, à de longs délais de récupération de place, à un échange excessif de pages du système d'exploitation, etc.
Contraintes de ressources : se produit lorsqu'il y a trop peu de mémoire disponible ou que votre mémoire est trop fragmentée pour allouer un objet volumineux - cela peut être natif ou, plus communément, lié au tas Java.
Fuites de tas Java : la fuite de mémoire classique, dans laquelle des objets Java sont créés en continu sans être libérés. Cela est généralement dû à des références d'objets latents.
Fuites de mémoire natives : associées à toute utilisation de mémoire en croissance continue en dehors du tas Java, telles que les allocations effectuées par le code JNI, les pilotes ou même les allocations JVM.
Dans ce didacticiel de gestion de la mémoire, je vais me concentrer sur les fuites de tas Java et décrire une approche pour détecter ces fuites basée sur les rapports Java VisualVM et en utilisant une interface visuelle pour analyser les applications basées sur la technologie Java pendant leur exécution.
Mais avant de pouvoir prévenir et détecter les fuites de mémoire, vous devez comprendre comment et pourquoi elles se produisent. ( Remarque : si vous maîtrisez bien les subtilités des fuites de mémoire, vous pouvez passer à autre chose. )
Fuites de mémoire : une introduction
Pour commencer, considérez les fuites de mémoire comme une maladie et l' OutOfMemoryError
(OOM, pour la brièveté) de Java comme un symptôme. Mais comme pour toute maladie, tous les OOM n'impliquent pas nécessairement des fuites de mémoire : un OOM peut se produire en raison de la génération d'un grand nombre de variables locales ou d'autres événements de ce type. D'un autre côté, toutes les fuites de mémoire ne se manifestent pas nécessairement sous forme de MOO , en particulier dans le cas d'applications de bureau ou d'applications clientes (qui ne sont pas exécutées très longtemps sans redémarrage).
Pourquoi ces fuites sont-elles si graves ? Entre autres choses, les fuites de blocs de mémoire pendant l'exécution du programme dégradent souvent les performances du système au fil du temps, car les blocs de mémoire alloués mais inutilisés devront être échangés une fois que le système manquera de mémoire physique libre. Finalement, un programme peut même épuiser son espace d'adressage virtuel disponible, conduisant au MOO.
Déchiffrer l' OutOfMemoryError
Comme mentionné ci-dessus, le MOO est une indication courante d'une fuite de mémoire. Essentiellement, l'erreur est générée lorsqu'il n'y a pas suffisamment d'espace pour allouer un nouvel objet. Malgré tous les efforts, le ramasse-miettes ne trouve pas l'espace nécessaire et le tas ne peut plus être étendu. Ainsi, une erreur apparaît, ainsi qu'une trace de pile.
La première étape du diagnostic de votre MOO consiste à déterminer la signification réelle de l'erreur. Cela semble évident, mais la réponse n'est pas toujours aussi claire. Par exemple : le MOO apparaît-il parce que le tas Java est plein ou parce que le tas natif est plein ? Pour vous aider à répondre à cette question, analysons quelques-uns des messages d'erreur possibles :
java.lang.OutOfMemoryError: Java heap space
java.lang.OutOfMemoryError: PermGen space
java.lang.OutOfMemoryError: Requested array size exceeds VM limit
java.lang.OutOfMemoryError: request <size> bytes for <reason>. Out of swap space?
java.lang.OutOfMemoryError: <reason> <stack trace> (Native method)
"Java heap space"
Ce message d'erreur n'implique pas nécessairement une fuite de mémoire. En fait, le problème peut être aussi simple qu'un problème de configuration.
Par exemple, j'étais responsable de l'analyse d'une application qui produisait constamment ce type de OutOfMemoryError
. Après quelques recherches, j'ai compris que le coupable était une instanciation de tableau qui demandait trop de mémoire ; dans ce cas, ce n'était pas la faute de l'application, mais plutôt le serveur d'applications qui s'appuyait sur la taille de tas par défaut, qui était trop petite. J'ai résolu le problème en ajustant les paramètres de mémoire de la JVM.
Dans d'autres cas, et pour les applications à longue durée de vie en particulier, le message peut être une indication que nous détenons involontairement des références à des objets , empêchant le ramasse-miettes de les nettoyer. C'est l'équivalent en langage Java d'une fuite de mémoire . ( Remarque : les API appelées par une application peuvent également contenir involontairement des références d'objet. )
Une autre source potentielle de ces MOO "Java heap space" survient avec l'utilisation de finaliseurs . Si une classe a une méthode finalize
, les objets de ce type ne voient pas leur espace récupéré au moment de la récupération de place. Au lieu de cela, après la récupération de place, les objets sont mis en file d'attente pour la finalisation, qui se produit plus tard. Dans l'implémentation de Sun, les finaliseurs sont exécutés par un thread démon. Si le thread de finalisation ne peut pas suivre la file d'attente de finalisation, le tas Java peut se remplir et un MOO peut être lancé.
"Espace PermGen"
Ce message d'erreur indique que la génération permanente est pleine. La génération permanente est la zone du tas qui stocke les objets de classe et de méthode. Si une application charge un grand nombre de classes, il peut être nécessaire d'augmenter la taille de la génération permanente à l'aide de l'option -XX:MaxPermSize
.
Les objets java.lang.String
sont également stockés dans la génération permanente. La classe java.lang.String
gère un pool de chaînes. Lorsque la méthode interne est appelée, la méthode vérifie le pool pour voir si une chaîne équivalente est présente. Si tel est le cas, il est renvoyé par la méthode interne ; sinon, la chaîne est ajoutée au pool. Plus précisément, la méthode java.lang.String.intern
renvoie la représentation canonique d'une chaîne ; le résultat est une référence à la même instance de classe qui serait renvoyée si cette chaîne apparaissait comme un littéral. Si une application intègre un grand nombre de chaînes, vous devrez peut-être augmenter la taille de la génération permanente.
Remarque : vous pouvez utiliser la commande jmap -permgen
pour imprimer des statistiques liées à la génération permanente, y compris des informations sur les instances String internalisées.
"La taille de la baie demandée dépasse la limite de la VM"
Cette erreur indique que l'application (ou les API utilisées par cette application) a tenté d'allouer un tableau plus grand que la taille du tas. Par exemple, si une application tente d'allouer un tableau de 512 Mo mais que la taille de segment de mémoire maximale est de 256 Mo, un MOO sera généré avec ce message d'erreur. Dans la plupart des cas, le problème est soit un problème de configuration, soit un bogue qui survient lorsqu'une application tente d'allouer une baie massive.
"Demande <taille> octets pour <raison>. Vous n'avez plus d'espace d'échange ? »
Ce message semble être un MOO. Cependant, la machine virtuelle HotSpot lève cette exception apparente lorsqu'une allocation à partir du tas natif a échoué et que le tas natif peut être proche de l'épuisement. Le message comprend la taille (en octets) de la demande qui a échoué et la raison de la demande de mémoire. Dans la plupart des cas, le <reason> est le nom du module source qui signale un échec d'allocation.
Si ce type de MOO est émis, vous devrez peut-être utiliser des utilitaires de dépannage sur votre système d'exploitation pour diagnostiquer davantage le problème. Dans certains cas, le problème peut même ne pas être lié à l'application. Par exemple, vous pouvez voir cette erreur si :
Le système d'exploitation est configuré avec un espace d'échange insuffisant.
Un autre processus sur le système consomme toutes les ressources mémoire disponibles.
Il est également possible que l'application ait échoué en raison d'une fuite native (par exemple, si une partie du code de l'application ou de la bibliothèque alloue en permanence de la mémoire mais ne parvient pas à la libérer au système d'exploitation).
<raison> <trace de la pile> (méthode native)
Si vous voyez ce message d'erreur et que le cadre supérieur de votre trace de pile est une méthode native, cette méthode native a rencontré un échec d'allocation. La différence entre ce message et le précédent est que l'échec d'allocation de mémoire Java a été détecté dans une méthode JNI ou native plutôt que dans le code Java VM.
Si ce type de MOO est émis, vous devrez peut-être utiliser des utilitaires sur le système d'exploitation pour diagnostiquer davantage le problème.
Crash de l'application sans MOO
Parfois, une application peut se bloquer peu de temps après un échec d'allocation à partir du tas natif. Cela se produit si vous exécutez du code natif qui ne vérifie pas les erreurs renvoyées par les fonctions d'allocation de mémoire.
Par exemple, l'appel système malloc
renvoie NULL
s'il n'y a pas de mémoire disponible. Si le retour de malloc
n'est pas vérifié, l'application peut planter lorsqu'elle tente d'accéder à un emplacement mémoire non valide. Selon les circonstances, ce type de problème peut être difficile à localiser.
Dans certains cas, les informations du journal des erreurs fatales ou du vidage sur incident seront suffisantes. Si la cause d'un plantage est déterminée comme étant un manque de gestion des erreurs dans certaines allocations de mémoire, vous devez rechercher la raison de cet échec d'allocation. Comme pour tout autre problème de tas natif, le système peut être configuré avec un espace d'échange insuffisant, un autre processus peut consommer toutes les ressources de mémoire disponibles, etc.
Diagnostiquer les fuites
Dans la plupart des cas, le diagnostic des fuites de mémoire nécessite une connaissance très détaillée de l'application en question. Attention : le processus peut être long et itératif.
Notre stratégie pour traquer les fuites de mémoire sera relativement simple :
Identifier les symptômes
Activer la récupération de place détaillée
Activer le profilage
Analyser la trace
1. Identifier les symptômes
Comme indiqué, dans de nombreux cas, le processus Java lèvera éventuellement une exception d'exécution OOM, un indicateur clair que vos ressources mémoire ont été épuisées. Dans ce cas, vous devez faire la distinction entre un épuisement normal de la mémoire et une fuite. Analysez le message du MOO et essayez de trouver le coupable sur la base des discussions fournies ci-dessus.
Souvent, si une application Java demande plus de stockage que le tas d'exécution n'en offre, cela peut être dû à une mauvaise conception. Par exemple, si une application crée plusieurs copies d'une image ou charge un fichier dans un tableau, elle manquera de stockage lorsque l'image ou le fichier est très volumineux. Il s'agit d'un épuisement normal des ressources. L'application fonctionne comme prévu (bien que cette conception soit clairement stupide).
Mais si une application augmente régulièrement son utilisation de la mémoire tout en traitant le même type de données, vous pouvez avoir une fuite de mémoire.
2. Activer la récupération de place détaillée
L'un des moyens les plus rapides d'affirmer que vous avez effectivement une fuite de mémoire est d'activer la récupération de place détaillée. Les problèmes de contrainte de mémoire peuvent généralement être identifiés en examinant les modèles dans la sortie verbosegc
.

Plus précisément, l'argument -verbosegc
vous permet de générer une trace chaque fois que le processus de récupération de place (GC) est lancé. C'est-à-dire que, pendant que la mémoire est récupérée, des rapports récapitulatifs sont imprimés à l'erreur standard, vous donnant une idée de la façon dont votre mémoire est gérée.
Voici une sortie typique générée avec l'option –verbosegc
:
Chaque bloc (ou strophe) de ce fichier de trace GC est numéroté dans l'ordre croissant. Pour donner un sens à cette trace, vous devez examiner les strophes d'échec d'allocation successives et rechercher la mémoire libérée (octets et pourcentage) diminuant au fil du temps tandis que la mémoire totale (ici, 19725304) augmente. Ce sont des signes typiques d'épuisement de la mémoire.
3. Activer le profilage
Différentes JVM offrent différentes façons de générer des fichiers de trace pour refléter l'activité du tas, qui incluent généralement des informations détaillées sur le type et la taille des objets. C'est ce qu'on appelle le profilage du tas .
4. Analysez la trace
Cet article se concentre sur la trace générée par Java VisualVM. Les traces peuvent se présenter sous différents formats, car elles peuvent être générées par différents outils de détection de fuites de mémoire Java, mais l'idée sous-jacente est toujours la même : trouver un bloc d'objets dans le tas qui ne devrait pas s'y trouver, et déterminer si ces objets s'accumulent. au lieu de relâcher. Les objets transitoires qui sont connus pour être alloués chaque fois qu'un certain événement est déclenché dans l'application Java sont particulièrement intéressants. La présence de nombreuses instances d'objets qui ne devraient exister qu'en petites quantités indique généralement un bogue de l'application.
Enfin, la résolution des fuites de mémoire vous oblige à revoir votre code en profondeur. Connaître le type d'objet qui fuit peut être très utile et accélérer considérablement le débogage.
Comment fonctionne la récupération de place dans la JVM ?
Avant de commencer notre analyse d'une application avec un problème de fuite de mémoire, regardons d'abord comment fonctionne la récupération de place dans la JVM.
La JVM utilise une forme de ramasse-miettes appelé collecteur de traçage , qui fonctionne essentiellement en mettant en pause le monde qui l'entoure, en marquant tous les objets racine (objets référencés directement par les threads en cours d'exécution) et en suivant leurs références, en marquant chaque objet qu'il voit en cours de route.
Java implémente ce qu'on appelle un ramasse-miettes générationnel basé sur l'hypothèse de l'hypothèse générationnelle, qui stipule que la majorité des objets créés sont rapidement supprimés et que les objets qui ne sont pas collectés rapidement sont susceptibles d'être présents pendant un certain temps .
Sur la base de cette hypothèse, Java partitionne les objets en plusieurs générations. Voici une interprétation visuelle :
Jeune génération - C'est là que les objets commencent. Il comporte deux sous-générations :
Eden Space - Les objets commencent ici. La plupart des objets sont créés et détruits dans l'espace Eden. Ici, le GC fait des GC mineurs , qui sont des récupérations de place optimisées. Lorsqu'un GC mineur est effectué, toutes les références aux objets qui sont encore nécessaires sont migrées vers l'un des espaces survivants (S0 ou S1).
Espace survivant (S0 et S1) - Les objets qui survivent à Eden se retrouvent ici. Il y en a deux, et un seul est utilisé à un moment donné (à moins que nous n'ayons une grave fuite de mémoire). L'un est désigné comme vide , et l'autre comme vivant , en alternance avec chaque cycle GC.
Génération titulaire - Également connue sous le nom d'ancienne génération (ancien espace sur la figure 2), cet espace contient des objets plus anciens avec des durées de vie plus longues (déplacés des espaces survivants, s'ils vivent assez longtemps). Lorsque cet espace est rempli, le GC fait un Full GC , ce qui coûte plus cher en termes de performances. Si cet espace augmente sans limite, la JVM lèvera un
OutOfMemoryError - Java heap space
.Génération permanente - Troisième génération étroitement liée à la génération permanente, la génération permanente est spéciale car elle contient les données requises par la machine virtuelle pour décrire des objets qui n'ont pas d'équivalence au niveau du langage Java. Par exemple, les objets décrivant les classes et les méthodes sont stockés dans la génération permanente.
Java est suffisamment intelligent pour appliquer différentes méthodes de récupération de place à chaque génération. La jeune génération est gérée à l'aide d'un collecteur de traçage et de copie appelé Parallel New Collector . Ce collectionneur arrête le monde, mais comme la jeune génération est généralement peu nombreuse, la pause est courte.
Pour plus d'informations sur les générations JVM et leur fonctionnement plus détaillé, consultez la section Gestion de la mémoire dans la documentation Java HotSpot Virtual Machine.
Détection d'une fuite de mémoire
Pour trouver les fuites de mémoire et les éliminer, vous avez besoin des outils de fuite de mémoire appropriés. Il est temps de détecter et de supprimer une telle fuite à l'aide de Java VisualVM.
Profilage à distance du tas avec Java VisualVM
VisualVM est un outil qui fournit une interface visuelle pour afficher des informations détaillées sur les applications basées sur la technologie Java pendant leur exécution.
Avec VisualVM, vous pouvez afficher les données relatives aux applications locales et à celles qui s'exécutent sur des hôtes distants. Vous pouvez également capturer des données sur les instances logicielles JVM et enregistrer les données sur votre système local.
Afin de bénéficier de toutes les fonctionnalités de Java VisualVM, vous devez exécuter Java Platform, Standard Edition (Java SE) version 6 ou supérieure.
Activation de la connexion à distance pour la JVM
Dans un environnement de production, il est souvent difficile d'accéder à la machine réelle sur laquelle notre code sera exécuté. Heureusement, nous pouvons profiler notre application Java à distance.
Tout d'abord, nous devons nous accorder un accès JVM sur la machine cible. Pour ce faire, créez un fichier appelé jstatd.all.policy avec le contenu suivant :
grant codebase "file:${java.home}/../lib/tools.jar" { permission java.security.AllPermission; };
Une fois le fichier créé, nous devons activer les connexions à distance à la VM cible à l'aide de l'outil jstatd - Virtual Machine jstat Daemon, comme suit :
jstatd -p <PORT_NUMBER> -J-Djava.security.policy=<PATH_TO_POLICY_FILE>
Par exemple:
jstatd -p 1234 -J-Djava.security.policy=D:\jstatd.all.policy
Avec le jstatd démarré dans la machine virtuelle cible, nous pouvons nous connecter à la machine cible et profiler à distance l'application avec des problèmes de fuite de mémoire.
Connexion à un hôte distant
Sur la machine cliente, ouvrez une invite et tapez jvisualvm
pour ouvrir l'outil VisualVM.
Ensuite, nous devons ajouter un hôte distant dans VisualVM. Comme la JVM cible est activée pour autoriser les connexions à distance à partir d'une autre machine avec J2SE 6 ou supérieur, nous démarrons l'outil Java VisualVM et nous nous connectons à l'hôte distant. Si la connexion avec l'hôte distant a réussi, nous verrons les applications Java qui s'exécutent dans la JVM cible, comme illustré ici :
Pour exécuter un profileur de mémoire sur l'application, il suffit de double-cliquer sur son nom dans le panneau latéral.
Maintenant que nous sommes tous configurés avec un analyseur de mémoire, examinons une application avec un problème de fuite de mémoire, que nous appellerons MemLeak .
MemLeak
Bien sûr, il existe plusieurs façons de créer des fuites de mémoire en Java. Pour plus de simplicité, nous définirons une classe comme étant une clé dans un HashMap
, mais nous ne définirons pas les méthodes equals() et hashcode().
Un HashMap est une implémentation de table de hachage pour l'interface Map, et en tant que tel, il définit les concepts de base de clé et de valeur : chaque valeur est liée à une clé unique, donc si la clé d'une paire clé-valeur donnée est déjà présente dans le HashMap, sa valeur actuelle est remplacée.
Il est obligatoire que notre classe de clé fournisse une implémentation correcte des méthodes equals()
et hashcode()
. Sans eux, il n'y a aucune garantie qu'une bonne clé sera générée.
En ne définissant pas les méthodes equals()
et hashcode()
, nous ajoutons la même clé au HashMap encore et encore et, au lieu de remplacer la clé comme il se doit, le HashMap grandit continuellement, ne parvenant pas à identifier ces clés identiques et lançant une OutOfMemoryError
.
Voici la classe MemLeak :
package com.post.memory.leak; import java.util.Map; public class MemLeak { public final String key; public MemLeak(String key) { this.key =key; } public static void main(String args[]) { try { Map map = System.getProperties(); for(;;) { map.put(new MemLeak("key"), "value"); } } catch(Exception e) { e.printStackTrace(); } } }
Remarque : la fuite mémoire n'est pas due à la boucle infinie de la ligne 14 : la boucle infinie peut conduire à un épuisement des ressources, mais pas à une fuite mémoire. Si nous avions correctement implémenté les méthodes equals()
et hashcode()
, le code fonctionnerait bien même avec la boucle infinie car nous n'aurions qu'un seul élément dans le HashMap.
(Pour les personnes intéressées, voici quelques moyens alternatifs de générer (intentionnellement) des fuites.)
Utilisation de Java VisualVM
Avec Java VisualVM, nous pouvons surveiller la mémoire du tas Java et déterminer si son comportement indique une fuite de mémoire.
Voici une représentation graphique de l'analyseur Java Heap de MemLeak juste après l'initialisation (rappelez-vous notre discussion sur les différentes générations):
Après seulement 30 secondes, l'ancienne génération est presque pleine, ce qui indique que, même avec un GC complet, l'ancienne génération ne cesse de croître, signe clair d'une fuite de mémoire.
Un moyen de détecter la cause de cette fuite est illustré dans l'image suivante ( cliquez pour zoomer ), générée à l'aide de Java VisualVM avec un heapdump . Ici, nous voyons que 50% des objets Hashtable$Entry sont dans le tas , tandis que la deuxième ligne nous pointe vers la classe MemLeak . Ainsi, la fuite mémoire est causée par une table de hachage utilisée au sein de la classe MemLeak .
Enfin, observez le Java Heap juste après notre OutOfMemoryError
dans lequel les générations Young et Old sont complètement pleines .
Conclusion
Les fuites de mémoire font partie des problèmes d'application Java les plus difficiles à résoudre, car les symptômes sont variés et difficiles à reproduire. Ici, nous avons décrit une approche étape par étape pour découvrir les fuites de mémoire et identifier leurs sources. Mais surtout, lisez attentivement vos messages d'erreur et faites attention à vos traces de pile - toutes les fuites ne sont pas aussi simples qu'elles le paraissent.
appendice
Outre Java VisualVM, plusieurs autres outils peuvent effectuer une détection des fuites de mémoire. De nombreux détecteurs de fuites fonctionnent au niveau de la bibliothèque en interceptant les appels aux routines de gestion de la mémoire. Par exemple, HPROF
, est un simple outil de ligne de commande fourni avec Java 2 Platform Standard Edition (J2SE) pour le profilage de tas et de CPU. La sortie de HPROF
peut être analysée directement ou utilisée comme entrée pour d'autres outils comme JHAT
. Lorsque nous travaillons avec des applications Java 2 Enterprise Edition (J2EE), il existe un certain nombre de solutions d'analyse de vidage de tas qui sont plus conviviales, telles que IBM Heapdumps pour les serveurs d'applications Websphere.