POO compressés dans la JVM

1. Vue d'ensemble

La JVM gère la mémoire pour nous. Cela supprime le fardeau de la gestion de la mémoire des développeurs, nous n'avons donc pas besoin de manipuler manuellement les pointeurs d'objet , ce qui s'avère être long et sujet aux erreurs.

Sous le capot, la JVM intègre de nombreuses astuces pour optimiser le processus de gestion de la mémoire. Une astuce est l'utilisation de pointeurs compressés , que nous allons évaluer dans cet article. Tout d'abord, voyons comment la JVM représente les objets au moment de l'exécution.

2. Représentation des objets d'exécution

La machine virtuelle Java HotSpot utilise une structure de données appelée oop s ou pointeurs d'objets ordinaires pour représenter les objets. Ces oops sont équivalents aux pointeurs C natifs. Les instanceOop sont un type spécial de oop qui représente les instances d'objet en Java . De plus, la JVM prend également en charge une poignée d'autres oops qui sont conservés dans l'arborescence des sources d'OpenJDK.

Voyons comment la JVM répartit les instances instanceOop en mémoire.

2.1. Disposition de la mémoire d'objets

La disposition de la mémoire d'un instanceOop est simple: il s'agit simplement de l'en-tête d'objet immédiatement suivi de zéro ou plusieurs références aux champs d'instance.

La représentation JVM d'un en-tête d'objet comprend:

  • Un mot de marque sert à plusieurs fins telles que le verrouillage biaisé , les valeurs de hachage d'identité et GC . Ce n'est pas un oop, mais pour des raisons historiques, il réside dans l' arborescence des sources oop d'OpenJDK . De plus, l'état du mot de marque ne contient qu'un uintptr_t, par conséquent, sa taille varie entre 4 et 8 octets dans les architectures 32 bits et 64 bits, respectivement
  • Un mot Klass, éventuellement compressé, qui représente un pointeur vers les métadonnées de classe. Avant Java 7, ils pointaient vers la génération permanente , mais à partir de Java 8, ils pointent vers le Metaspace
  • Un espace de 32 bits pour appliquer l'alignement des objets. Cela rend la mise en page plus conviviale pour le matériel, comme nous le verrons plus tard

Immédiatement après l'en-tête, il doit y avoir zéro ou plusieurs références aux champs d'instance. Dans ce cas, un mot est un mot machine natif, donc 32 bits sur les machines 32 bits héritées et 64 bits sur les systèmes plus modernes.

L'en-tête d'objet des tableaux, en plus des mots mark et klass, contient un mot de 32 bits pour représenter sa longueur.

2.2. Anatomie des déchets

Supposons que nous allons passer d'une architecture 32 bits héritée à une machine 64 bits plus moderne. Au début, nous pouvons nous attendre à obtenir une amélioration immédiate des performances. Cependant, ce n'est pas toujours le cas lorsque la JVM est impliquée.

Le principal coupable de cette éventuelle dégradation des performances est les références aux objets 64 bits. Les références 64 bits occupent deux fois l'espace des références 32 bits, ce qui entraîne une plus grande consommation de mémoire en général et des cycles GC plus fréquents. Plus le temps consacré aux cycles GC est important, moins il y a de tranches d'exécution du processeur pour nos threads d'application.

Alors, devrions-nous revenir en arrière et utiliser à nouveau ces architectures 32 bits? Même si c'était une option, nous ne pourrions pas avoir plus de 4 Go d'espace de tas dans des espaces de processus 32 bits sans un peu plus de travail.

3. POO compressés

En fin de compte, la JVM peut éviter de gaspiller de la mémoire en compressant les pointeurs d'objet ou Oops, afin que nous puissions avoir le meilleur des deux mondes: autoriser plus de 4 Go d'espace de tas avec des références 32 bits dans des machines 64 bits!

3.1. Optimisation de base

Comme nous l'avons vu précédemment, la JVM ajoute un remplissage aux objets afin que leur taille soit un multiple de 8 octets. Avec ces bourrages, les trois derniers bits en oops sont toujours à zéro. En effet, les nombres multiples de 8 se terminent toujours par 000 en binaire.

Puisque la JVM sait déjà que les trois derniers bits sont toujours à zéro, il est inutile de stocker ces zéros insignifiants dans le tas. Au lieu de cela, il suppose qu'ils sont là et stocke 3 autres bits plus significatifs que nous ne pouvions pas insérer dans 32 bits auparavant. Maintenant, nous avons une adresse 32 bits avec 3 zéros décalés vers la droite, nous compressons donc un pointeur 35 bits en un pointeur 32 bits. Cela signifie que nous pouvons utiliser jusqu'à 32 Go - 232 + 3 = 235 = 32 Go - d'espace de tas sans utiliser de références 64 bits.

Afin de faire fonctionner cette optimisation, lorsque la JVM a besoin de trouver un objet en mémoire, elle décale le pointeur vers la gauche de 3 bits (ajoute essentiellement ces 3 zéros à la fin). En revanche, lors du chargement d'un pointeur vers le tas, la JVM décale le pointeur vers la droite de 3 bits pour ignorer les zéros précédemment ajoutés. En gros, la JVM effectue un peu plus de calculs pour économiser de l'espace. Heureusement, le transfert de bits est une opération vraiment triviale pour la plupart des processeurs.

Pour activer la compression oop , nous pouvons utiliser l' indicateur de réglage -XX: + UseCompressedOops . La compression oop est le comportement par défaut à partir de Java 7 chaque fois que la taille maximale du tas est inférieure à 32 Go. Lorsque la taille maximale du tas est supérieure à 32 Go, la machine virtuelle Java désactivera automatiquement la compression oop . Ainsi, l'utilisation de la mémoire au-delà d'une taille de tas de 32 Go doit être gérée différemment.

3.2. Au-delà de 32 Go

Il est également possible d'utiliser des pointeurs compressés lorsque les tailles de tas Java sont supérieures à 32 Go. Bien que l'alignement d'objet par défaut soit de 8 octets, cette valeur est configurable à l'aide de l' indicateur de réglage -XX: ObjectAlignmentInBytes . La valeur spécifiée doit être une puissance de deux et doit être comprise entre 8 et 256 .

Nous pouvons calculer la taille maximale possible du tas avec des pointeurs compressés comme suit:

4 GB * ObjectAlignmentInBytes

Par exemple, lorsque l'alignement des objets est de 16 octets, nous pouvons utiliser jusqu'à 64 Go d'espace de tas avec des pointeurs compressés.

Veuillez noter qu'à mesure que la valeur d'alignement augmente, l'espace inutilisé entre les objets peut également augmenter. Par conséquent, nous ne pouvons tirer aucun avantage de l'utilisation de pointeurs compressés avec de grandes tailles de tas Java.

3.3. GC futuristes

ZGC, un nouvel ajout à Java 11, était un garbage collector expérimental et évolutif à faible latence.

Il peut gérer différentes plages de tailles de tas tout en maintenant les pauses du GC sous 10 millisecondes. Étant donné que ZGC doit utiliser des pointeurs colorés 64 bits, il ne prend pas en charge les références compressées . Ainsi, l'utilisation d'un GC à latence ultra-faible comme ZGC doit être comparée à l'utilisation de plus de mémoire.

À partir de Java 15, ZGC prend en charge les pointeurs de classe compressés mais ne prend toujours pas en charge les POO compressés.

Cependant, tous les nouveaux algorithmes GC n'échangeront pas la mémoire contre une faible latence. Par exemple, Shenandoah GC prend en charge les références compressées en plus d'être un GC avec des temps de pause faibles.

De plus, Shenandoah et ZGC sont finalisés à partir de Java 15.

4. Conclusion

Dans cet article, nous avons décrit un problème de gestion de la mémoire JVM dans les architectures 64 bits . Nous avons examiné les pointeurs compressés et l'alignement des objets , et nous avons vu comment la JVM peut résoudre ces problèmes, nous permettant d'utiliser des tailles de tas plus grandes avec moins de pointeurs inutiles et un minimum de calculs supplémentaires.

Pour une discussion plus détaillée sur les références compressées, il est fortement recommandé de consulter un autre excellent article d'Aleksey Shipilëv. De plus, pour voir comment l'allocation d'objets fonctionne dans HotSpot JVM, consultez l'article Disposition de la mémoire des objets en Java.