Optimisation des performances de la JVM, partie 3 : Collecte des ordures

Le mécanisme de collecte des ordures de la plateforme Java augmente considérablement la productivité des développeurs, mais un collecteur d’ordures mal implémenté peut surconsommer les ressources des applications. Dans ce troisième article de la série sur l’optimisation des performances de la JVM, Eva Andreasson offre aux débutants en Java une vue d’ensemble du modèle de mémoire et du mécanisme GC de la plate-forme Java. Elle explique ensuite pourquoi la fragmentation (et non la GC) est le principal “gotcha !” des performances des applications Java, et pourquoi le ramassage des ordures générationnel et le compactage sont actuellement les principales approches (bien qu’elles ne soient pas les plus innovantes) pour gérer la fragmentation du tas dans les applications Java.

La collecte des ordures (GC) est le processus qui vise à libérer la mémoire occupée qui n’est plus référencée par aucun objet Java atteignable, et constitue une partie essentielle du système de gestion dynamique de la mémoire de la machine virtuelle Java (JVM). Dans un cycle typique de collecte des déchets, tous les objets qui sont encore référencés, et donc accessibles, sont conservés. L’espace occupé par les objets précédemment référencés est libéré et récupéré pour permettre l’allocation de nouveaux objets.

Pour comprendre la collecte des ordures et les différentes approches et algorithmes de GC, vous devez d’abord savoir quelques choses sur le modèle de mémoire de la plate-forme Java.

Collecte d’ordures et modèle de mémoire de la plate-forme Java

Lorsque vous spécifiez l’option de démarrage -Xmx sur la ligne de commande de votre application Java (par exemple : java -Xmx:2g MyApp), de la mémoire est attribuée à un processus Java. Cette mémoire est appelée le tas Java (ou simplement le tas). Il s’agit de l’espace d’adressage mémoire dédié où tous les objets créés par votre programme Java (ou parfois la JVM) seront alloués. Comme votre programme Java continue de s’exécuter et d’allouer de nouveaux objets, le tas Java (c’est-à-dire cet espace d’adressage) se remplira.

Éventuellement, le tas Java sera plein, ce qui signifie qu’un thread d’allocation est incapable de trouver une section consécutive suffisamment grande de mémoire libre pour l’objet qu’il veut allouer. À ce moment-là, la JVM détermine qu’un ramassage des ordures doit avoir lieu et en informe le ramasseur d’ordures. Un ramassage des ordures peut également être déclenché lorsqu’un programme Java appelle System.gc(). L’utilisation de System.gc() ne garantit pas un ramassage des déchets. Avant qu’un garbage collector puisse démarrer, un mécanisme GC détermine d’abord s’il est sûr de le faire. Il est sûr de commencer un garbage collection lorsque tous les threads actifs de l’application sont à un point sûr pour le permettre, par exemple en expliquant simplement qu’il serait mauvais de commencer le garbage collection au milieu d’une allocation d’objet en cours, ou au milieu de l’exécution d’une séquence d’instructions optimisées du CPU (voir mon article précédent sur les compilateurs), car vous pourriez perdre le contexte et ainsi gâcher les résultats finaux.

Un garbage collector ne devrait jamais récupérer un objet activement référencé ; le faire briserait la spécification de la machine virtuelle Java. Un garbage collector n’est pas non plus tenu de collecter immédiatement les objets morts. Les objets morts sont éventuellement collectés lors des cycles de ramassage ultérieurs. Bien qu’il existe de nombreuses façons d’implémenter le ramassage des ordures, ces deux hypothèses sont vraies pour toutes les variétés. Le véritable défi du ramasse-miettes est d’identifier tout ce qui est vivant (toujours référencé) et de récupérer toute mémoire non référencée, mais de le faire sans affecter les applications en cours d’exécution plus que nécessaire. Un garbage collector a donc deux mandats :

  1. Libérer rapidement la mémoire non référencée afin de satisfaire le taux d’allocation d’une application pour qu’elle ne manque pas de mémoire.
  2. Récupérer la mémoire tout en impactant le moins possible les performances (ex, latence et débit) d’une application en cours d’exécution.

Deux types de garbage collection

Dans le premier article de cette série, j’ai abordé les deux principales approches du garbage collection, qui sont le comptage de référence et les collecteurs de traçage. Cette fois, je vais approfondir chaque approche puis présenter certains des algorithmes utilisés pour mettre en œuvre les collecteurs de traçage dans les environnements de production.

Lisez la série sur l’optimisation des performances de la JVM

  • Optimisation des performances de la JVM, partie 1 : Vue d’ensemble
  • Optimisation des performances de la JVM, partie 2 : Compilateurs

Collecteurs de comptage de références

Les collecteurs de comptage de références gardent la trace du nombre de références qui pointent vers chaque objet Java. Lorsque le compte d’un objet devient nul, la mémoire peut être immédiatement récupérée. Cet accès immédiat à la mémoire récupérée est le principal avantage de l’approche de la collecte des déchets par comptage de références. Il y a très peu de frais généraux lorsqu’il s’agit de conserver de la mémoire non référencée. Garder tous les comptes de référence à jour peut être assez coûteux, cependant.

La principale difficulté avec les collecteurs de comptage de référence est de garder les comptes de référence précis. Un autre défi bien connu est la complexité associée à la manipulation des structures circulaires. Si deux objets se référencent l’un l’autre et qu’aucun objet vivant ne s’y réfère, leur mémoire ne sera jamais libérée. Les deux objets resteront à jamais avec un compte non nul. La récupération de la mémoire associée aux structures circulaires nécessite une analyse importante, ce qui apporte une surcharge coûteuse à l’algorithme, et donc à l’application.

Collecteurs de traçage

Les collecteurs de traçage sont basés sur l’hypothèse que tous les objets vivants peuvent être trouvés en traçant itérativement toutes les références et les références ultérieures à partir d’un ensemble initial d’objets connus pour être vivants. L’ensemble initial d’objets vivants (appelés objets racines ou simplement racines) est localisé en analysant les registres, les champs globaux et les cadres de pile au moment où un garbage collection est déclenché. Une fois qu’un ensemble initial d’objets vivants a été identifié, le collecteur de traçage suit les références de ces objets et les met en file d’attente pour qu’ils soient marqués comme vivants et que leurs références soient ensuite tracées. Le fait de marquer comme vivants tous les objets référencés trouvés signifie que l’ensemble vivant connu augmente au fil du temps. Ce processus se poursuit jusqu’à ce que tous les objets référencés (et donc tous les objets actifs) soient trouvés et marqués. Une fois que le collecteur de traçage a trouvé tous les objets vivants, il récupère la mémoire restante.

Les collecteurs de traçage diffèrent des collecteurs de comptage de références en ce qu’ils peuvent gérer les structures circulaires. Le piège avec la plupart des tracing collectors est la phase de marquage, qui entraîne une attente avant de pouvoir récupérer la mémoire non référencée.

Les tracing collectors sont le plus souvent utilisés pour la gestion de la mémoire dans les langages dynamiques ; ils sont de loin les plus courants pour le langage Java et ont fait leurs preuves commercialement dans les environnements de production depuis de nombreuses années. Je vais me concentrer sur les collecteurs de traçage pour le reste de cet article, en commençant par certains des algorithmes qui mettent en œuvre cette approche de la collecte des ordures.

Algorithmes de collecteur de traçage

La copie et la collecte des ordures par marquage et balayage ne sont pas nouvelles, mais elles sont encore les deux algorithmes les plus courants qui mettent en œuvre la collecte des ordures par traçage aujourd’hui.

Copying collectors

Les collecteurs de copie traditionnels utilisent un espace de départ et un espace d’arrivée — c’est-à-dire deux espaces d’adresse du tas définis séparément. Au moment de la collecte des déchets, les objets vivants dans la zone définie comme espace de départ sont copiés dans le prochain espace disponible dans la zone définie comme espace d’arrivée. Lorsque tous les objets vivants de l’espace de départ sont déplacés, l’espace de départ peut être entièrement récupéré. Lorsque l’allocation recommence, elle commence à partir du premier emplacement libre dans le to-space.

Dans les implémentations plus anciennes de cet algorithme, le from-space et le to-space changent de place, ce qui signifie que lorsque le to-space est plein, le garbage collection est déclenché à nouveau et le to-space devient le from-space, comme le montre la figure 1.

Figure 1. Une séquence de garbage collection traditionnelle par copie (cliquez pour agrandir)

Des implémentations plus modernes de l’algorithme de copie permettent d’attribuer des espaces d’adresse arbitraires dans le tas comme to-space et from-space. Dans ces cas, ils ne doivent pas nécessairement changer d’emplacement l’un avec l’autre ; plutôt, chacun devient un autre espace d’adresse dans le tas.

Un avantage des collecteurs de copie est que les objets sont alloués ensemble étroitement dans le to-space, éliminant complètement la fragmentation. La fragmentation est un problème commun que d’autres algorithmes de collecte des ordures luttent avec ; quelque chose que je discuterai plus tard dans cet article.

Avantages des collecteurs de copie

Les collecteurs de copie sont généralement des collecteurs stop-the-world, ce qui signifie qu’aucun travail d’application ne peut être exécuté tant que la collecte des ordures est en cycle. Dans une implémentation stop-the-world, plus la zone que vous devez copier est grande, plus l’impact sur les performances de votre application sera élevé. C’est un inconvénient pour les applications qui sont sensibles au temps de réponse. Avec un collecteur de copie, vous devez également envisager le pire scénario, lorsque tout est en direct dans l’espace de départ. Vous devez toujours laisser suffisamment de marge pour que les objets vivants puissent être déplacés, ce qui signifie que l’espace de départ doit être suffisamment grand pour accueillir tout ce qui se trouve dans l’espace d’arrivée. L’algorithme de copie est légèrement inefficace en mémoire en raison de cette contrainte.

Collecteurs mark-and-sweep

La plupart des JVM commerciales déployées dans des environnements de production d’entreprise exécutent des collecteurs mark-and-sweep (ou marquage), qui n’ont pas l’impact sur les performances que les collecteurs de copie. Certains des collecteurs de marquage les plus célèbres sont CMS, G1, GenPar, et DeterministicGC (voir Ressources).

Un collecteur de marquage et de balayage trace les références et marque chaque objet trouvé avec un bit “vivant”. Habituellement, un bit actif correspond à une adresse ou dans certains cas à un ensemble d’adresses sur le tas. Le bit live peut, par exemple, être stocké comme un bit dans l’en-tête de l’objet, un vecteur de bits ou une carte de bits.

Une fois que tout a été marqué live, la phase de balayage entre en jeu. Si un collecteur a une phase de balayage, il inclut fondamentalement un mécanisme pour parcourir à nouveau le tas (pas seulement l’ensemble vivant, mais la longueur totale du tas) pour localiser tous les morceaux non marqués des espaces d’adresse mémoire consécutifs. La mémoire non marquée est libre et récupérable. Le collecteur relie ensuite ces morceaux non marqués en listes libres organisées. Il peut y avoir plusieurs listes libres dans un collecteur d’ordures, généralement organisées par tailles de morceaux. Certaines JVM (comme JRockit Real Time) mettent en œuvre des collecteurs avec des heuristiques qui organisent dynamiquement les listes par taille en fonction des données de profilage de l’application et des statistiques de taille des objets.

Lorsque la phase de balayage est terminée, l’allocation recommence. De nouvelles zones d’allocation sont allouées à partir des listes libres et les morceaux de mémoire pourraient correspondre aux tailles des objets, aux moyennes de taille des objets par ID de thread, ou aux tailles TLAB accordées par l’application. Adapter l’espace libre plus étroitement à la taille de ce que votre application essaie d’allouer optimise la mémoire et pourrait aider à réduire la fragmentation.

Plus d’informations sur les tailles TLAB

Le partitionnement TLAB et TLA (Thread Local Allocation Buffer ou Thread Local Area) sont abordés dans Optimisation des performances de la JVM, partie 1.

Avantages des collecteurs mark-and-sweep

La phase de mark dépend de la quantité de données en direct sur votre tas, tandis que la phase de sweep dépend de la taille du tas. Puisque vous devez attendre que les phases de marquage et de balayage soient toutes deux terminées pour récupérer la mémoire, cet algorithme pose des problèmes de temps de pause pour les plus grands tas et les plus grands ensembles de données vivantes.

Une façon dont vous pouvez aider les applications fortement consommatrices de mémoire est d’utiliser des options de réglage GC qui s’adaptent à divers scénarios et besoins d’application. L’accord peut, dans de nombreux cas, aider au moins à retarder l’une ou l’autre de ces phases pour qu’elles ne deviennent pas un risque pour votre application ou vos accords de niveau de service (SLA). (Un SLA spécifie que l’application respectera certains temps de réponse de l’application — c’est-à-dire la latence). Le réglage pour chaque changement de charge et modification de l’application est cependant une tâche répétitive, car le réglage n’est valable que pour une charge de travail et un taux d’allocation spécifiques.

Mise en œuvre du mark-and-sweep

Il existe au moins deux approches commercialement disponibles et éprouvées pour mettre en œuvre la collecte mark-and-sweep. L’une est l’approche parallèle et l’autre est l’approche concurrente (ou principalement concurrente).

Collecteurs parallèles

La collecte parallèle signifie que les ressources affectées au processus sont utilisées en parallèle pour la collecte des ordures. La plupart des collecteurs parallèles implémentés commercialement sont des collecteurs monolithiques stop-the-world — tous les threads de l’application sont arrêtés jusqu’à ce que le cycle complet de collecte des ordures soit terminé. L’arrêt de tous les threads permet d’utiliser efficacement toutes les ressources en parallèle pour terminer la collecte des déchets par les phases de marquage et de balayage. Il en résulte un très haut niveau d’efficacité, qui se traduit généralement par des scores élevés dans les tests de débit tels que SPECjbb. Si le débit est essentiel pour votre application, l’approche parallèle est un excellent choix.

Laisser un commentaire

Votre adresse e-mail ne sera pas publiée.