Une introduction à ZGC: un garbage collector JVM à faible latence évolutif et expérimental

1. Introduction

Aujourd'hui, il n'est pas rare que des applications desservent des milliers, voire des millions d'utilisateurs simultanément. De telles applications nécessitent d'énormes quantités de mémoire. Cependant, la gestion de toute cette mémoire peut facilement avoir un impact sur les performances des applications.

Pour résoudre ce problème, Java 11 a introduit le Z Garbage Collector (ZGC) en tant qu'implémentation expérimentale du garbage collector (GC).

Dans ce didacticiel, nous verrons comment ZGC parvient à maintenir des temps de pause faibles même sur des tas de plusieurs téraoctets .

2. Principaux concepts

Pour comprendre le fonctionnement de ZGC, nous devons comprendre les concepts de base et la terminologie derrière la gestion de la mémoire et les garbage collector.

2.1. Gestion de la mémoire

La mémoire physique est la RAM fournie par notre matériel.

Le système d'exploitation (OS) alloue de l'espace mémoire virtuelle pour chaque application.

Bien sûr, nous stockons la mémoire virtuelle dans la mémoire physique et le système d'exploitation est chargé de maintenir le mappage entre les deux. Ce mappage implique généralement une accélération matérielle.

2.2. Multi-cartographie

Le multi-mappage signifie qu'il existe des adresses spécifiques dans la mémoire virtuelle, qui pointe vers la même adresse dans la mémoire physique. Étant donné que les applications accèdent aux données via la mémoire virtuelle, elles ne savent rien de ce mécanisme (et elles n'en ont pas besoin).

En effet, nous mappons plusieurs plages de la mémoire virtuelle à la même plage dans la mémoire physique:

À première vue, ses cas d'utilisation ne sont pas évidents, mais nous verrons plus tard, que ZGC en a besoin pour faire sa magie. En outre, il offre une certaine sécurité car il sépare les espaces mémoire des applications.

2.3. Déménagement

Puisque nous utilisons l'allocation de mémoire dynamique, la mémoire d'une application moyenne se fragmente au fil du temps. C'est parce que lorsque nous libérons un objet au milieu de la mémoire, un espace libre reste là. Au fil du temps, ces lacunes s'accumulent et notre mémoire ressemblera à un échiquier composé de zones alternées d'espace libre et utilisé.

Bien sûr, nous pourrions essayer de combler ces lacunes avec de nouveaux objets. Pour ce faire, nous devons rechercher dans la mémoire un espace libre suffisamment grand pour contenir notre objet. Faire cela est une opération coûteuse, surtout si nous devons le faire à chaque fois que nous voulons allouer de la mémoire. En outre, la mémoire sera toujours fragmentée, car nous ne pourrons probablement pas trouver un espace libre qui a exactement la taille dont nous avons besoin. Par conséquent, il y aura des espaces entre les objets. Bien entendu, ces écarts sont plus petits. De plus, nous pouvons essayer de minimiser ces écarts, mais cela utilise encore plus de puissance de traitement.

L'autre stratégie consiste à déplacer fréquemment des objets des zones de mémoire fragmentées vers des zones libres dans un format plus compact . Pour être plus efficace, nous divisons l'espace mémoire en blocs. Nous déplaçons tous les objets dans un bloc ou aucun d'entre eux. De cette façon, l'allocation de mémoire sera plus rapide car nous savons qu'il y a des blocs vides entiers dans la mémoire.

2.4. Collecte des ordures

Lorsque nous créons une application Java, nous n'avons pas à libérer la mémoire que nous avons allouée, car les garbage collector le font pour nous. En résumé, GC surveille les objets que nous pouvons atteindre depuis notre application à travers une chaîne de références et libère ceux que nous ne pouvons pas atteindre .

Un GC doit suivre l'état des objets dans l'espace du tas pour effectuer son travail. Par exemple, un état possible est accessible. Cela signifie que l'application contient une référence à l'objet. Cette référence peut être transitive. La seule chose qui compte, c'est que l'application puisse accéder à ces objets via des références. Un autre exemple est finalisable: des objets auxquels nous n'avons pas accès. Ce sont les objets que nous considérons comme des déchets.

Pour y parvenir, les garbage collector ont plusieurs phases.

2.5. Propriétés de la phase GC

Les phases GC peuvent avoir différentes propriétés:

  • une phase parallèle peut s'exécuter sur plusieurs threads GC
  • une phase série s'exécute sur un seul thread
  • une phase d' arrêt du monde ne peut pas s'exécuter simultanément avec le code de l'application
  • une phase simultanée peut s'exécuter en arrière-plan, pendant que notre application fait son travail
  • une phase incrémentielle peut se terminer avant de terminer tout son travail et continuer plus tard

Notez que toutes les techniques ci-dessus ont leurs forces et leurs faiblesses. Par exemple, disons que nous avons une phase qui peut s'exécuter simultanément avec notre application. Une implémentation en série de cette phase nécessite 1% des performances globales du processeur et s'exécute pendant 1000 ms. En revanche, une implémentation parallèle utilise 30% du CPU et termine son travail en 50 ms.

Dans cet exemple, la solution parallèle utilise globalement plus de CPU, car elle peut être plus complexe et devoir synchroniser les threads . Pour les applications lourdes de processeur (par exemple, les travaux par lots), c'est un problème car nous avons moins de puissance de calcul pour effectuer un travail utile.

Bien sûr, cet exemple a inventé des chiffres. Cependant, il est clair que toutes les applications ont leurs caractéristiques, donc elles ont des exigences de GC différentes.

Pour des descriptions plus détaillées, veuillez consulter notre article sur la gestion de la mémoire Java.

3. Concepts du ZGC

ZGC entend proposer des phases d'arrêt du monde aussi courtes que possible. Il y parvient de telle manière que la durée de ces temps de pause n'augmente pas avec la taille du tas. Ces caractéristiques font de ZGC un bon choix pour les applications serveur, où les gros tas sont courants et où des temps de réponse rapides des applications sont une exigence.

En plus des techniques GC éprouvées, ZGC introduit de nouveaux concepts, que nous aborderons dans les sections suivantes.

Mais pour l'instant, jetons un coup d'œil à la vue d'ensemble du fonctionnement de ZGC.

3.1. Grande image

ZGC a une phase appelée marquage, où nous trouvons les objets accessibles. Un GC peut stocker des informations sur l'état des objets de plusieurs manières. Par exemple, nous pourrions créer une carte, où les clés sont des adresses mémoire, et la valeur est l'état de l'objet à cette adresse. C'est simple mais nécessite de la mémoire supplémentaire pour stocker ces informations. En outre, maintenir une telle carte peut être difficile.

ZGC utilise une approche différente: il stocke l'état de référence en tant que bits de la référence. C'est ce qu'on appelle la coloration de référence. Mais de cette façon, nous avons un nouveau défi. La définition de bits d'une référence pour stocker des métadonnées sur un objet signifie que plusieurs références peuvent pointer vers le même objet car les bits d'état ne contiennent aucune information sur l'emplacement de l'objet. Multimapping à la rescousse!

Nous voulons également réduire la fragmentation de la mémoire. ZGC utilise la délocalisation pour y parvenir. Mais avec un gros tas, la relocalisation est un processus lent. Puisque ZGC ne veut pas de longs temps de pause, il effectue la majeure partie du déplacement en parallèle avec l'application. Mais cela introduit un nouveau problème.

Disons que nous avons une référence à un objet. ZGC le déplace et un changement de contexte se produit, où le thread d'application s'exécute et tente d'accéder à cet objet via son ancienne adresse. ZGC utilise des barrières de charge pour résoudre ce problème. Une barrière de charge est un morceau de code qui s'exécute lorsqu'un thread charge une référence à partir du tas - par exemple, lorsque nous accédons à un champ non primitif d'un objet.

Dans ZGC, les barrières de charge vérifient les bits de métadonnées de la référence. En fonction de ces bits, ZGC peut effectuer un traitement sur la référence avant de l'obtenir. Par conséquent, cela pourrait produire une référence entièrement différente. Nous appelons cela le remappage.

3.2. Marquage

ZGC divise le marquage en trois phases.

La première phase est une phase d'arrêt du monde. Dans cette phase, nous recherchons les références racines et les marquons. Les références racine sont les points de départ pour atteindre les objets du tas , par exemple, des variables locales ou des champs statiques. Le nombre de références racine étant généralement faible, cette phase est courte.

La phase suivante est simultanée. Dans cette phase, nous parcourons le graphe d'objets, à partir des références racine. Nous marquons chaque objet que nous atteignons. De plus, lorsqu'une barrière de charge détecte une référence non marquée, elle la marque également.

La dernière phase est également une phase d'arrêt du monde pour gérer certains cas extrêmes, comme les références faibles.

À ce stade, nous savons quels objets nous pouvons atteindre.

ZGC utilise les bits de métadonnées marqué0 et marqué1 pour le marquage.

3.3. Coloration de référence

Une référence représente la position d'un octet dans la mémoire virtuelle. Cependant, nous ne devons pas nécessairement utiliser tous les bits d'une référence pour ce faire - certains bits peuvent représenter les propriétés de la référence . C'est ce que nous appelons la coloration de référence.

Avec 32 bits, nous pouvons adresser 4 gigaoctets. Étant donné que de nos jours il est courant qu'un ordinateur ait plus de mémoire que cela, nous ne pouvons évidemment pas utiliser l'un de ces 32 bits pour la coloration. Par conséquent, ZGC utilise des références 64 bits. Cela signifie que ZGC n'est disponible que sur les plates-formes 64 bits:

Les références ZGC utilisent 42 bits pour représenter l'adresse elle-même. Par conséquent, les références ZGC peuvent adresser 4 téraoctets d'espace mémoire.

En plus de cela, nous avons 4 bits pour stocker les états de référence:

  • bit finalisable - l'objet est uniquement accessible via un finaliseur
  • remapper bit - la référence est à jour et pointe vers l'emplacement actuel de l'objet (voir déplacement)
  • Bits marqués0 et marqués1 - ils sont utilisés pour marquer les objets accessibles

Nous avons également appelé ces bits bits de métadonnées. Dans ZGC, précisément l'un de ces bits de métadonnées est 1.

3.4. Déménagement

Dans ZGC, la relocalisation comprend les phases suivantes:

  1. Une phase simultanée, qui recherche des blocs, que nous voulons déplacer et les met dans l'ensemble de relocalisation.
  2. Une phase d'arrêt du monde déplace toutes les références racine dans l'ensemble de relocalisation et met à jour leurs références.
  3. Une phase simultanée déplace tous les objets restants dans le jeu de relocalisation et stocke le mappage entre l'ancienne et la nouvelle adresse dans la table de transfert.
  4. La réécriture des références restantes se produit dans la phase de marquage suivante. De cette façon, nous n'avons pas à parcourir l'arborescence d'objets deux fois. Les barrières de chargement peuvent également le faire.

3.5. Remappage et barrières de chargement

Notez que dans la phase de relocalisation, nous n'avons pas réécrit la plupart des références aux adresses relocalisées. Par conséquent, en utilisant ces références, nous n'accéderions pas aux objets que nous voulions. Pire encore, nous pourrions accéder aux ordures.

ZGC utilise des barrières de charge pour résoudre ce problème. Les barrières de charge fixent les références pointant vers des objets déplacés avec une technique appelée remappage.

Lorsque l'application charge une référence, elle déclenche la barrière de charge, qui suit ensuite les étapes suivantes pour renvoyer la référence correcte:

  1. Vérifie si le bit de remappage est défini sur 1. Si tel est le cas, cela signifie que la référence est à jour, nous pouvons donc la renvoyer en toute sécurité.
  2. Ensuite, nous vérifions si l'objet référencé était dans le jeu de relocalisation ou non. Si ce n'était pas le cas, cela signifie que nous ne voulions pas le déplacer. Pour éviter cette vérification lors du prochain chargement de cette référence, nous définissons le bit de remappage sur 1 et renvoyons la référence mise à jour.
  3. Nous savons maintenant que l'objet auquel nous voulons accéder était la cible de la relocalisation. La seule question est de savoir si le déménagement a eu lieu ou non? Si l'objet a été déplacé, nous passons à l'étape suivante. Sinon, nous le repositionnons maintenant et créons une entrée dans la table de transfert, qui stocke la nouvelle adresse pour chaque objet déplacé. Après cela, nous passons à l'étape suivante.
  4. Nous savons maintenant que l'objet a été déplacé. Soit par ZGC, us à l'étape précédente, soit par la barrière de charge lors d'un coup précédent de cet objet. Nous mettons à jour cette référence au nouvel emplacement de l'objet (soit avec l'adresse de l'étape précédente, soit en la recherchant dans la table de transfert), définissons le bit de remappage et retournons la référence.

Et c'est tout, avec les étapes ci-dessus, nous nous sommes assurés que chaque fois que nous essayons d'accéder à un objet, nous obtenons la référence la plus récente. Puisque chaque fois que nous chargeons une référence, cela déclenche la barrière de charge. Par conséquent, cela diminue les performances de l'application. Surtout la première fois que nous accédons à un objet déplacé. Mais c'est un prix que nous devons payer si nous voulons des temps de pause courts. Et comme ces étapes sont relativement rapides, elles n'ont pas d'impact significatif sur les performances de l'application.

4. Comment activer ZGC?

Nous pouvons activer ZGC avec les options de ligne de commande suivantes lors de l'exécution de notre application:

-XX:+UnlockExperimentalVMOptions -XX:+UseZGC

Notez que puisque ZGC est un GC expérimental, il faudra un certain temps pour être officiellement pris en charge.

5. Conclusion

Dans cet article, nous avons vu que ZGC a l'intention de prendre en charge de grandes tailles de tas avec des temps de pause d'application faibles.

Pour atteindre cet objectif, il utilise des techniques, notamment des références 64 bits colorées, des barrières de charge, une relocalisation et un remappage.