Comprendre et optimiser le Garbage Collector

Romain Guy (Gfx)

Vous allez découvrir le Garbage Collector de Java, fort malhabilement nommé ramasse-miettes en français. Cet article explique comment la machine virtuelle gère la mémoire et comment fonctionne le Garbage Collector. En le maitrisant, vous pourrez éviter des utilisations abusives de la mémoire et optimiser vos applications.

- Titre : Comprendre et optimiser le Garbage Collector

- Auteur : Romain Guy (Gfx)

- Parution : 27 juillet 2006

- Mise à jour : 8 novembre 2019

- Licence : Copyright ® 2006 Developpez LLC. Tous droits réservés Developpez LLC. Aucune reproduction, même partielle, ne peut être faite de ce site et de l'ensemble de son contenu : textes, documents et images sans l'autorisation expresse de Developpez LLC. Sinon vous encourez selon la loi jusqu'à trois ans de prison et jusqu'à 300 000 € de dommages et intérêts.

Sommaire

- I. Comprendre et optimiser le Garbage Collector

I - Comprendre et optimiser le Garbage Collector

Le Garbage Collector, que je nommerai par la suite GC pour ne fatiguer ni vos yeux ni mes doigts, est une sentinelle bienveillante présente dans toutes les machines virtuelles. Son rôle consiste à identifier puis à libérer les zones de mémoire inutilisées par l'application en cours. Ceci explique en partie son affreux nom de ramasse-miettes. Au cours de son cycle de vie, une application crée une certaine quantité d'objets, c'est-à-dire des données qui consomment de la mémoire, dont la durée de vie varie suivant leur rôle au sein du programme. Par exemple, l'objet correspondant à la fenêtre de votre navigateur Internet possède, en simplifiant, une durée de vie équivalente à celle de l'application. À l'inverse l'objet représentant le logo #ProgX sur cette page ne vit que le temps que vous passez sur ce site. De manière générale une application crée un grand nombre d'objets à courte durée de vie. Quoi qu'il en soit, vous comprendrez aisément que déterminer et contrôler le cycle de vie de chaque objet dans un programme demande un effort considérable de la part du programmeur.

Pour vous donner une idée de l'incroyable quantité d'objets mis au monde puis assassinés, sachez que la simple lecture d'un fichier dans un éditeur de texte en nécessite 342 997. La simple conversion d'un quintal en kilogrammes dans le logiciel NumericalChameleon entraîne la vie et la mort de 171 896 objets. Même si le programmeur ne doit pas explicitement manipuler dans sa tête autant d'objets une simple erreur de sa part peut se révéler catastrophique. Telle est la cause des fameuses fuites mémoire dont souffrent certains programmes. Celles-ci arrivent quand un grand nombre d'objets créés ne sont jamais détruits : le programme prend de plus en plus de mémoire jusqu'à effondrement. Une bonne gestion de la mémoire est l'une des plus grosses difficultés relatives à des langages comme le C ou le C++. Java, comme SmallTalk ou Python, repose sur un GC dont le rôle est de diviser la quantité de travail du programmeur par deux. Seule la création des objets l'intéressera alors.

Bien qu'extrêmement pratique, un GC n'a rien de magique et entraîne souvent des effets pervers. À l'abri derrière ce bouclier contre la gestion de la mémoire, le développeur peut facilement oublier ses bonnes manières et obtenir des résultats catastrophiques. Il pourra alors blâmer le GC, le langage ou encore la plate-forme. En tâchant de comprendre comment fonctionne le GC vous pourrez dans un premier temps optimiser la machine virtuelle Java (JVM) pour l'adapter aux spécificités de chacune de vos applications. Comme je vous l'ai dit, nous ne verrons pas de code ici, mais, si le sujet vous intéresse toujours, nous pourrons nous tourner vers des détails plus techniques dans une prochaine news.

Les GC existent depuis très très longtemps en informatique et des dizaines d'algorithmes existent. Puisque nous aimons tous être à la page, je ne traiterai ici que du cas des JVM HotSpot 1.4.1 et supérieures de Sun Microsystems. Vous devez comprendre que chaque auteur de JVM peut choisir sa propre stratégie pour le GC. En outre, pour une même version d'une même JVM, certains détails peuvent varier d'une application à l'autre. Ainsi les programmes Java exécutés sous Solaris pourront gagner en performances en s'intéressant de près au problème des threads. Le GC de la machine virtuelle HotSpot qui nous intéresse est appelé generational garbage collector. Il s'agit, en français, d'un ramasse-miettes faisant la différence entre plusieurs générations d'objets. Dans la machine virtuelle, les objets naissent, vivent et meurent dans une zone mémoire appelée heap, ou tas. Ainsi le heap se compose de deux parties correspondant aux deux générations d'objets possibles : le young space et le tenured (ou old) space. Le premier accueille les objets récents, ou enfants, tandis que le second contient les objets à longue durée de vie, les ancêtres. Outre le heap, la JVM exploite une autre zone mémoire appelée perm dans laquelle est archivé le code binaire de chaque classe chargée pour l'exécution du programme. Bien que le perm soit très important pour les applications réalisant beaucoup de générations dynamiques de code, comme les serveurs JSP, nous ne nous y attarderons pas plus longtemps. Consultez plutôt le schéma ci-dessous résumant la structure du heap.

Les espaces virtuels correspondent à de la mémoire disponible pour la JVM, mais non occupée par les données. La taille du young et du tenured space peut en effet varier dans le temps. Cette caractéristique a une importance capitale sur le paramétrage de la JVM. Sur ce schéma se trouvent trois zones supplémentaires, incluses dans le young space : l'éden et deux survivor spaces. Pour comprendre leur utilité, nous allons devoir nous intéresser à présent aux algorithmes utilisés par le GC.

Quand un nouvel objet est alloué sur le tas, la JVM le crée dans l'éden. Les survivor sont pour leur part occupés à tour de rôle. Celui resté libre sert de réceptacle pour favoriser le travail du GC. Lorsque le taux d'occupation du young space devient inquiétant, le GC exécute une minor collection. Pour ce faire, un algorithme de copie, très simple, est utilisé et c'est ici qu'intervient le survivor libre. Le GC va en effet parcourir tous les objets de l'éden et du survivor occupé pour déterminer quels objets sont encore en vie, c'est-à-dire quels objets possèdent encore des références externes. Chacun d'entre eux sera alors copié dans le survivor resté vide. L'avantage majeur de cet algorithme réside dans sa vitesse d'exécution, car il n'y a pas de libération de la mémoire à proprement parler. Après une minor collection, l'éden et un survivor space sont considérés comme libres. Le travail de copie est pour sa part favorisé par une caractéristique des JVM actuelles qui implique que l'ensemble du heap ne forme qu'un seul segment continu de mémoire. Ceci explique notamment pourquoi, sous Windows, le heap ne peut dépasser 1,5 Go environ. Au fil des minor collections, les objets restés en vie passent d'un survivor space à l'autre. Lorsqu'un objet atteint un âge suffisant, déterminé dynamiquement par HotSpot à l'exécution, ou lorsque le survivor space d'accueil n'est plus assez grand, une copie est effectuée vers le tenured space. La plupart des objets naissent et meurent directement dans le young space. Pour obtenir des performances idéales, il est préférable de configurer la JVM pour éviter des copies inutiles du young space au tenured.

Dans ce nouvel espace mémoire les lois ne sont plus les mêmes. Lorsque de la mémoire est requise, une major collection est effectuée à l'aide de l'algorithme Mark-Sweep-Compact. Ce dernier n'est pas très compliqué, mais bien plus gourmand que celui de copie. En résumé, le GC va parcourir tous les objets du heap, marquer les candidats à l'extermination, et parcourir tout le heap de nouveau pour tasser les objets encore en vie et ainsi éviter la fragmentation de la mémoire. Parcourir le heap par deux fois est la cause principale des baisses de performances que l'on peut parfois percevoir dans des applications Java. Pour effectuer son travail, le GC doit en effet interrompre complètement l'exécution de l'application. Vous comprendrez sans peine qu'il est judicieux de réduire la charge du Mark-Sweep-Compact au maximum.

Compte tenu de ces informations, vous pouvez maintenant utiliser les options en ligne de commande suivantes pour la JVM, où taille peut être une taille en octets (32765), en kilooctets (96k), en mégaoctets (32m) ou encore en gigaoctets (2g) :

- -Xms[taille], définit la taille minimale du heap. Ce paramètre permet d'éviter des dimensionnements fréquents du heap si vous savez que votre application utilise beaucoup de mémoire ;

- -Xmx[taille], définit la taille maximum du heap. Ce paramètre est majoritairement utilisé pour les applications serveur qui nécessitent parfois plusieurs gigaoctets de mémoire. Le heap peut varier entre les valeurs spécifiées par -Xms et -Xmx ;

- -XX:NewRatio=[nombre], indique le rapport de taille tenured sur young space. Le nombre 2 donnera par exemple un tenured de 64 Mo et un young de 32 Mo pour un heap global de 96 Mo ;

- -XX:SurvivorRatio=[nombre], indique le rapport de taille entre l'éden et un survivor space. Pour un ratio de 2 et un young space de 64 Mo, l'éden occupera 32 Mo et chaque survivor 16 Mo.

Le choix des valeurs pour chaque paramètre dépend énormément de votre application. Une application créant une grande quantité d'objets à courte durée de vie, un serveur HTTP par exemple, n'aura pas les mêmes besoins qu'une application très statique, comme un économiseur d'écran. Référez-vous au document officiel de Sun pour obtenir une liste complète des paramètres de la JVM HotSpot.

Outre le contrôle du heap, HotSpot permet de modifier le comportement même du GC. Bien que les algorithmes ne changent pas vraiment, il existe trois modes particuliers d'exploitation du GC. Nous savons qu'en temps normal l'exécution de l'application est interrompue pour effectuer les minor et major collections. Ce comportement est très pénible sur des machines multiprocesseurs par exemple. Le premier mode est le GC incrémental. Dans ce cas le GC effectue une partie d'une major collection à chaque minor collection. Les performances globales sont réduites, mais les longues attentes lors des major collections sont éliminées. Comme ce mode peut entraîner une grande fragmentation du heap dans le tenured space, il est conseillé de l'utiliser dans des applications possédant surtout des objets à longue durée de vie. Le deuxième mode correspond au GC parallèle qui utilise plusieurs threads, un par processeur par défaut, pour les minor collections. Ce mode ne trouve pas d'intérêt en dessous de trois processeurs. Le troisième et dernier mode est le GC concurrent. Il permet d'effectuer les major collections de manière incrémentale, mais sans, pour ainsi dire, interrompre l'application. Le GC concurrent peut également bénéficier du traitement parallèle des minor collections. D'après les documents de Sun Microsystems ce GC obtient de bons résultats avec des applications interactives sur architecture monoprocesseur. Voici les options permettant d'utiliser ces différents modes :

- -Xincgc, active le GC incrémental ;

- -XX:+UseParallelGC, active le GC parallèle. Le nombre de threads utilisés peut être modifié à l'aide de l'option -XX:ParallelGCThreads=[nombre] ;

- -XX:+UseConcMarkSweepGC, active le GC concurrent. Les minor collections parallèles peuvent être activés en utilisant conjointement l'option -XX:+UseParNewGC.

Pour mesure l'impact de vos paramètres vous pouvez ajouter l'option -verbose:gc à la ligne de commande exécutant votre application. Vous obtiendrez une trace de l'activité du GC sur la sortie standard. En la redirigeant vers un fichier et en appliquant le script Python suivant sur le résultat, vous pourrez construire d'utiles graphiques dans n'importe quel tableur ainsi qu'en témoigne l'exemple un peu plus loin. Ce script peut être utilisé en précisant un fichier en paramètre ou en le chaînant directement à la commande java. Le résultat produit est un document CSV utilisant des tabulations comme séparateurs.

#!/usr/bin/env python
# -*- coding: ISO-8859-1 -*-

import fileinput
import re

print "%s\t%s\t%s\t%s" % ("Minor", "Major", "Alive", "Freed")

for line in fileinput.input():
match = re.match("\[(Full )?GC (\d+)K->(\d+)K\((\d+)K\), ([^ ]+) secs]", line)
if match is not None:
minor = match.group(1) == "Full " and "0.0" or match.group(5)
major = match.group(1) == "Full " and match.group(5) or "0.0"
alive = match.group(3)
freed = int(match.group(2)) - int(alive)
print "%s\t%s\t%s\t%s" % (minor, major, alive, freed)

Le graphique présente le temps d'exécution de chaque intervention du GC au cours du temps. Pour générer ce graphique, voici les commandes utilisées :

> java -verbose:gc -Xms64m -Xmx128m -XX:NewRatio=2
-XX:+UseConcMarkSweepGC -jar ..\lib\jext-5.0.jar > gclog
> gclog2csv.py gclog > gclog.csv

Nous avons découvert le fonctionnement du garbage collector de Java 1.4.x et 1.5. Je vous avais également donné un petit script pour vous permettre de vérifier l'impact de vos modifications sur les paramètres de la JVM. Eh bien si vous disposez d'un J2SE 5 SDK 1.5 ce script est désormais inutile puisqu'un outil bien plus puissant est fourni : jconsole.

jconsole est un outil graphique de monitoring de la JVM. Il vous permet d'obtenir toutes les informations pratiques la concernant : classpath, libpath, mémoire occupée, nombre de threads, classes chargées, etc. Vous pourrez ainsi étudier graphiquement l'occupation de l'éden, du tenured, des survivor et du young space.

Pour exploiter cet outil, vous devrez utiliser quelques options de la ligne de commande. La méthode la plus simple consiste à faire du monitoring local :

> java
-XX:+PerfBypassFileSystemCheck
-Dcom.sun.management.jmxremote
-jar ../lib/jext-5.0.jar

Dans cet exemple l'option -XX:+PerfBypassFileSystemCheck n'est utile que si vous êtes sous Windows avec un système de fichier FAT32. Pour monitorer votre application à distance, utilisez une ligne de commande semblable à la suivante :

> java
-Dcom.sun.management.jmxremote.authenticate=false
-Dcom.sun.management.jmxremote.ssl=false
-Dcom.sun.management.jmxremote.port=8463
-jar ../lib/jext-5.0.jar

Par défaut le monitoring distant nécessite SSL. Nous l'avons ici désactivé. Nous avons de même supprimé la phase d'authentification. Pour plus d'informations sur ces deux options, je vous conseille de vous référer à la documentation de Sun Microsystems. Une fois votre application lancée en mode monitoring, vous pouvez exécuter jconsole de l'une de ces manières :

# monitoring local, consultez le gestionnaire des tâches
# pour obtenir le PID de la JVM utilisée par votre programme
> jconsole PID
# monitoring distant, par exemple localhost:8463
> jconsole host:port

Deux autres outils intéressants peuvent exploiter le monitoring, jstat et jps. Ce dernier permet de dresser la liste des machines virtuelles en mode monitoring sur une machine. Voici un exemple d'utilisation pour obtenir le PID associé à la machine virtuelle que nous avons lancé précédemment :

> jps
1884 jext-5.0.jar

Nous pouvons donc maintenant utiliser jstat pour lire les statistiques relatives aux classes :

> jstat -class 1884
Loaded  Bytes  Unloaded  Bytes     Time
899  1064,2        4     3,1       0,40

L'outil jstat propose de nombreuses autres options, comme -gc, que vous pouvez consulter en tapant jstat -options. Je vous conseille donc de vous mettre au J2SE 5 SDK 1.5 dès aujourd'hui. Outre de meilleures performances, vous bénéficierez de nouveaux outils très pratiques.