Optimisation des performances JVM, partie 3: Garbage collection

Le mécanisme de récupération de place de la plate-forme Java augmente considérablement la productivité des développeurs, mais un récupérateur de mémoire mal implémenté peut surconsommer les ressources de l'application. Dans ce troisième article de la série d' optimisation des performances JVM , Eva Andreasson offre aux débutants Java un aperçu du modèle de mémoire et du mécanisme GC de la plate-forme Java. Elle explique ensuite pourquoi la fragmentation (et non GC) est le principal "gotcha!" des performances des applications Java, et pourquoi le garbage collection et le compactage générationnels sont actuellement les principales approches (mais pas les plus innovantes) de la gestion de la fragmentation du tas dans les applications Java.

Le garbage collection (GC) est le processus qui vise à libérer de la mémoire occupée qui n'est plus référencée par aucun objet Java accessible, et est une partie essentielle du système de gestion dynamique de la mémoire de la machine virtuelle Java (JVM). Dans un cycle de garbage collection typique, 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 une nouvelle allocation d'objets.

Afin de comprendre le garbage collection et les différentes approches et algorithmes GC, vous devez d'abord connaître quelques informations sur le modèle de mémoire de la plate-forme Java.

Optimisation des performances JVM: lire la série

  • Partie 1: Aperçu
  • Partie 2: Compilateurs
  • Partie 3: Ramassage des ordures
  • Partie 4: Compactage simultané du GC
  • Partie 5: Évolutivité

Garbage collection et modèle de mémoire de la plateforme Java

Lorsque vous spécifiez l'option de démarrage -Xmxsur la ligne de commande de votre application Java (par exemple:), la java -Xmx:2g MyAppmémoire est affecté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. Au fur et à mesure que votre programme Java s'exécute et alloue de nouveaux objets, le tas Java (c'est-à-dire cet espace d'adressage) se remplit.

Finalement, 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 souhaite allouer. À ce stade, la JVM détermine qu'un garbage collection doit avoir lieu et il en informe le garbage collector. Un garbage collection peut également être déclenché lorsqu'un programme Java appelle System.gc(). En utilisantSystem.gc()ne garantit pas un garbage collection. Avant qu'un garbage collection puisse démarrer, un mécanisme GC déterminera d'abord s'il est sûr de le démarrer. Il est sûr de démarrer un ramasse-miettes lorsque tous les threads actifs de l'application sont à un point sûr pour le permettre, par exemple simplement expliqué qu'il serait mauvais de commencer le ramassage des ordures au milieu d'une allocation d'objets en cours, ou au milieu de exécuter une séquence d'instructions CPU optimisées (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 doit jamais récupérer un objet activement référencé; cela enfreindrait la spécification de la machine virtuelle Java. Un garbage collector n'est pas non plus obligé de collecter immédiatement les objets morts. Les objets morts sont finalement collectés lors des cycles de récupération de place suivants. Bien qu'il existe de nombreuses façons d'implémenter le garbage collection, ces deux hypothèses sont vraies pour toutes les variétés. Le véritable défi du garbage collection est d'identifier tout ce qui est actif (toujours référencé) et de récupérer toute mémoire non référencée, mais sans affecter plus que nécessaire les applications en cours d'exécution. Un garbage collector a donc deux mandats:

  1. Pour libérer rapidement de la mémoire non référencée afin de satisfaire le taux d'allocation d'une application afin qu'elle ne manque pas de mémoire.
  2. Pour récupérer de la mémoire tout en ayant un impact minimal sur les performances (par exemple, la latence et le débit) d'une application en cours d'exécution.

Deux types de collecte des ordures

Dans le premier article de cette série, j'ai abordé les deux principales approches du garbage collection, à savoir le comptage de références et le traçage des collecteurs. Cette fois, je vais approfondir chaque approche, puis présenter certains des algorithmes utilisés pour implémenter des collecteurs de traçage dans les environnements de production.

Lire la série d'optimisation des performances JVM

  • Optimisation des performances JVM, partie 1: présentation
  • Optimisation des performances JVM, partie 2: Compilateurs

Collectionneurs de comptage de références

Les collecteurs de comptage de références gardent une trace du nombre de références pointant vers chaque objet Java. Une fois que le nombre d'un objet devient zéro, 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 comptage des références pour le garbage collection. Il y a très peu de frais généraux lorsqu'il s'agit de conserver une mémoire non référencée. La mise à jour de tous les décomptes de références peut cependant être assez coûteuse.

La principale difficulté avec les collecteurs de comptage de référence est de maintenir l'exactitude des comptages de référence. 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 n'y fait référence, leur mémoire ne sera jamais libérée. Les deux objets resteront à jamais avec un compte différent de zéro. La récupération de la mémoire associée aux structures circulaires nécessite une analyse majeure, ce qui entraîne des frais généraux coûteux pour l'algorithme, et donc pour l'application.

Traçage des collecteurs

Les collecteurs de traçage sont basés sur l'hypothèse que tous les objets vivants peuvent être trouvés en traçant de manière itérative 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 racine ou simplement racines pour faire court) sont localisés 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 dynamique initial a été identifié, le collecteur de traçages suit les références de ces objets et les met en file d'attente pour être marqués comme actifs et que leurs références soient ensuite tracées. Marquage de tous les objets référencés trouvés en directsignifie que le live set connu augmente avec le temps. Ce processus se poursuit jusqu'à ce que tous les objets référencés (et donc tous les objets vivants) soient trouvés et marqués. Une fois que le collecteur de tracés a trouvé tous les objets actifs, il récupère la mémoire restante.

Les collecteurs de traçage diffèrent des collecteurs de comptage de référence en ce qu'ils peuvent manipuler des structures circulaires. Le hic avec la plupart des collecteurs de traçage est la phase de marquage, qui implique une attente avant de pouvoir récupérer de la mémoire non référencée.

Les collecteurs de traçage sont les plus couramment 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 dans les environnements de production depuis de nombreuses années. Je vais me concentrer sur le suivi des collecteurs pour le reste de cet article, en commençant par certains des algorithmes qui implémentent cette approche du garbage collection.

Suivi des algorithmes de collecteur

La copie et la récupération de place par marquage et balayage ne sont pas nouvelles, mais ce sont toujours les deux algorithmes les plus courants qui implémentent aujourd'hui la récupération de place de suivi.

Copie de collectionneurs

Les collecteurs de copie traditionnels utilisent un espace depuis et un vers espace , c'est -à- dire deux espaces d'adressage définis séparément du tas. Au moment de la récupération de place, les objets en direct dans la zone définie comme depuis l'espace sont copiés dans l'espace disponible suivant dans la zone définie comme vers l'espace. Lorsque tous les objets vivants dans l'espace depuis l'espace sont déplacés vers l'extérieur, l'intégralité de l'espace depuis l'espace peut être récupérée. Lorsque l'allocation recommence, elle commence à partir du premier emplacement libre dans l'espace vers.

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

Des implémentations plus modernes de l'algorithme de copie permettent d'affecter des espaces d'adressage arbitraires dans le tas en tant que vers-espace et depuis-espace. Dans ces cas, ils ne doivent pas nécessairement changer d'emplacement l'un avec l'autre; au lieu de cela, chacun devient un autre espace d'adressage dans le tas.

L'un des avantages de la copie de collecteurs est que les objets sont alloués étroitement ensemble dans l'espace vers, éliminant complètement la fragmentation. La fragmentation est un problème courant avec lequel les autres algorithmes de récupération de place sont confrontés; quelque chose dont je discuterai plus tard dans cet article.

Inconvénients de la copie de collectionneurs

Les collecteurs de copie sont généralement des collecteurs d' arrêt du monde , ce qui signifie qu'aucune tâche d'application ne peut être exécutée tant que le ramasse-miettes est en cycle. Dans une implémentation d'arrêt du monde, plus la zone que vous devez copier est grande, plus l'impact sur les performances de votre application sera important. C'est un inconvénient pour les applications sensibles au temps de réponse. Avec un collecteur de copies, vous devez également envisager le pire des cas, lorsque tout est en direct dans le from-space. Vous devez toujours laisser suffisamment d'espace libre pour que les objets vivants puissent être déplacés, ce qui signifie que l'espace vers l'espace doit être suffisamment grand pour accueillir tout ce qui se trouve dans l'espace depuis l'espace. L'algorithme de copie est légèrement inefficace en mémoire en raison de cette contrainte.

Collectionneurs Mark-and-Sweep

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

Un collecteur Mark-and-Sweep trace les références et marque chaque objet trouvé avec un bit "live". Habituellement, un bit défini correspond à une adresse ou, dans certains cas, à un ensemble d'adresses sur le tas. Le bit en direct peut, par exemple, être stocké sous forme de bit dans l'en-tête de l'objet, un vecteur de bit ou une carte de bits.

Une fois que tout a été marqué en direct, la phase de balayage démarre. Si un collecteur a une phase de balayage, il comprend essentiellement un mécanisme pour parcourir à nouveau le tas (pas seulement l'ensemble en direct mais toute la longueur du tas) pour localiser tous les non-marqués morceaux d'espaces d'adressage 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 diverses listes gratuites dans un garbage collector - généralement organisées par taille de bloc. Certaines machines virtuelles Java (telles que JRockit Real Time) implémentent des collecteurs avec des heuristiques qui dynamiquement des listes de plages de tailles basées sur les données de profilage d'application et les statistiques de taille d'objet.

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 segments de mémoire peuvent être mis en correspondance avec les tailles d'objet, les moyennes de taille d'objet par ID de thread ou les tailles TLAB réglées par l'application. Ajuster plus étroitement l'espace libre à la taille de ce que votre application tente d'allouer optimise la mémoire et peut aider à réduire la fragmentation.

En savoir plus sur les tailles TLAB

Le partitionnement TLAB et TLA (Thread Local Allocation Buffer ou Thread Local Area) est présenté dans l'optimisation des performances JVM, partie 1.

Inconvénients des collectionneurs de marque et de balayage

La phase de marquage dépend de la quantité de données en direct sur votre tas, tandis que la phase de balayage dépend de la taille du tas. Étant donné que vous devez attendre que les phases de marquage et de balayage soient terminées pour récupérer la mémoire, cet algorithme entraîne des problèmes de temps de pause pour les segments de mémoire plus volumineux et les ensembles de données en direct plus volumineux.

Une façon d'aider les applications très gourmandes en mémoire consiste à utiliser des options de réglage GC qui s'adaptent à divers scénarios et besoins d'application. Le réglage peut, dans de nombreux cas, aider au moins à retarder l'une ou l'autre de ces phases de devenir un risque pour votre application ou les 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 d'application est 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.

Implémentations de mark-and-sweep

Il existe au moins deux approches éprouvées et disponibles dans le commerce pour la mise en œuvre de la collecte par marquage et balayage. L'une est l'approche parallèle et l'autre est l'approche simultanée (ou principalement simultanée).

Collecteurs parallèles

La collecte parallèle signifie que les ressources affectées au processus sont utilisées en parallèle à des fins de garbage collection. La plupart des collecteurs parallèles mis en œuvre commercialement sont des collecteurs monolithiques stop-the-world - tous les threads d'application sont arrêtés jusqu'à ce que tout le cycle de ramasse-miettes soit terminé. L'arrêt de tous les threads permet à toutes les ressources d'être efficacement utilisées en parallèle pour terminer le garbage collection à travers les phases de marquage et de balayage. Cela conduit à un très haut niveau d'efficacité, se traduisant généralement par des scores élevés sur des benchmarks de débit tels que SPECjbb. Si le débit est essentiel pour votre application, l'approche parallèle est un excellent choix.