Développons en Java 2.30 | |
Copyright (C) 1999-2022 Jean-Michel DOUDOUX | (date de publication : 15/06/2022) |
|
Niveau : | Confirmé |
Ce chapitre va détailler la gestion de la mémoire dans la plate-forme Java SE.
Celle-ci repose en grande partie sur le ramasse-miettes ou garbage collector (les deux désignations sont utilisées dans ce chapitre) dont le mode de fonctionnement et la mise oeuvre sont largement détaillés dans ce chapitre.
Ce chapitre présente comment obtenir des informations sur la mémoire, sur les différentes exceptions liées à la mémoire et sur les fuites de mémoire.
Ce chapitre contient plusieurs sections :
Le ramasse-miettes est une fonctionnalité de la JVM qui a pour rôle de gérer la mémoire notamment en libérant celle des objets qui ne sont plus utilisés.
La règle principale pour déterminer qu'un objet n'est plus utilisé est de vérifier qu'il n'existe plus aucun autre objet qui lui fait référence. Ainsi un objet est considéré comme libérable par le ramasse-miettes lorsqu'il n'existe plus aucune référence dans la JVM pointant vers cet objet.
Lorsque le ramasse-miettes va libérer la mémoire d'un objet, il a l'obligation d'exécuter un éventuel finalizer définit dans la classe de l'objet. Attention, l'exécution complète de ce finalizer n'est pas garantie : si une exception survient durant son exécution, les traitements sont interrompus et la mémoire de l'objet est libérée sans que le finalizer soit entièrement exécuté.
La mise en oeuvre d'un ramasse-miettes possède plusieurs avantages :
Mais elle possède aussi plusieurs inconvénients :
Le garbage collector a plusieurs rôles :
Le ramasse-miettes s'exécute dans un ou plusieurs threads de la JVM.
Les objets en cours d'utilisation (dont il existe encore une référence) sont considérés comme "vivants". Les objets inutilisés (ceux dont plus aucun autre objet ne possède une référence) sont considérés comme pouvant être libérés. Les traitements pour identifier ces objets et libérer la mémoire qu'ils occupent se nomment garbage collection. Ces traitements sont effectués par le garbage collector ou ramasse-miettes en français.
Le rôle primaire d'un ramasse-miettes est de trouver les objets de la mémoire qui ne sont plus utilisés par l'application et de libérer l'espace qu'ils occupent. Le principe général d'exécution du ramasse-miettes est de parcourir l'espace mémoire, marquer les objets dont il existe au moins une référence de la part d'un autre objet. Tous les objets qui ne sont pas marqués sont éligibles pour récupérer leur mémoire. Leur espace mémoire sera libéré par le ramasse-miettes ce qui augmentera l'espace mémoire libre de la JVM.
Il est important de comprendre comment le ramasse-miettes détermine si un objet est encore utilisé ou pas : un objet est considéré comme inutilisé s'il n'existe plus aucune référence sur cet objet dans la mémoire.
Dans l'exemple ci-dessus, un objet A est créé. Au cours de sa vie, un objet B est instancié et l'objet A possède une référence sur l'objet B. Tant que cette référence existe, l'objet B ne sera pas supprimé par le ramasse-miettes même si l'objet B n'est plus considéré comme utile d'un point de vue fonctionnel. Ce cas de figure est fréquent notamment avec les objets des interfaces graphiques, les listeners ou avec les collections.
L'algorithme le plus basique pour un ramasse-miettes, parcourt tous les objets, marque ceux dont il existe au moins une référence. A la fin de l'opération, tous les objets non marqués peuvent être supprimés de la mémoire. Le gros inconvénient de cet algorithme est que son temps d'exécution est proportionnel au nombre d'objets contenus dans la mémoire. De plus, les traitements de l'application sont arrêtés durant l'exécution du ramasse-miettes.
Plusieurs autres algorithmes ont été développés pour améliorer les performances et diminuer les temps de pauses liés à l'exécution du ramasse-miettes.
Plusieurs considérations doivent être prises en compte dans le choix de l'algorithme à utiliser lors d'une collection par le ramasse-miettes :
serial ou parallel : avec une collection de type serial, une seule tâche peut être exécutée à un instant donné même si plusieurs processeurs sont disponibles sur la machine.
Avec une collection de type parallel, les traitements du garbage collector sont exécutés en concomitance par plusieurs processeurs. Le temps global de traitement est ainsi plus court mais l'opération est plus complexe et augmente généralement la fragmentation de la mémoire
stop the world ou concurrent : avec une collection de type stop the world, l'exécution de l'application est totalement suspendue durant les traitements d'une collection. Stop the world utilise un algorithme assez simple puisque durant ses traitements, les objets ne sont pas modifiés. Son inconvénient majeur est la mise en pause de l'application durant l'exécution de la collection.
Avec une collection de type concurrent, une ou plusieurs collections peuvent être exécutées simultanément avec l'application. Cependant, une collection de type concurrent ne peut pas réaliser tous ses traitements de façon concurrente et doit parfois en réaliser certains sous la forme stop the world.
De plus, l'algorithme d'une collection de type concurrent est beaucoup plus complexe puisque les objets peuvent être modifiés par l'application durant la collection : il nécessite généralement plus de ressources CPU et de mémoire pour s'exécuter.
compacting, non compacting ou copying : une fois la libération de la mémoire effectuée par le garbage collector, il peut être intéressant pour ce dernier d'effectuer un compactage de la mémoire en regroupant les objets alloués d'une part et la mémoire libre de l'autre.
Ce compactage nécessite un certain temps de traitement mais il accélère ensuite l'allocation de mémoire car il n'est plus utile de déterminer quel espace libre utiliser : s'il y a eu compactage, cette espace correspond obligatoirement à la première zone de mémoire libre rendant l'allocation très rapide.
Si la mémoire n'est pas compactée, le temps nécessaire à la collection est réduit mais il est nécessaire de parcourir la mémoire pour rechercher le premier espace de mémoire qui permettra d'allouer la mémoire requise, ce qui augmente les temps d'allocation de mémoire aux nouveaux objets et la fragmentation de cette dernière.
Il existe aussi une troisième forme qui consiste à copier les objets survivants à différentes collections dans des zones de mémoires différentes (copying). Ainsi la zone de création des objets se vide au fur et à mesure, ce qui rend l'allocation rapide. Le copying nécessite plus de mémoire.
Suite à diverses observations, plusieurs constats ont été faits sur la durée de vie des objets d'une application en général :
Il est alors apparu l'idée d'introduire la notion de générations dans le traitement des collections (generational collection). L'idée est de repartir les différents objets dans différentes zones de la mémoire nommées générations selon leur durée de vie. Généralement deux générations principales sont utilisées :
L'utilisation de générations possède plusieurs intérêts :
Il est facile de conclure que le nombre de collections à réaliser sur la Young Generation sera beaucoup plus important que sur la Old Generation. De plus, les collections sur la Young Generation devraient être rapides puisque, vraisemblablement, la taille sera relativement réduite et le nombre d'objets sans référence important. Les collections dans la Young Generation sont appelées collections mineures (minor collections) puisque très rapide.
Si un objet survit à plusieurs collections, il peut être promu (Tenured) dans la Old Generation. Généralement, la taille de la Old Generation est plus importante que celle de la Young Generation. Les collections sur la Old Generation sont généralement plus longues puisque la taille de la génération est plus importante mais elles sont aussi moins fréquentes.
En effet, une collection dans la Old Generation n'intervient en général qu'une fois que l'espace mémoire libre dans cette génération devient faible. Une collection dans la Old Generation étant généralement longue elle est désignée par le terme collection majeure (major collection).
Le but de l'utilisation des générations est de limiter le nombre de collections majeures effectuées.
Le ramasse-miettes ne résout pas tous les problèmes de mémoires :
De plus le ramasse-miettes est un processus complexe qui consomme des ressources et nécessite un temps d'exécution non négligeable pouvant être à l'origine de problèmes de performance.
Une bonne connaissance du mode de fonctionnement du ramasse-miettes est obligatoire pour apporter une solution lorsque celui-ci est à l'origine de goulets d'étranglements lors de l'exécution de l'application.
Le mécanisme d'allocation de mémoire est aussi lié au garbage collector car il nécessite de trouver un espace mémoire suffisant pour les besoins de l'allocation. Ceci implique pour le garbage collector de compacter la mémoire lors de la récupération de celle-ci pour limiter les effets inévitables de fragmentation.
Le garbage collector est un mécanise complexe mais fiable. Bien que complexe, son fonctionnement doit essayer de limiter l'impact sur les performances de l'application notamment en essayant de limiter son temps de traitement et la fréquence de son exécution. Pour atteindre ces objetifs, des travaux sont constamment en cours de développement afin de trouver de nouveaux algorithmes. Il peut aussi être nécessaire d'effectuer un tuning du ramasse-miettes en utilisant les nombreuses options proposées par la JVM.
La performance du garbage collector est intimement liée à la taille de la mémoire qu'il a à gérer. Ainsi, si la taille de la mémoire est petite, le temps de traitement du garbage collector sera court mais il interviendra plus fréquemment. Si la taille de la mémoire est grande, la fréquence d'exécution sera moindre mais le temps de traitement sera long. Le réglage de la taille de la mémoire influe sur les performances du garbage collector et est un des facteurs importants en fonction des besoins de chaque application.
Le ramasse-miettes fait son travail dans la JVM mais il se limite aux instances des objets créés par la machine virtuelle. Cependant, dans une application, il peut y avoir des allocations de mémoire en dehors des instances d'objets Java.
Ceci concerne des ressources natives du système qui sont allouées par un processus hors du contexte Java. C'est notamment le cas lors de l'utilisation de JNI. Dans ce cas, il faut explicitement demander la libération des ressources en invoquant une méthode dédiée car le ramasse-miettes n'a aucun contrôle sur l'espace mémoire de ces entités. Par exemple, certaines classes qui encapsulent des composants de AWT proposent une méthode dispose() qui se charge de libérer les ressources natives du système.
Le traitement du ramasse-miettes dans la Permanent Generation suit des règles particulières :
Pour optimiser les performances du ramasse-miettes, il est nécessaire d'avoir des indicateurs sous la forme de métriques :
L'importance de ces indicateurs dans le tuning du ramasse-miettes dépend du type d'application utilisée, par exemple :
Le choix de l'algorithme utilisé pour les collections mineures et majeures est important pour les performances globales du ramasse-miettes. Il est préférable d'utiliser un algorithme rapide pour la Young Generation et un algorithme privilégiant l'espace pour la old generation.
En général, il faut aussi privilégier la vitesse d'allocation de mémoire pour les nouveaux objets qui sont des opérations à la demande plutôt que la libération de la mémoire qui n'a pas besoin d'intervenir dès que l'objet n'est plus utilisé sauf si la JVM manque de mémoire.
Pour réaliser des applications pointues et permettre leur bonne montée en charge, il est important de comprendre les mécanismes utilisés par la JVM pour mettre en oeuvre le ramasse-miettes car celui-ci peut être à l'origine de fortes dégradations des performances.
L'algorithme le plus simple d'un ramasse-miettes parcourt tous les objets pour déterminer ceux dont il n'existe plus aucune référence. Ceux-ci peuvent alors être libérés. Ce temps de traitement du ramasse-miettes est alors proportionnel au nombre d'objets présents dans la mémoire de la JVM. Ce nombre peut facilement être très important et ainsi dégrader les performances car durant cette opération l'exécution de tous les threads doit être interrompue.
Le fonctionnement du ramasse-miettes de la JVM Hotspot évolue au fur et à mesure de ses versions et repose sur plusieurs concepts :
L'idée est toujours de réduire la fréquence des invocations et les temps de traitements du ramasse-miettes.
De nombreux paramètres permettent de configurer le comportement du ramasse-miettes de la JVM.
Depuis Java 1.2, le ramasse-miettes implémente plusieurs algorithmes et utilise la notion de génération. Les ingénieurs ont constaté que d'une façon générale, il y a deux grandes typologies d'objets créés dans une application :
La notion de génération est issue de l'observation du mode de fonctionnement de différentes typologies d'applications relativement à la durée de leurs objets. Ainsi, il a été constaté que de nombreux objets avaient une durée de vie relativement courte.
La notion de génération divise la mémoire de la JVM en différentes portions qui vont contenir des objets en fonction de leur âge. Une grande majorité des objets sont créés dans la generation des objets jeunes (Young Generation) et meurent dans cette génération.
Ainsi le tas est découpé en générations dans lesquelles les objets sont passés au fur et à mesure de l'allongement de leur durée de vie :
La JVM dispose aussi d'une troisième génération nommée Permanent Generation qui contient des données nécessaires au fonctionnement de la JVM comme par exemple la description de chaque classes et le code de chaque méthodes.
Sauf pour l'algorithme throughput collector, le découpage de la mémoire de la JVM est généralement sous la forme ci-dessous
La Young Generation est composée de trois parties :
La taille de la Young Generation doit être suffisante pour permettre à un maximum d'objets d'être libérables entre deux collections mineures.
La mémoire d'une JVM Hotspot est composée de trois générations :
L'organisation des générations est généralement la suivante (sauf pour l'algorithme parallel collector)
Au démarrage de la JVM, tout l'espace mémoire maximum n'est pas physiquement alloué et l'espace de mémoire utilisable en cas de besoin est dit virtuel.
L'espace mémoire utilisé pour stocker les instances d'objets est nommé tas (heap). Il est composé de la Young Generation et Tenured Generation
La Young Generation est composée de plusieurs espaces :
Lorsque la Young Generation est remplie, une collection mineure est exécutée par le ramasse-miettes. Une collection mineure peut être optimisée puisqu'elle part du prérequis que l'espace occupé par la plupart des objets de la Young Generation va être récupéré lors d'une collection mineure. Comme la durée d'une collection dépend du nombre d'objets utiles, une collection mineure doit s'exécuter rapidement.
Un objet toujours vivant après plusieurs collections mineures est promu dans la Tenured Generation. Si la Tenured Generation est remplie, une collection majeure est exécutée par le ramasse-miettes. Lors d'une collection majeure, le ramasse-miettes opère sur l'intégralité du tas (Young et Tenured Generation). Généralement une collection majeure est beaucoup plus longue qu'une collection mineure puisqu'elle implique beaucoup plus d'objets à traiter.
L'allocation d'objets est aussi soumise à des problématiques multithreads puisque plusieurs threads peuvent demander l'allocation d'objets. Pour tenir compte de ces contraintes et ne pas dégrader les performances, la JVM HotSpot réserve à chaque thread une zone de mémoire de l'espace Eden nommée Thread Local Allocation Buffer (TLAB) dans laquelle les objets du thread sont créés. Une synchronisation est cependant nécessaire si le TLAB est plein et qu'il faut en allouer un supplémentaire au thread. Des fonctionnalités sont mises en place pour limiter l'espace inutilisé des TLAB.
Lorsque la Old Generation ou la Permanent Generation se remplit, une collection majeure est effectuée, impliquant une collection sur toutes les générations.
De même, si l'espace libre de la Old Generation est insuffisant pour stocker les objets promus de la Young Generation, alors l'algorithme de collection de la Old Generation est appliqué sur l'intégralité du tas.
La mémoire de la JVM contient une section nommée génération permanente (Perm Gen). La JVM stocke dans cet espace les classes et leurs méthodes.
Du point de vue d'une collection les instances et les classes sont considérées comme des objets puisque ces deux types d'entités ont une représentation similaire dans la JVM. Le stockage dans la Permanent Generation des classes se justifie par le fait que les classes sont généralement des objets ayant une durée de vie relativement longue. Ainsi pour limiter le travail du ramasse-miettes et pour séparer les entités applicatives de celles de la JVM, les classes sont stockées dans cette generation dédiée.
Les principaux objets qui sont stockés dans la Permanent Generation sont :
Les entités stockées dans cet espace ne sont pas vraiment permanentes sauf si l'option -noclassgc est utilisée lors du lancement de la JVM.
La taille de l'espace Permanent est indépendante de la taille du tas.
Des applications qui chargent de nombreuses classes ont besoin d'un espace plus important pour l'espace Permanent que celui proposé par défaut. La taille de l'espace Permanent peut être précisée en utilisant l'option -XX:PermSize au lancement de la JVM. La valeur fournie à ce paramètre sera utilisée pour définir la taille de l'espace Permanent au lancement de la JVM. Il est possible de définir la taille maximale de l'espace Permanent en utilisant l'option -xx:MaxPermSize au lancement de la JVM.
Si la taille de l'espace Permanent n'est pas assez importante pour les besoins de la JVM, une exception de type OutOfMemoryError est levée avec dans son message une référence au PermGen.
Actuellement, les collections sur la Permanent Generation sont toujours de type serial. Lorsqu'une collection est effectuée sur la Permanent Generation, elle est toujours réalisée avant celle de la Tenured Generation. Un objet de la Permanent Generation ne change jamais de generation suite à une collection.
La version 1.5 de Java SE propose quatre algorithmes d'implémentation pour le ramasse-miettes :
Remarque : L'algorithme incremental low pause collector ou train collector n'est plus supporté depuis la version 1.4.2 de Java.
Tous ces algorithmes reposent sur l'utilisation de générations.
Avec un algorithme de type Serial Collector, les collections de la Young et Tenured Generation sont faites de manière séquentielle, par un seul processeur, à la façon stop the world : ainsi l'exécution de l'application est suspendue durant l'exécution des collections.
Les objets utilisés sont déplacés de l'espace Eden vers l'espace Survivor qui est vide. Les objets qui sont trop gros pour être déplacés dans l'espace Survivor sont directement déplacés dans la Tenured Generation.
Les objets les plus jeunes de l'espace Survivor rempli (Survivor From) sont déplacés dans l'autre espace Survivor en cours de remplissage (Survivor To). Les objets les plus anciens sont déplacés dans la Tenured Generation. A la fin de la collection l'espace Survivor qui était rempli doit être vide. Si l'espace Survivor en cours de remplissage ne peut plus recevoir d'objets alors tous les objets sont directement promus dans la Tenured Generation sans tenir compte de leur âge.
Après le déplacement des objets en cours d'utilisation, tous les objets qui restent dans l'espace Eden et l'espace Survivor qui était rempli sont des objets inutilisés dont l'espace peut être récupéré.
A la fin de la collection, l'espace Eden et l'espace Survivor initialement rempli sont vides. Ainsi dans la Young Generation, seul l'espace Survivor qui a été rempli contient encore des objets. Les deux espaces Survivor échangent leur rôle pour la prochaine collection.
Le Serial Collector utilise un algorithme de type Mark/Sweep/Compact pour traiter la Tenured Generation et la Permanent Generation :
Un traitement similaire est effectué dans la Permanent Generation.
Le Serial Collector est recommandé pour les applications clientes : ces applications sont généralement peu gourmandes en mémoire et le Serial Collector peut facilement effectuer un full GC en moins d'une demi seconde.
Depuis la version 5 de Java, le Serial Collector est choisi par défaut sur des machines de type client. Pour demander l'utilisation du Serial Collector sur des machines de type server, il faut utiliser l'option -XX:+UseSerialGC
Pour profiter des avantages offerts par des machines disposant de plusieurs coeurs ou de plusieurs CPU, le Parallel Collector, aussi appelé Throughput Collector, a été développé. Il permet de réaliser les traitements de la collection en utilisant plusieurs CPU au lieu d'un seul.
Le Parallel Collector utilise une version multithreads de l'algorithme utilisé par le Serial Collector : c'est toujours un algorithme de type stop the world avec déplacement des objets selon leur ancienneté mais ces traitements sont réalisés en parallèle par plusieurs threads. Le temps de ces traitements est ainsi réduit.
Java 5 propose quelques options supplémentaires pour configurer le comportement souhaité du parallel collector notamment en ce qui concerne le temps maximum de pause de l'application et le throughput.
Le temps maximum de pause de l'application souhaité peut être précisé avec l'option -XX:MaxGCPauseMillis=n où n représente une valeur en millisecondes. Le Parallel Collector va tenter de respecter ce souhait en procédant à des ajustements de paramètres mais il n'y a aucune garantie sur sa mise en oeuvre. Par défaut, aucun temps maximum de pause n'est défini.
Le throughput souhaité peut être exprimé avec l'option -XX:GCTimeRatio=n où n entre dans le calcul du ratio entre le temps du ramasse-miettes et le temps de l'application selon la formule 1 / ( 1 + n ).
Exemple
-XX:GCTimeRatio=19 correspond à 5% ( 1 / ( 1 + 19) ) du temps pour le ramasse-miettes
La valeur par défaut est 99, ce qui représente 1% du temps pour le ramasse-miettes.
Si ce souhait n'est pas atteint, l'algorithme va agrandir la taille des générations pour allonger le délai entre deux collections.
Les priorités dans les souhaits pris en compte par l'algorithme sont dans l'ordre :
Pour être pris en compte, les souhaits précédents doivent être atteints avant que l'algorithme ne tente de réaliser le souhait suivant.
Le Parallel Collector utilise les générations et un algorithme similaire à celui du Serial Collector pour le traitement de la Young Generation mais il parallélise ses traitements sur plusieurs threads. Par défaut, le Parallel Collector utilise autant de threads que de processeurs.
Sur une machine avec un seul processeur, le Parallel Collector est moins performant que le Serial Collector notamment à cause du coût de synchronisation des traitements.
Sur une machine avec deux processeurs, le Parallel Collector et le Serial Collector ont des performances similaires.
Le gain en performance sur le temps des collections mineures croît sur des machines avec plus de deux processeurs.
Le nombre de threads utilisés peut être précisé explicitement en utilisant -XX:ParallelGCThreads=n ou n correspond au nombre de threads.
Comme plusieurs threads sont utilisés pour réaliser une collection mineure, il est possible que la Tenured Generation se fragmente. Chaque thread effectue la promotion d'un objet de la Young Generation vers la Tenured Generation dans une portion de cette dernière qui lui est dédiée
Les générations sont organisées de façon particulière dans le tas de la JVM.
A la fin de chaque collection, l'algorithme met à jour ses statistiques et test si les souhaits sont atteints. Si ce n'est pas le cas, l'algorithme va modifier les paramètres du ramasse-miettes et la taille du tas et des générations pour tenter d'atteindre les souhaits exprimés.
Par défaut, la taille d'une generation est augmentée de 20% et réduite de 5%. Ces valeurs peuvent être modifiées en utilisant plusieurs options :
Les demandes explicites d'exécution du ramasse-miettes ne rentrent pas dans le calcul des statistiques.
Si le temps maximum de pause souhaité n'est pas atteint, la taille d'une seule generation est réduite à la fois (celle dont le temps de pause a été le plus long).
Si le throughput souhaité n'est pas atteint, la taille des deux générations est augmentée.
Si la taille minimale et maximale du tas ne sont pas précisées au lancement de la JVM alors elles sont déterminées en fonction de la taille de la mémoire physique de la machine. Par défaut, la taille minimale est égale à la taille de la mémoire / 64. Par défaut, la taille maximale est égale à la plus petite valeur entre taille de la mémoire / 4 et 1 Go.
Le parallel collector lève une exception de type OutOfMemoryError si plus de 98% du temps est passé à exécuter le ramasse-miettes et que moins de 2% du tas est libéré. Cette sécurité peut être désactivée en utilisant l'option -XX:-UseGCOverheadLimit
L'algorithme utilisé par le Parallel Collector dans la Tenured Generation est identique à celui utilisé par le Serial Collector (Mark-Sweep-Compact) réalisé par un seul CPU. Ainsi le temps nécessaire à d'éventuels full GC peut être long.
Le Parallel Collector est intéressant pour des applications exécutées sur une machine avec plusieurs CPU n'ayant pas de contrainte forte sur les temps de pause liés au GC (exemple : des applications de type batch).
L'utilisation du Parallel Collector est intéressante pour des machines disposant de plusieurs processeurs. Le Serial Collector n'utilise qu'un seul thread pour effectuer ses traitements sur la Young Generation alors que le Parallel Collector en utilise plusieurs pour faire les mêmes traitements. Ceci est particulièrement intéressant pour des applications qui utilisent beaucoup de threads et instancient beaucoup d'objets car le temps de traitement du ramasse-miettes pour la Young Generation est réduit.
Le Parallel Compacting Collector est utilisable depuis la version 5 update 6 de Java. Par rapport au Parallel Collector, le Parallel Compacting Collector utilise un algorithme différent et multithread pour le traitement de la Tenured Generation.
L'algorithme utilisé par le Parallel Compacting Collector dans la Young Generation est identique à celui utilisé par le Parallel Collector.
L'algorithme utilisé par le Parallel Compacting Collector dans la Tenured Generation est de type stop the world avec compactage de la mémoire en utilisant plusieurs CPU pour paralléliser ses traitements.
Les traitements du Parallel Compacting Collector comporte trois étapes :
L'utilisation de cet algorithme est intéressante lorsque la machine dispose de plusieurs CPU. Par rapport au Parallel Collector, il permet de réduire les temps de pause de l'application.
Pour utiliser le Parallel Compacting Collector, il faut utiliser l'option -XX:+UseParallelOldGC de la JVM.
Le nombre de threads utilisés pour les traitements en parallèle peut être limité en utilisant l'option -XX:ParallelGCThreads=n. Ceci peut être utile notamment sur de gros serveurs afin que la JVM ne monopolise pas trop de CPU.
De nombreuses applications ont besoin de la meilleure réactivité possible. Généralement, la collection de la Young Generation est assez rapide. Par contre la collection de la Tenured Generation peut être assez longue d'autant que cette durée est en relation avec la taille du tas. L'algorithme CMS collector aussi nommé low latency collector permet de réduire la durée d'une collection de la Tenured Generation en effectuant une partie de ses traitements de façon concurrente avec ceux de l'application.
Le CMS collector a un mode de fonctionnement similaire au Serial Collector mais certains traitements réalisés dans la Tenured Generation sont faits dans un ou plusieurs threads dédiés, donc de façon concurrente à l'exécution de l'application. Le but est de réduire les temps de pause de l'application qui sont requis pour les collections de la Tenured Generation.
L'algorithme utilisé par le CMS collector dans la Young Generation est identique à celui utilisé par le Parallel Collector (plusieurs threads sont utilisés pour réaliser les traitements).
Une collection réalisée par le CMS collector comporte plusieurs étapes :
Le schéma ci-dessous illustre le fonctionnement du Serial Collector et du CMS collector.
L'étape initial mark est une pause relativement courte. Le temps des traitements concurrents peut être relativement long. Le temps de traitements de la seconde étape provoquant une pause, nommée remark, dépend de l'activité de modification des objets par l'application durant l'étape concurrent mark.
L'exécution concurrente de certains traitements du ramasse-miettes avec l'application consomme des ressources CPU qui ne sont pas affectées à cette dernière. Les traitements du ramasse-miettes effectués en concurrence sont réalisés avec un seul thread. Sur une machine mono processeur, cela va réduire les performances de l'application tendant à ne pas apporter de gain. Sur une machine bi processeur, un processeur est dédié à l'application, l'autre au thread du ramasse-miettes. Plus le nombre de processeurs est important dans la machine, meilleur est le gain en utilisant le CMS Collector.
Il est possible de demander l'utilisation du mode incremental dans lequel les traitements réalisés en concurrence avec l'application sont faits de façon incrémentale sur une petite période entre chaque collection sur la Young Generation. Ceci permet de donner plus de temps de traitement à l'application. Ce mode peut être utile notamment sur des machines ayant un ou deux processeurs.
Les traitements du CMS Collector dans la Tenured Generation sont censés être réalisés avant que cette génération ne soient pleine. Il peut arriver que la génération soit remplie avant la fin des traitements : dans ce cas, l'application est mise en pause pour permettre l'exécution complète des traitements du ramasse-miettes.
L'exécution de certains traitements en parallèle implique une surcharge de travail pour l'algorithme notamment durant l'étape Remark.
Le CMS collector est le seul algorithme qui ne compacte pas la mémoire après la libération des objets inutilisés. Ceci permet d'économiser du temps de traitements lors des collections mais complexifie l'allocation de mémoire pour de nouveaux objets. Dans le cas d'un compactage, il est facile de connaître le prochain espace mémoire à utiliser puisqu'il correspond à la première adresse mémoire libre de la génération. Sans compactage, il faut gérer une liste des espaces de mémoire disponible (adresse et quantité de mémoire continue). A chaque instanciation, il faut rechercher dans la liste un espace mémoire adéquat et mettre à jour la liste ce qui rend l'instanciation d'un objet dans la Tenured Generation plus lente. Ce mécanisme nécessite donc aussi plus d'espace mémoire dans la JVM.
Ceci ralentit aussi les traitements de collection sur la Young Generation puisque la plupart des allocations de mémoire dans la Tenured Generation sont réalisées par les collections lors de la promotion des objets de la Young Generation.
De part son mode de fonctionnement, le CMS collector requiert plus de mémoire que les autres collectors pour son propre usage mais aussi parce que l'application peut créer de nouveaux objets pendant l'exécution d'une partie des traitements du ramasse-miettes.
Bien que l'algorithme garantisse que tous les objets utilisés soient marqués, il est possible que certains objets marqués soient devenus inutilisés du fait de l'exécution en concurrence de l'application. Dans ce cas, l'espace mémoire de ces objets ne sera pas récupéré durant la collection en cours mais le sera à la prochaine collection. L'ensemble de ces objets est nommé floating garbage.
Pour limiter la fragmentation de la mémoire liée au fait que la génération n'est pas compactée, le CMS collector peut fusionner des espaces de mémoires contigus devenus disponibles.
Contrairement aux autres algorithmes de collections, le CMS collector n'attend pas que la génération soit pleine pour commencer une collection. Il tente d'anticiper son exécution afin d'éviter que cela ne survienne sinon son temps de traitement serait supérieur à celui d'un serial ou parallel collector. Avec un algorithme de type CMS collector, une collection doit être démarrée de telle sorte que les traitements de la collection soient terminés avant que la Old Generation ne soit entièrement remplie.
Le démarrage de la collection peut être lancé selon deux facteurs :
Le CMS collector calcule des statistiques basées sur le fonctionnement de l'application pour tenter d'estimer le temps nécessaire avant que la Old Generation ne soit remplie et le temps nécessaire aux cycles pour effectuer la collection.
Sur la base des statistiques, les cycles de traitements de la collection sont démarrés avec pour objectif que ceux-ci soient terminés avant que la Old Generation ne soit pleine.
Il est possible que les estimations provoquent un démarrage trop tardif de la collection ce qui fait rentrer l'algorithme dans un mode nommé concurrent mode failure qui est très coûteux car les temps de pauses sont alors beaucoup plus longs, ce qui a un effet inverse à celui escompté par l'algorithme sur l'exécution de l'application.
Malheureusement, le calcul de statistiques sur des traitements ayant eu lieu n'est pas toujours le reflet de ce qui se passe ou va se passer. Si de trop nombreux full garbages sont exécutés les uns à la suite des autres, il est possible de modifier plusieurs paramètres pour tenter d'y remédier :
Remarque : ces différentes modifications doivent être faites les unes à la suite des autres, dans l'ordre indiqué jusqu'à ce que le problème disparaisse.
Le traitement de la Young Generation peut avoir lieu en concurrence avec le traitement de la Old Generation. Comme le traitement de la Young Generation est similaire à celui utilisé par le Parallel Collector en utilisant un algorithme de type stop the world, les threads de traitements de la Tenured Generation sont interrompus
Les pauses liées à une collection sur la Young Generation et la Old Generation sont indépendantes mais peuvent survenir l'une à la suite de l'autre ce qui peut donc allonger le temps de pause de l'application. Pour éviter ce phénomène, l'algorithme tente d'exécuter l'étape remark entre deux pauses liées à la collection de la Young Generation.
La ramasse-miettes CMS est deprecated en Java 9.
Le mode incremental permet d'utiliser le CMS collector sur des machines avec un seul processeur.
Par défaut, l'algorithme du CMS collector utilise un ou plusieurs threads pour exécuter ses traitements concurrents en dédiant ces threads à ses activités puisque l'algorithme est prévu pour fonctionner sur des machines avec plusieurs CPU.
L'utilisation de cet algorithme peut cependant être intéressante sur des machines avec uniquement un ou deux processeurs. Dans ce cas, l'algorithme propose le mode incremental "i-cms" qui découpe les traitements concurrents de l'algorithme en plusieurs morceaux (duty cycle) exécutés entre les pauses des collections mineures. En dehors de ces cycles, le ou les threads sont suspendus pour permettre au processeur d'exécuter d'autres threads. Le temps d'exécution des cycles est calculé par défaut par l'algorithme en fonction du comportement de l'application dans la JVM (automatic pacing).
Ainsi, le mode incrémental permet de réduire l'impact des traitements concurrents sur l'application en rendant périodiquement la main au processeur pour exécuter l'application. Ces traitements sont découpés en petites unités qui sont exécutées entre deux collections sur la Young Generation.
L'option -XX:+CMSIncrementalMode permet de demander l'utilisation du mode incrémental : dans ce mode, les traitements réalisés de façon concurrente partagent leur temps d'exécution selon des cycles interrompus par un retour à l'exécution de l'application par le processeur. Le temps d'exécution alloué à un cycle est défini par un pourcentage de temps du processeur accordé pour la collection. Ce pourcentage peut être précisé comme attribut fourni à la JVM ou calculé par l'algorithme en fonction du comportement de l'application.
L'option -XX:+CMSIncrementalPacing de la JVM permet de demander de calculer le temps du cycle en fonction de statistiques issues du comportement de l'application. Par défaut, cette option n'est pas activée en Java 5 et est activée en Java 6.
L'option -XX:CMSIncrementalDutyCycle permet de préciser le pourcentage du temps accordé pour les traitements de l'algorithme entre deux collections mineures. Si l'option -XX:+CMSIncrementalPacing est activée alors cela représente la valeur initiale. La valeur par défaut est 50 en Java 5, ce qui est généralement trop, et 10 en Java 6.
L'option -XX:CMSIncrementalDutyCycleMin permet de préciser le pourcentage du temps minimum accordé pour les traitements de l'algorithme lorsque l'option -XX:+CMSIncrementalPacing est activée. La valeur par défaut est 10 en Java 5 et 0 en Java 6.
Les options recommandées pour i-cms avec Java 5 sont :
-XX:+UseConcMarkSweepGC
-XX:+CMSIncrementalMode
-XX:+CMSIncrementalPacing
-XX:CMSIncrementalDutyCycleMin=0
-XX:CMSIncrementalDutyCycle=10
-XX:+PrintGCDetails
-XX:+PrintGCTimeStamps
Les options recommandées pour i-cms avec Java 6 sont :
-XX:+UseConcMarkSweepGC
-XX:+CMSIncrementalMode
-XX:+PrintGCDetails
-XX:+PrintGCTimeStamps
En fait, ce sont les mêmes options mais les valeurs par défaut, non précisées en Java 6, sont celles recommandées avec Java 5.
Le mode incrémental de CMS est deprecated en Java 8 et retiré en Java 9.
Cet algorithme réduit les temps de pause de l'application liés à son activité en exécutant une partie de celle-ci en concurrence avec l'exécution de l'application. Notamment la recherche initiale des objets utilisés est réalisée dans plusieurs threads. Cet algorithme a toujours besoin de temps de pauses mais leurs durées sont réduites grâce à l'exécution de certains traitements en concurrence avec l'application.
Le CMS collector est prévu pour être utilisé dans une JVM exécutant une application souhaitant avoir de faibles temps de pause et qui permette de partager ses ressources processeurs durant son exécution. Généralement, il s'agit d'pplications avec une grosse Tenured Generation exécutée sur une machine avec plusieurs processeurs. Mais cet algorithme peut aussi être utilisé pour des applications avec une Tenured Generation de petite taille, exécutée sur une machine mono processeur avec le mode incrémental activé.
Le CMS collector est recommandé pour des applications qui ont besoin de temps de pauses liés au ramasse-miettes les plus courts possibles et qui peuvent accepter d'avoir une partie des traitements du ramasse-miettes exécutés en concurrence avec elle. Dans les faits, ce sont des applications avec une Tenured Generation de tailles importantes, exécutée sur une machine avec plusieurs processeurs.
Les applications qui possèdent de nombreux objets ayant une durée de vie assez longue et qui s'exécutent sur une machine multiprocesseur peuvent tirer avantage de ce collector : c'est notamment le cas pour les serveurs ou conteneurs web.
Pour utiliser le i-CMS collector, il faut utiliser l'option -XX:+UseConcMarkSweepGC et pour demander l'utilisation du mode incremental, il faut utiliser l'option -XX:+CMSIncrementalMode.
Les types d'applications qui peuvent être développées en Java et exécutées dans une JVM sont nombreux allant d'une petite applet à une grosse application web ou d'entreprise.
Pour répondre aux besoins variés de ces différents types d'applications, la JVM Hotspot propose plusieurs algorithmes utilisables pour le ramasse-miettes. Depuis Java 5, la JVM définit des paramètres de configuration par défaut du ramasse-miettes en fonction du type de machine et du système d'exploitation. Cependant ces valeurs prédéfinies ne sont pas toujours optimales pour une application donnée et il est parfois nécessaire de définir une autre configuration explicitement.
Java 5 propose une fonctionnalité nommée ergonomics dont le but est de configurer certains éléments de la JVM pour permettre d'obtenir de bonnes performances sans configuration. Cette fonctionnalité repose sur un ensemble de règles qui définissent des valeurs par défaut pour :
Ceci permet d'avoir automatiquement un léger tuning plutôt que d'avoir des valeurs par défaut identiques dans tous les contextes.
La définition de ces valeurs par défaut convient généralement pour des cas standard mais elle n'exclut pas d'avoir à configurer soi-même ces paramètres pour qu'ils correspondent mieux aux besoins de l'application.
A partir de Java 5, la JVM détermine, par défaut, plusieurs options de configuration pour le ramasse-miettes en fonction de la machine et du système d'exploitation. Ces valeurs par défaut conviennent généralement pour la majorité des applications.
Parmi les valeurs déterminées, il y a le mode de fonctionnement de la JVM HotSpot :
Les valeurs par défaut pour le mode client sont :
Les valeurs par défaut pour le mode server sont :
Quel que soit le mode d'utilisation de la VM, si le parallel collector est utilisé les tailles du tas sont :
Depuis Java 5, avant de se lancer dans une adaptation personnalisée du GC, il faut étudier si la configuration par défaut déterminée par la JVM répond aux besoins. Si ce n'est pas le cas, il est possible de la modifier explicitement.
La meilleure méthodologie pour améliorer les performances est de mesurer, analyser, modifier et d'itérer sur ces trois étapes jusqu'à l'obtention d'un résultat satisfaisant.
Si la configuration par défaut définie par la JVM ne répond pas au besoin, une première amélioration peut être de modifier la taille du tas et des générations qu'il contient. Si cela ne convient toujours pas, il est possible d'essayer un autre algorithme pour le ramasse-miettes.
Le choix d'un algorithme pour le ramasse-miettes doit prendre en compte plusieurs facteurs, en particulier :
Voici quelques exemples de recommandations :
Conditions |
Algorithme recommandé |
Si la taille du tas est inférieure à 100Mb |
serial collector -XX:+UseSerialGC |
Si la machine est mono processeur |
serial collector -XX:+UseSerialGC |
Sans contrainte sur les pauses de l'application |
parallel collector -XX:+UseParallelGC ou parallel compacting collector -XX:+UseParallelOldGC |
Limiter le plus possible les temps de pause de l'application |
CMS collector (avec le mode incremental activé si la machine dispose d'un ou deux processeurs) -XX:+UseConcMarkSweepGC |
Dans tous les cas, ces recommandations sont à tester pour valider si l'algorithme proposé répond aux besoins de l'application.
Certaines combinaisons d'algorithmes ne sont pas autorisées : dans ce cas la JVM ne démarre pas et affiche un message d'erreur explicite.
Résultat : |
C:\java\tests>java -XX:+UseConcMarkSweepGC -XX:+UseSerialGC -jar test.jar
Conflicting collector combinations in option list; please refer to the release n
otes for the combinations allowed
Could not create the Java virtual machine.
C:\java\tests>java -XX:+UseConcMarkSweepGC -XX:+UseParallelGC -jar test.jar
Conflicting collector combinations in option list; please refer to the release n
otes for the combinations allowed
Could not create the Java virtual machine.
|
Généralement l'exécution du ramasse-miettes est conditionnée par un manque d'espace libre. Cela permet dans un premier temps de libérer de l'espace. Si cela ne suffit pas alors l'espace mémoire est agrandit jusqu'à atteindre le maximum défini.
La méthode gc() de la classe System permet de demander l'exécution du ramasse-miettes.
Cependant, le moment d'exécution du ramasse-miettes n'est pas facilement prédictible. Même l'appel à la méthode gc() de la classe System n'implique pas obligatoirement l'exécution du ramasse-miettes mais sollicite simplement une demande d'exécution.
Avec certains algorithmes du ramasse-miettes, forcer l'invocation du ramasse-miettes peut être préjudiciable sur les performances notamment pour de grosses applications. L'option -XX:+DisableExplicitGC de la JVM demande à la JVM d'ignorer les demandes explicites d'exécution du ramasse-miettes.
Les Java Langage Specifications imposent au ramasse-miettes d'appeler la méthode de finalisation de l'objet héritée de la classe Object avant de libérer la mémoire. Ainsi, le ramasse-miettes a l'obligation par ces spécifications d'invoquer la méthode finalize() lorsque l'espace mémoire d'une instance d'un objet va être récupéré.
Il est alors facile d'imaginer de mettre des traitements de libération de ressources par exemple dans cette méthode puisque la JVM garantit l'appel de cette méthode lorsque l'objet n'est plus utilisé.
Malheureusement, seule l'invocation est garantie : l'exécution de cette méthode dans son intégralité ne l'est pas notamment si une exception est levée durant ses traitements.
De plus, le moment où l'espace mémoire va être récupéré et donc le moment où la méthode finalize() sera invoquée n'est pas prédictible.
Pour ces deux raisons, il ne faut surtout pas utiliser la méthode finalize() pour libérer des ressources.
Généralement une bonne pratique est de ne pas faire usage dans la mesure du possible de la méthode finalize() et de ne surtout pas utiliser le garbage collector pour faire autre chose que la libération de la mémoire.
En Java 9, la méthode finalize() dépréciée.
La JVM Hotpsot propose de nombreux paramètres relatifs à l'activité du ramasse-miettes.
La JVM propose de nombreuses options pour configurer le ramasse-miettes : elles permettent notamment de modifier la taille du tas et des générations, choisir un algorithme, le configurer et obtenir des informations sur son exécution.
Plusieurs paramètres permettent de configurer la taille des différents espaces mémoire de la JVM.
L'allocation de la mémoire pour la JVM se fait au moyen de plusieurs options préfixées par -X
Option |
Rôle |
-Xms |
Taille initiale du tas (heap) |
-Xmx |
Taille maximale du tas (heap) |
-Xmn |
Taille de la Young Generation du tas |
-Xss |
Taille de la pile (stack) de chaque thread. Si celle-ci est trop petite, une exception de type StackOverFlowError est levée Exemple : -Xss1024k |
Remarque : sur certaines machines utilisant Linux, il est parfois nécessaire de modifier la taille de la pile au niveau des paramètres du système d'exploitation en utilisant la commande ulimit -s.
Plusieurs paramètres non standard sont proposés par la JVM Hotspot pour gérer la taille du tas et des générations.
Option |
Description |
-XX:MinHeapFreeRatio=n |
Définir le ratio minimum d'espace libre du tas avant que sa taille ne soit agrandie. Exemple : si le ratio est de 20 et que le pourcent d'espace libre n'est plus que de 20% alors la taille du tas est agrandie pour qu'il y ait 20% d'espace libre. |
-XX:MaxHeapFreeRatio=n |
Définir le ratio maximum d'espace libre du tas avant que sa taille ne soit réduite. Exemple : si le ratio est de 70 et que le pourcent d'espace libre dépasse les 70% alors la taille du tas est réduite pour qu'il y est 70% d'espace libre. |
-XX:NewSize=n |
Définir la taille initiale de la Young Generation. |
-XX:NewRatio=n |
Définir le ratio entre la Young Generation et la Old Generation. Exemple : Si n vaut 3 alors le ratio est de 1:3. Ainsi la taille de la Young Generation est le quart de celle du tas. |
-XX:SurvivorRatio=n |
Définir le ratio entre les espaces Survivor et Eden dans la Young Generation Exemple : Si n vaut 3 alors le ratio est de 1:3. Ainsi la taille de la Young Generation est le quart de celle du tas. Exemple : si n vaut 6 alors le ratio est 1:8 de la Young Generation (ce n'est pas 1:7 car il y a deux espaces survivor). |
-XX:MaxPermSize=n |
Définir la taille maximale de la Permanent Generation |
Il est tout à fait normal que la taille de la JVM observée sur le système soit supérieure à la taille fournie par l'option -Xmx. La valeur de cette option ne concerne que le tas et non tout l'espace mémoire de la JVM qui inclus aussi entre autres les piles (stack), la permanente generation, ...
Les valeurs à affecter aux options -Xms et -Xmx dépendent de l'application exécutée dans la JVM. La taille minimale doit supporter l'espace requis par l'application. Sur un poste utilisateur il est généralement préférable de mettre des valeurs minimales et maximales différentes pour limiter la consommation de mémoire sur la machine tout en lui permettant de grossir aux besoins. Sur un serveur, il est généralement préférable de mettre la même valeur car les ressources mémoire sont généralement moins limitées et cela évite les allocations de mémoire successives.
Les options pour choisir l'algorithme utilisées par le ramasse-miettes sont :
Option |
Algorithme utilisé par le ramasse-miettes |
-XX:+UseSerialGC |
Serial collector |
-XX:+UseParallelGC |
Parallel collector |
-XX:+UseParallelOldGC |
Parallel compacting collector |
-XX:+UseConcMarkSweepGC |
Concurrent mark-sweep collector (CMS collector) |
Les options pour afficher des informations sur l'exécution du ramasse-miettes sont :
Option |
Description |
-XX:+PrintGC |
Afficher des informations de base à chaque exécution du ramasse-miettes |
-XX:+PrintGCDetails |
Afficher des informations détaillées à chaque exécution du ramasse-miettes |
-XX:+PrintGCTimeStamps |
Afficher la date-heure de début d'exécution du ramasse-miettes |
Le paramètre -verbose:gc permet aussi d'afficher dans la console des informations sur chaque collecte effectuée par le ramasse-miettes.
Les options pour les algorithmes parallel et parallel compacting collector sont :
Option |
Description |
-XX:ParallelGCThreads=n |
Nombre de threads utilisés par le ramasse-miettes (par défaut, c'est le nombre de CPU de la machine) |
-XX:MaxGCPauseMillis=n |
Demander au ramasse-miettes d'essayer de limiter son temps d'exécution à celui fourni en millisecondes en paramètre. |
-XX:GCTimeRatio=n |
Demander au ramasse-miettes d'essayer de respecter un ratio 1/(1+n) du temps total utilisé pour les activités du ramasse-miettes. La valeur par défaut est 99 |
Les principales options pour l'algorithme CMS collector sont :
Option |
Description |
-XX:+CMSIncrementalMode |
Activer le mode d'exécution des traitements concurrents de façon incrémentale |
-XX:+CMSIncrementalPacing |
Activer le calcul automatique de statistiques pour déterminer le temps de traitement du ramasse-miettes dans le mode incrémental. |
-XX:ParallelGCThreads=n |
Nombre de threads utilisés par le ramasse-miettes pour le traitement de la Young Generation et des traitements concurrents de la old generation. |
Le ramasse-miettes peut induire de véritables problèmes de performance pour certaines applications en fonction des besoins de celles-ci et du paramétrage de la JVM.
En cas de problème de performance avec le ramasse-miettes, si la taille du tas doit être modifiée, le plus souvent il faudra aussi adapter la taille de chacune des générations.
En cas de fuite de mémoire ou d'inadéquation de la taille du tas avec les besoins de l'application, il est possible que les performances de la JVM se dégradent très fortement car elle peut occuper une large partie de ses traitements à l'exécution du ramasse-miettes de façon répétée.
Une bonne adéquation entre les besoins de l'application et la configuration de la JVM peut permettre de réduire le temps nécessaire à l'exécution du ramasse-miettes durant l'exécution de l'application.
Plusieurs indicateurs peuvent être utilisés pour mesurer les performances du ramasse-miettes :
Il n'y a pas de règle absolue pour optimiser les performances du ramasse-miettes : cette optimisation est dépendante de l'application exécutée dans la JVM. Par exemple, dans une application standalone, il est très important d'avoir l'indicateur pause le plus court possible alors qu'il n'est généralement pas crucial pour une application de type web.
Les besoins relatifs aux performances du ramasse-miettes dépendent de la typologie des applications exécutées, par exemple :
Cette optimisation doit tenir compte des priorités données à chaque indicateur.
Par exemple, plus la taille de la Young Generation est importante, plus le throughput devrait s'améliorer mais l'empreinte mémoire et les temps de pause augmentent.
A l'inverse, plus la taille de la Young Generation est petite plus les temps de pause sont réduit mais au détriment du throughput.
Il n'y a donc pas qu'une façon d'optimiser la taille des générations mais il faut tenir compte des besoins et des exigences de l'application.
Plusieurs paramètres peuvent avoir une influence sur la taille des générations. Au lancement de la JVM, l'espace entier défini par le paramètre -Xmx du tas est réservé. Si la valeur du paramètre -Xms est plus petite que celle de -Xmx alors seule la quantité indiquée par -Xms est immédiatement disponible pour le tas. Le reste de la mémoire est dite virtuelle : elle sera utilisée au besoin.
Les différentes générations (young, tenured, permanent) peuvent ainsi grossir jusqu'à atteindre leur taille maximale respective. Certaines caractéristiques sont fournies sous la forme de ratio. Par exemple, le paramètre NewRatio définit la proportion de la taille de la young et Tenured Generation dans le tas.
La quantité de mémoire du tas gérée est un facteur important pour les performances du ramasse-miettes.
Par défaut, les traitements du ramasse-miettes peuvent faire grossir ou réduire la taille du tas lors de chaque collection afin de respecter la quantité de mémoire libre souhaitée précisée sous la forme de pourcentage par les paramètres -XX:MinHeapFreeRatio=<minimum> et -XX:MaxHeapFreeRatio=<maximum>
Attention : l'augmentation de la taille de mémoire du tas provoque généralement des effets de bord sur les temps de traitements liés à l'exécution du garbage collector notamment parce que ce dernier est invoqué moins fréquemment mais que ses temps de traitement sont plus longs.
Donner la même valeur au paramètre -Xms et -Xmx permet d'éviter à la JVM de devoir effectuer des calculs sur la taille des différentes régions mais cela empêche aussi la JVM de procéder à des ajustements si les valeurs fournies ne sont pas judicieuses.
Le ratio entre la Young Generation et la Tenured Generation dans le tas est un facteur important dans les performances du ramasse-miettes. Plus le ratio de la Young Generation est important, moins il y a de collections mineures qui sont effectuées. Par contre, cela implique une taille de la Tenured Generation plus importante et donc un accroissement du nombre potentiel de collections majeures. La valeur du ratio entre les deux générations dépend donc du nombre d'objets créés et de la durée de vie de ces objets dans l'application.
La taille de la Young Generation est déterminée par le paramètre NewRatio qui indique le ratio de la young et de la Tenured Generation dans le tas.
La taille de la Young Generation peut être précisée avec le paramètre -XX:NewRatio=n où n représente le ratio entre la Young Generation et la old generation.
Exemple : si n vaut 3, la Young Generation aura une taille de 1/4 de la taille totale du tas.
Le paramètre -XX:NewSize permet de préciser la taille initiale de la Young Generation.
Le paramètre -XX:MaxNewSize permet de préciser la taille maximale de la Young Generation.
La valeur par défaut de l'attribut NewRatio est dépendante de la plate-forme et du mode d'exécution de la VM Hotspot (client ou server).
La définition de la taille des générations du tas peut se faire de plusieurs façons :
Remarque : l'attribut -Xmn est un raccourci pour NewSize
Plus la taille de la Young Generation est importante plus les chances qu'un objet meurt dans la Young Generation et ne soit donc pas promu dans la old generation est élevé. Cependant, il n'est pas recommandé d'avoir une taille très importante pour la Young Generation car cela fera exécuter le ramasse-miettes moins souvent mais son temps d'exécution sera plus long et comme ces traitements sont de type stop the world, les temps de pause de l'application seront plus longs. L'idéal est d'adapter la taille de la génération en fonction de l'application exécutée dans la JVM.
Le paramètre -XX:SurvivorRatio=n permet de préciser le ratio entre l'espace Eden et les deux espaces survivor dans la Young Generation.
Exemple : si n vaut 6 alors le ratio est 1:6. Dans ce cas, chaque espace survivor occupera 1/8 de la taille de la Young Generation (ce n'est pas 1/7 car il y a deux espaces de type survivor)
Si l'espace requit pour copier un objet dans l'espace survivor n'est pas assez grand, alors l'objet est promu directement dans la old generation.
A chaque collection mineure, le ramasse-miettes détermine le nombre de collections qu'un objet doit avoir subi avant d'être promu dans la old generation. Ce nombre est déterminé de façon à ce qu'à la fin de la collection l'espace survivor soit à moitié rempli
L'option -XX:+PrintTenuringDistribution permet de voir la répartition par âges des objets de la Young Generation. Ceci permet de voir la répartition de la durée des objets de la Young Generation.
Il faut tout d'abord décider de la quantité de mémoire qui sera affectée au tas. Ensuite, il est possible de mesurer les performances et d'ajuster la taille de la Young Generation en fonction du comportement de l'application.
Pour la plupart des applications, la Permanent Generation n'influe pas de façon importante sur les performances du ramasse-miettes. Pour des applications qui chargent et/ou génèrent beaucoup de classes, il faut augmenter la taille de cette génération pour éviter des manques de mémoire.
La taille du tas ne permet pas à elle seule de déterminer la quantité de mémoire utilisée par le processus système de la JVM puisque le tas ne représente qu'une partie de la mémoire de la JVM.
Il ne doit donc pas être étonnant que la quantité de mémoire affichée par le système (TaskManager sous Windows ou top sous Unix like par exemple) soit supérieure à la taille maximale du tas précisée avec l'option -Xmx.
|
La suite de cette section sera développée dans une version future de ce document
|
Java 1.2 propose plusieurs types de références qui contiennent une référence particulière sur un objet.
Ces références sont définies dans des classes du package java.lang.ref :
Ces différentes références peuvent être utilisées par le ramasse-miettes pour récupérer de la mémoire au cas où celle-ci commence à manquer.
|
La suite de cette section sera développée dans une version future de ce document
|
La classe Runtime propose deux méthodes pour obtenir des informations basiques sur la mémoire occupée par le tas :
Depuis la version 5 de la JVM, il est aussi possible d'obtenir des informations sur la mémoire en utilisant les MBeans JMX exposés par la JVM.
La libération de la mémoire des objets inutilisés est implicite en Java grâce au ramasse-miettes alors qu'elle est explicite dans d'autres langages (en Pascal avec l'instruction dispose, en C avec l'instruction free, ...). Ceci facilite le travail du développeur puisqu'il n'a pas à libérer explicitement la mémoire des objets.
Il est facile de penser que la libération de mémoire étant assurée par le garbage collector de la JVM, le développeur n'a plus aucune responsabilité à ce sujet et que les fuites de mémoires sont impossibles. Ce raisonnement est le résultat de la méconnaissance du mode de fonctionnement du garbage collector.
Le ramasse-miettes doit s'assurer pour libérer la mémoire d'un objet que celui-ci n'est plus utilisé : pour le déterminer, il recherche s'il existe parmi les objets de la JVM, une référence vers l'objet. Même s'il n'est plus utilisé mais qu'il existe encore une référence sur l'objet, la mémoire de celui-ci n'est pas libérée. Ceci rend la tâche de détection d'une fuite de mémoire particulièrement difficile car il est facile de savoir si un objet est encore utile mais il est difficile de savoir s'il existe encore une référence vers l'objet.
Une mauvaise utilisation de l'API collection par exemple peut notamment favoriser les fuites de mémoires.
Une fuite de mémoire se traduit généralement par une augmentation de la taille du heap pouvant aller jusqu'à un arrêt de la JVM avec une exception de type OutOfMemoryError.
Le premier réflexe lorsqu'une exception de type OutOfMemoryError est levée concernant le tas est d'augmenter la taille de la mémoire de JVM. Cependant, si cette erreur est liée à une fuite de mémoire cela ne fait que reporter sa levée.
L'indicateur le plus visible lors d'une possible fuite de mémoire est la levée d'une exception de type OutOfMemory. La levée de cette exception n'implique pas obligatoirement une fuite mémoire mais peut simplement traduire un manque de mémoire pour permettre l'exécution de l'application.
Cependant, c'est l'issue fatale suite à une fuite de mémoire qui peut être plus ou moins longue. Ceci est particulièrement vrai pour des applications exécutées sur des serveurs car elles ne sont généralement pas redémarrées fréquemment.
Même si c'est un travail long et difficile, il faut toujours traiter une fuite de mémoire avec une grande attention. La solution n'est pas d'augmenter la taille du tas car cela ne fera que reporter l'échéance fatale. La solution n'est pas non plus de redémarrer périodiquement la JVM car généralement les applications concernées doivent avoir un taux de disponibilité élevé.
|
La suite de cette section sera développée dans une version future de ce document
|
Le diagnostique d'une fuite de mémoire dans une application Java est une tâche difficile et longue qui nécessite généralement des outils qui vont permettre de voir quels sont les objets stockés dans la mémoire de la JVM.
Le JDK fournit de plus en plus d'outils pour effectuer ces recherches et donner des indications sur l'origine du problème mais ils sont en ligne de commande et sont donc peu productifs notamment jmap, jhat et jconsole. Leur intérêt est cependant d'être fourni en standard. A partir de Java 6 update 7 l'outil graphique Visual VM propose de regrouper ces fonctionnalités de façon conviviale.
Le paramètre -XX:+HeapDumpOnOutOfMemoryError de la JVM HotSpot permet de demander la création d'un dump du tas au cas où l'exception de type OutOfMemoryError est levée. La commande jmap permet alors de fournir un histogramme en utilisant l'option -histo suivi du fichier contenant le dump.
Le paramètre -XX:+PrintClassHistogram de la JVM HotSpot permet de demander l'affichage dans la console de l'histogramme des classes du tas lorsque la combinaison Ctrl + Arret défil est utilisée.
Il existe aussi plusieurs outils de profiling open source (Eclipse TPTP, Eclipse Mat, Netbeans Profiler, ...) ou commerciaux.
Bien que leur utilisation facilite le travail, la détection d'une fuite de mémoire est souvent délicate car :
La détection d'une fuite de mémoire n'est souvent qu'une hypothèse en cas d'arrêt de la JVM par manque de mémoire. Dans ce cas, la suspicion de fuite de mémoire est conditionnée par l'importance de la fuite, la taille de la mémoire de la JVM et la durée de vie de la JVM.
Exemple : une petite fuite de mémoire dans une application de type client lourd fréquemment relancée ne sera peut être jamais détectée par contre une petite fuite de mémoire dans une application de type web dont la durée de vie est longue a des chances de provoquer tôt ou tard une erreur de type OutOfMemoryError.
Le grand avantage des fuites de mémoire en Java est qu'elles n'ont pas d'impact sur le système d'exploitation. La JVM et donc l'application s'arrête et la mémoire qui lui était allouée est restituée au système.
Le simple fait de regarder, avec les outils du système d'exploitation, la quantité de mémoire consommée par la JVM ne peut en aucun cas fournir d'indication sur une possible fuite de mémoire dans l'application.
La quantité de mémoire indiquée par le système ne contient qu'une partie relative au tas de la JVM. De plus la taille du tas peut varier entre le minimum et le maximum défini. Au démarrage de la VM, l'espace de mémoire du tas alloué correspond à la valeur minimale. Au fur et à mesure des besoins, la taille du tas peut grossir jusqu'à la valeur maximale et cela sans attendre la fin des traitements du ramasse-miettes. Dans ce cas, la quantité de mémoire indiquée par le système grossit mais n'implique pas obligatoirement une fuite de mémoire.
Un outil de type profiler est nécessaire pour permettre d'inspecter le contenu du tas et connaître le nombre d'objets, le nombre d'instances de chaque classe, ...
Même avec ce type d'outil, la recherche d'une fuite de mémoire est un processus généralement long et itératif.
Certaines entités sont propices à la génération de fuites de mémoire :
Les conséquences d'une fuite de mémoire dans une application Java sont généralement moins dramatiques que dans des applications natives dans la mesure où en Java seule la JVM et donc l'application risque de s'arrêter. Une fuite de mémoire peut engendrer un arrêt de la JVM dans laquelle l'application s'exécute mais le système d'exploitation reste opérationnel. Dans une application native; une fuite de mémoire peut aller jusqu'à nécessiter le redémarrage du système d'exploitation si ce dernier n'a plus assez de mémoire.
Les fuites de mémoire dans une application peuvent avoir plusieurs origines dont les plus communes sont :
Le ramasse-miettes invoque la méthode finalize() si celle-ci est implémentée pour un objet avant que son espace mémoire ne soit récupéré. Il n'y a cependant aucune garantie sur la bonne exécution de cette méthode : il ne faut surtout pas s'en servir pour libérer des ressources sous prétexte qu'elle est invoquée automatiquement par le ramasse-miettes.
Deux exceptions différentes peuvent être levées par la JVM selon l'origine du manque de mémoire StackOverFlowError et OutOfMemoryError. Ces deux exceptions ne sont pas checkées mais provoquent un arrêt de la JVM si elles ne sont pas traitées dans un bloc catch durant la remontée de la pile d'appels du thread.
Si la taille de la pile est trop petite alors une exception de type java.lang.StackOverFlowError est levée.
Il y a deux grandes origines au fait que la taille de la pile ne soit pas assez importante :
Une exception de type OutOfMemoryError est assez courante lors de l'exécution d'applications Java : elle indique qu'il n'y a pas l'espace disponible pour créer de nouveaux objets même après l'exécution du ramasse-miettes et qu'il n'y a pas la possibilité d'agrandir la taille du tas.
L'exception OutOfMemoryError peut concerner plusieurs parties de la mémoire de la JVM : cette partie est explicitement indiquée dans le message de l'exception :
Il est donc important de bien prendre en compte le message de l'exception OutOfMemoryError pour pouvoir y apporter une solution.
Une exception de type OutOfMemoryError n'est pas obligatoirement un problème de fuite de mémoire mais simplement une mauvaise adaptation de la configuration de la JVM aux besoins de l'application.
S'il est nécessaire de réduire l'empreinte mémoire de l'application dans la JVM, il faut tenter de réduire le nombre d'objets ou de limiter la durée de vie de certains objets, par exemple :
L'utilisation d'un outil de profiling peut être nécessaire voire obligatoire pour analyser le contenu de la mémoire de la JVM et déterminer l'origine de la consommation mémoire. L'utilisation de ce type d'outils induit forcément un overhead et réduit donc sensiblement les performances. Leur utilisation doit donc être limitée dans un environnement de production.
Une exception de type OutOfMemory est levée avec le message "Java heap space" lorsque l'espace mémoire libre du tas (heap) ne permet plus la création de nouveaux objets malgré l'exécution du ramasse-miettes.
Dans ce cas, l'exception peut avoir plusieurs origines :
Une exception de type OutOfMemory est levée avec le message "PermGen space" lorsque l'espace mémoire alloué à la Permanent Generation n'est pas assez important pour contenir toutes les métadonnées utilisées par la JVM.
C'est généralement pour des applications côté serveur car elles s'exécutent dans un même conteneur et utilisent généralement de nombreuses classes différentes liées à l'utilisation de plusieurs frameworks.
La représentation interne des chaînes de caractères de type constante est aussi stockée dans un pool de la Permanent Generation. Lorsque la méthode intern() de la classe String est invoquée, elle recherche dans le pool si la chaîne existe déjà. Si c'est le cas, elle renvoie celle du pool sinon elle l'ajoute. L'espace mémoire requis pour le Permanent Generation est donc plus imporant lorsqu'une application utilise beaucoup de chaînes de caractères sous la forme de constantes.
La seule solution est alors d'agrandir l'espace mémoire alloué à la Permanent Generation, par exemple en utilisant l'option -XX:MaxPermSize pour une JVM Hotspot.
Une exception de type OutOfMemory est levée avec le message "Requested array size exceeds VM limit" lorsqu'une tentative de création d'un tableau requiert plus de mémoire que l'espace libre du tas.
Si la taille du tableau à créer est normale alors la seule solution est d'augmenter la taille du tas de la JVM.
|