Améliorer les performances des applications avec ces techniques GC avancées

Les performances des applications sont au premier plan de nos esprits, et l’optimisation de la Garbage Collection est un bon endroit pour faire des avancées petites, mais significatives

La collecte automatique des déchets (avec le compilateur JIT HotSpot) est l’un des composants les plus avancés et les plus appréciés de la JVM, mais de nombreux développeurs et ingénieurs sont beaucoup moins familiers avec la Garbage Collection (GC), son fonctionnement et son impact sur les performances des applications.

D’abord, à quoi sert la GC ? La Garbage Collection est le processus de gestion de la mémoire pour les objets du tas. Lorsque les objets sont alloués au tas, ils passent par quelques phases de collecte – généralement assez rapidement car la majorité des objets du tas ont une courte durée de vie.

Les événements de collecte d’ordures contiennent trois phases – le marquage, la suppression et la copie/compaction. Dans la première phase, le GC parcourt le tas et marque tout soit comme des objets vivants (référencés), des objets non référencés ou de l’espace mémoire disponible. Les objets non référencés sont ensuite supprimés et les objets restants sont compactés. Dans les ramassages générationnels, les objets « vieillissent » et sont promus à travers 3 espaces dans leur vie – Eden, espace Survivor et espace Tenured (Old). Ce déplacement se produit également dans le cadre de la phase de compactage.

Mais assez à ce sujet, passons à la partie amusante!

Pst ! Vous cherchez une solution pour améliorer les performances des applications ? OverOps aide les entreprises à identifier non seulement quand et où les ralentissements se produisent, mais aussi pourquoi et comment ils se produisent. Regardez une démo en direct pour voir comment cela fonctionne.

Mieux connaître la Garbage Collection (GC) en Java

L’un des grands avantages de la GC automatisée est que les développeurs n’ont pas vraiment besoin de comprendre comment elle fonctionne. Malheureusement, cela signifie que de nombreux développeurs NE comprennent PAS comment elle fonctionne. Comprendre le garbage collection et les nombreuses GC disponibles, c’est un peu comme connaître les commandes CLI de Linux. Vous n’avez pas techniquement besoin de les utiliser, mais le fait de les connaître et de devenir à l’aise pour les utiliser peut avoir un impact significatif sur votre productivité.

Comme pour les commandes CLI, il y a les bases absolues. La commande ls pour afficher une liste de dossiers dans un dossier parent, mv pour déplacer un fichier d’un emplacement à un autre, etc. En GC, ce genre de commandes équivaudrait à savoir qu’il y a plus d’un GC à choisir, et que le GC peut poser des problèmes de performance. Bien sûr, il y a tellement plus à apprendre (sur l’utilisation du CLI Linux ET sur la garbage collection).

L’objectif d’apprendre le processus de garbage collection de Java n’est pas seulement pour des amorces de conversation gratuites (et ennuyeuses), l’objectif est d’apprendre à mettre en œuvre et à maintenir efficacement la bonne GC avec des performances optimales pour votre environnement spécifique. Savoir que la collecte des ordures affecte les performances des applications est élémentaire, et il existe de nombreuses techniques avancées pour améliorer les performances de la GC et réduire son impact sur la fiabilité des applications.

Préoccupations liées aux performances de la GC

Fuites de mémoire –

Avec la connaissance de la structure du tas et de la façon dont la collecte des ordures est effectuée, nous savons que l’utilisation de la mémoire augmente progressivement jusqu’à ce qu’un événement de collecte des ordures se produise et que l’utilisation redescende. L’utilisation du tas pour les objets référencés reste généralement stable, donc la baisse devrait être plus ou moins au même volume.

Avec une fuite de mémoire, chaque événement GC efface une plus petite partie des objets du tas (bien que de nombreux objets laissés derrière ne soient pas utilisés), donc l’utilisation du tas continuera à augmenter jusqu’à ce que la mémoire du tas soit pleine et qu’une exception OutOfMemoryError soit levée. La cause de ce phénomène est que la GC ne marque que les objets non référencés pour la suppression. Ainsi, même si un objet référencé n’est plus utilisé, il ne sera pas effacé du tas. Il y a quelques astuces de codage utiles pour empêcher cela que nous couvrirons un peu plus tard.

Événements « Stop the World » continus –

Dans certains scénarios, le garbage collection peut être appelé un événement « Stop the World » car lorsqu’il se produit, tous les threads de la JVM (et donc, l’application qui s’exécute dessus) sont arrêtés pour permettre au GC de s’exécuter. Dans les applications saines, le temps d’exécution de GC est relativement faible et n’a pas un grand effet sur les performances de l’application.

Dans les situations sous-optimales, cependant, les événements Stop the World peuvent avoir un impact considérable sur les performances et la fiabilité d’une application. Si un événement GC nécessite une pause Stop the World et prend 2 secondes pour s’exécuter, l’utilisateur final de cette application subira un retard de 2 secondes car les threads exécutant l’application sont arrêtés pour permettre le GC.

Lorsque des fuites de mémoire se produisent, les événements Stop the World continus sont également problématiques. Comme moins d’espace mémoire de tas est purgé à chaque exécution de la GC, il faut moins de temps pour que la mémoire restante se remplisse. Lorsque la mémoire est pleine, la JVM déclenche un autre événement GC. Finalement, la JVM exécutera des événements Stop the World répétés causant des problèmes de performance majeurs.

Utilisation du CPU –

Et tout se résume à l’utilisation du CPU. Un symptôme majeur des événements GC / Stop the World continus est un pic d’utilisation du CPU. GC est une opération lourde en termes de calcul, et peut donc prendre plus que sa juste part de puissance CPU. Pour les GC qui exécutent des threads simultanés, l’utilisation du CPU peut être encore plus élevée. Choisir le bon GC pour votre application aura le plus grand impact sur l’utilisation du CPU, mais il y a aussi d’autres façons d’optimiser pour une meilleure performance dans ce domaine.

Nous pouvons comprendre à partir de ces préoccupations de performance entourant le garbage collection que, quelle que soit l’avancée des GC (et ils deviennent assez avancés), leur talon d’Achille reste le même. Des allocations d’objets redondantes et imprévisibles. Pour améliorer les performances des applications, il ne suffit pas de choisir le bon GC. Nous devons savoir comment le processus fonctionne, et nous devons optimiser notre code pour que nos GC ne tirent pas de ressources excessives ou ne provoquent pas de pauses excessives dans notre application.

Generational GC

Avant de nous plonger dans les différents GC Java et leur impact sur les performances, il est important de comprendre les bases du garbage collection générationnel. Le concept de base de la GC générationnelle est basé sur l’idée que plus une référence existe longtemps à un objet dans le tas, moins il est probable qu’il soit marqué pour la suppression. En étiquetant les objets avec un « âge » figuratif, ils pourraient être séparés dans différents espaces de stockage pour être marqués par la GC moins fréquemment.

Lorsqu’un objet est alloué au tas, il est placé dans ce qu’on appelle l’espace Eden. C’est là que les objets commencent, et dans la plupart des cas, c’est là qu’ils sont marqués pour être supprimés. Les objets qui survivent à cette étape  » fêtent un anniversaire  » et sont copiés dans l’espace Survivor. Ce processus est illustré ci-dessous :

Les espaces Eden et Survivor constituent ce que l’on appelle la jeune génération. C’est là que se déroule l’essentiel de l’action. Quand (si) un objet de la Jeune Génération atteint un certain âge, il est promu dans l’espace Tenured (aussi appelé Old). L’avantage de diviser les mémoires d’objets en fonction de l’âge est que la GC peut fonctionner à différents niveaux.

Une GC mineure est une collection qui se concentre uniquement sur la jeune génération, ignorant effectivement l’espace Tenured complètement. En général, la majorité des objets de la jeune génération sont marqués pour être supprimés et une GC majeure ou complète (incluant l’ancienne génération) n’est pas nécessaire pour libérer la mémoire sur le tas. Bien sûr, une GC majeure ou complète sera déclenchée si nécessaire.

Une astuce rapide pour optimiser le fonctionnement de la GC en fonction de cela est d’ajuster les tailles des zones de tas pour mieux répondre aux besoins de vos applications.

Types de collecteurs

Il existe de nombreux GC disponibles parmi lesquels choisir, et bien que G1 soit devenu le GC par défaut dans Java 9, il était initialement destiné à remplacer le collecteur CMS qui est Low Pause, donc les applications fonctionnant avec des collecteurs Throughput peuvent être mieux adaptées en restant avec leur collecteur actuel. Comprendre les différences opérationnelles, et les différences d’impact sur les performances, pour les collecteurs de déchets Java est toujours important.

Collecteurs Throughput

Mieux pour les applications qui doivent être optimisées pour un débit élevé et peuvent négocier une latence plus élevée pour y parvenir.

Série –

Le collecteur série est le plus simple, et celui que vous êtes le moins susceptible d’utiliser, car il est principalement conçu pour les environnements à un seul thread (par exemple, 32 bits ou Windows) et pour les petits tas. Ce collecteur peut faire évoluer verticalement l’utilisation de la mémoire dans la JVM, mais il nécessite plusieurs GC majeures/complètes pour libérer les ressources de tas inutilisées. Cela provoque de fréquentes pauses Stop the World, ce qui le disqualifie à toutes fins utiles pour être utilisé dans des environnements orientés utilisateur.

Parallèle –

Comme son nom le décrit, ce GC utilise plusieurs threads s’exécutant en parallèle pour balayer et compacter le tas. Bien que le GC parallèle utilise plusieurs threads pour la collecte des ordures, il met toujours en pause tous les threads de l’application pendant son exécution. Le collecteur parallèle est le mieux adapté aux apps qui doivent être optimisées pour le meilleur débit et qui peuvent tolérer une latence plus élevée en échange.

Collecteurs à faible pause

La plupart des applications tournées vers l’utilisateur nécessiteront un GC à faible pause, afin que l’expérience utilisateur ne soit pas affectée par des pauses longues ou fréquentes. Ces GC ont pour but d’optimiser la réactivité (temps/événement) et les performances solides à court terme.

Concurrent Mark Sweep (CMS) –

Similaire au collecteur parallèle, le collecteur Concurrent Mark Sweep (CMS) utilise plusieurs threads pour marquer et balayer (supprimer) les objets non référencés. Cependant, ce GC ne déclenche les événements Stop the World que dans deux instances spécifiques :

(1) lors de l’initialisation du marquage initial des racines (objets de l’ancienne génération atteignables depuis les points d’entrée des threads ou des variables statiques) ou de toute référence provenant de la méthode main(), et quelques autres

(2) lorsque l’application a modifié l’état du tas pendant que l’algorithme s’exécutait de manière concurrente, le forçant à revenir en arrière et à faire quelques retouches finales pour s’assurer qu’il a les bons objets marqués

G1 –

Le collecteur Garbage first (communément appelé G1) utilise plusieurs threads d’arrière-plan pour balayer le tas qu’il divise en régions. Il fonctionne en balayant d’abord les régions qui contiennent le plus d’objets poubelles, ce qui lui donne son nom (Garbage first).

Cette stratégie réduit les chances que le tas soit épuisé avant que les threads d’arrière-plan aient fini de balayer les objets inutilisés, auquel cas le collecteur devrait arrêter l’application. Un autre avantage pour le collecteur G1 est qu’il compacte le tas en cours de route, ce que le collecteur CMS ne fait que lors des collectes complètes Stop the World.

Amélioration des performances GC

Les performances des applications sont directement impactées par la fréquence et la durée des garbage collections, ce qui signifie que l’optimisation du processus GC se fait en réduisant ces métriques. Il y a deux façons principales d’y parvenir. Premièrement, en ajustant les tailles de tas des jeunes et des anciennes générations, et deuxièmement, pour réduire le taux d’allocation et de promotion des objets.

En ce qui concerne l’ajustement des tailles de tas, ce n’est pas aussi simple qu’on pourrait le croire. La conclusion logique serait que l’augmentation de la taille du tas diminuerait la fréquence de la GC tout en augmentant la durée, et que la diminution de la taille du tas diminuerait la durée de la GC tout en augmentant la fréquence.

Le fait est, cependant, que la durée d’une GC mineure dépend non pas de la taille du tas, mais du nombre d’objets qui survivent à la collecte. Cela signifie que pour les applications qui créent principalement des objets à courte durée de vie, l’augmentation de la taille de la jeune génération peut effectivement réduire la durée et la fréquence des GC. Cependant, si l’augmentation de la taille de la jeune génération conduit à une augmentation significative des objets devant être copiés dans les espaces de survie, les pauses GC prendront plus de temps conduisant à une augmentation de la latence.

3 Conseils pour écrire du code GC-efficace

Conseil #1 : Prévoir les capacités de collection –

Toutes les collections Java standard, ainsi que la plupart des implémentations personnalisées et étendues (telles que Trove et Guava de Google), utilisent des tableaux sous-jacents (soit primitifs, soit basés sur des objets). Comme les tableaux sont de taille immuable une fois alloués, l’ajout d’éléments à une collection peut, dans de nombreux cas, entraîner l’abandon d’un ancien tableau sous-jacent en faveur d’un tableau plus grand nouvellement alloué.

La plupart des implémentations de collections essaient d’optimiser ce processus de réaffectation et de le maintenir à un minimum amorti, même si la taille prévue de la collection n’est pas fournie. Cependant, les meilleurs résultats peuvent être obtenus en fournissant à la collection sa taille attendue dès sa construction.

Conseil #2 : Traitez les flux directement –

Lorsque vous traitez des flux de données, comme des données lues dans des fichiers, ou des données téléchargées sur le réseau, par exemple, il est très courant de voir quelque chose du type :

Le tableau d’octets résultant pourrait ensuite être analysé dans un document XML, un objet JSON ou un message Protocol Buffer, pour ne citer que quelques options populaires.

Lorsqu’on traite des fichiers volumineux ou de taille imprévisible, c’est évidemment une mauvaise idée, car cela nous expose à des OutOfMemoryErrors dans le cas où la JVM ne peut pas réellement allouer un tampon de la taille du fichier entier.

Une meilleure façon d’aborder cela est d’utiliser l’InputStream approprié (FileInputStream dans ce cas) et de l’alimenter directement dans l’analyseur syntaxique, sans d’abord lire le tout dans un tableau d’octets. Toutes les bibliothèques majeures exposent des API pour analyser les flux directement, par exemple:

Tip #3 : Utilisez des objets immuables –

L’immuabilité a de nombreux avantages. L’un d’entre eux, auquel on accorde rarement l’attention qu’il mérite, est son effet sur la collecte des ordures.

Un objet immuable est un objet dont les champs (et spécifiquement les champs non primitifs dans notre cas) ne peuvent pas être modifiés après la construction de l’objet.

L’immutabilité implique que tous les objets référencés par un conteneur immuable ont été créés avant la fin de la construction du conteneur. En termes de GC : Le conteneur est au moins aussi jeune que la plus jeune référence qu’il détient. Cela signifie que lorsqu’il effectue des cycles de collecte de déchets sur les jeunes générations, le GC peut sauter les objets immuables qui se trouvent dans des générations plus anciennes, car il sait avec certitude qu’ils ne peuvent pas référencer quoi que ce soit dans la génération qui est collectée.

Moins d’objets à analyser signifie moins de pages de mémoire à analyser, et moins de pages de mémoire à analyser signifie des cycles GC plus courts, ce qui signifie des pauses GC plus courtes et un meilleur débit global.

Pour plus d’astuces et d’exemples détaillés, consultez ce post couvrant des tactiques approfondies pour écrire un code plus efficace en termes de mémoire.

*** Un grand merci à Amit Hurvitz de l’équipe R&D d’OverOps pour sa passion et la perspicacité qui ont alimenté ce post !

.

Laisser un commentaire