Machine virtuelle Java - Guide rapide

La JVM est une spécification et peut avoir différentes implémentations, à condition qu'elles respectent les spécifications. Les spécifications peuvent être trouvées dans le lien ci-dessous -https://docs.oracle.com

Oracle a sa propre implémentation JVM (appelée HotSpot JVM), IBM a la sienne (la JVM J9, par exemple).

Les opérations définies dans la spécification sont données ci-dessous (source - Spécifications Oracle JVM, voir le lien ci-dessus) -

  • Le format de fichier 'classe'
  • Types de données
  • Types et valeurs primitifs
  • Types et valeurs de référence
  • Zones de données d'exécution
  • Frames
  • Représentation d'objets
  • Arithmétique à virgule flottante
  • Méthodes spéciales
  • Exceptions
  • Résumé du jeu d'instructions
  • Bibliothèques de classe
  • Conception publique, mise en œuvre privée

La JVM est une machine virtuelle, un ordinateur abstrait qui a son propre ISA, sa propre mémoire, sa propre pile, son tas, etc. Il fonctionne sur le système d'exploitation hôte et lui impose ses demandes de ressources.

L'architecture de HotSpot JVM 3 est illustrée ci-dessous -

Le moteur d'exécution comprend le garbage collector et le compilateur JIT. La JVM est disponible en deux versions -client and server. Les deux partagent le même code d'exécution mais diffèrent dans ce que JIT est utilisé. Nous en apprendrons plus à ce sujet plus tard. L'utilisateur peut contrôler la saveur à utiliser en spécifiant les indicateurs JVM -client ou -server . La JVM serveur a été conçue pour les applications Java de longue durée sur les serveurs.

La JVM est disponible en versions 32b et 64b. L'utilisateur peut spécifier la version à utiliser en utilisant -d32 ou -d64 dans les arguments de la machine virtuelle. La version 32b ne pouvait adresser que jusqu'à 4G de mémoire. Avec des applications critiques conservant de grands ensembles de données en mémoire, la version 64b répond à ce besoin.

La JVM gère le processus de chargement, de liaison et d'initialisation des classes et des interfaces de manière dynamique. Pendant le processus de chargement, leJVM finds the binary representation of a class and creates it.

Pendant le processus de liaison, le loaded classes are combined into the run-time state of the JVM so that they can be executed during the initialization phase. La JVM utilise essentiellement la table de symboles stockée dans le pool de constantes d'exécution pour le processus de liaison. L'initialisation consiste en faitexecuting the linked classes.

Types de chargeurs

le BootStrapLe chargeur de classe est au sommet de la hiérarchie du chargeur de classe. Il charge les classes JDK standard dans le répertoire lib du JRE .

le Extension class loader se trouve au milieu de la hiérarchie du chargeur de classe et est l'enfant immédiat du chargeur de classe bootstrap et charge les classes dans le répertoire lib \ ext du JRE.

le ApplicationLe chargeur de classe se trouve au bas de la hiérarchie du chargeur de classe et est l'enfant immédiat du chargeur de classe d'application. Il charge les fichiers JAR et les classes spécifiés par leCLASSPATH ENV variable.

Mise en relation

Le processus de liaison comprend les trois étapes suivantes -

Verification- Ceci est fait par le vérificateur Bytecode pour s'assurer que les fichiers .class générés (le Bytecode) sont valides. Sinon, une erreur est générée et le processus de liaison s'arrête.

Preparation - La mémoire est allouée à toutes les variables statiques d'une classe et elles sont initialisées avec les valeurs par défaut.

Resolution- Toutes les références mémoire symboliques sont remplacées par les références d'origine. Pour ce faire, la table de symboles dans la mémoire des constantes d'exécution de la zone de méthode de la classe est utilisée.

Initialisation

Il s'agit de la phase finale du processus de chargement de classe. Les variables statiques reçoivent des valeurs d'origine et des blocs statiques sont exécutés.

La spécification JVM définit certaines zones de données d'exécution qui sont nécessaires pendant l'exécution du programme. Certains d'entre eux sont créés lors du démarrage de la JVM. D'autres sont locaux aux threads et ne sont créés que lorsqu'un thread est créé (et détruit lorsque le thread est détruit). Ceux-ci sont énumérés ci-dessous -

Registre PC (compteur de programmes)

Il est local pour chaque thread et contient l'adresse de l'instruction JVM que le thread est en cours d'exécution.

Empiler

Il est local pour chaque thread et stocke les paramètres, les variables locales et les adresses de retour lors des appels de méthode. Une erreur StackOverflow peut se produire si un thread demande plus d'espace de pile que ce qui est autorisé. Si la pile est extensible dynamiquement, elle peut toujours lancer OutOfMemoryError.

Tas

Il est partagé entre tous les threads et contient des objets, des métadonnées de classes, des tableaux, etc., qui sont créés lors de l'exécution. Il est créé au démarrage de la JVM et détruit lors de l'arrêt de la JVM. Vous pouvez contrôler la quantité de tas que votre JVM demande au système d'exploitation à l'aide de certains indicateurs (nous en parlerons plus tard). Il faut veiller à ne pas exiger trop ou trop peu de mémoire, car cela a des implications importantes sur les performances. De plus, le GC gère cet espace et supprime continuellement les objets morts pour libérer l'espace.

Zone de méthode

Cette zone d'exécution est commune à tous les threads et est créée au démarrage de la machine virtuelle Java. Il stocke des structures par classe telles que le pool de constantes (plus à ce sujet plus tard), le code pour les constructeurs et les méthodes, les données de méthode, etc. JVM peut choisir d'ignorer GC. En outre, cela peut ou non augmenter selon les besoins de l'application. Le JLS n'impose rien à ce sujet.

Pool de constantes d'exécution

La JVM gère une structure de données par classe / par type qui agit comme la table de symboles (l'un de ses nombreux rôles) tout en liant les classes chargées.

Piles de méthodes natives

Lorsqu'un thread invoque une méthode native, il entre dans un nouveau monde dans lequel les structures et les restrictions de sécurité de la machine virtuelle Java n'entravent plus sa liberté. Une méthode native peut probablement accéder aux zones de données d'exécution de la machine virtuelle (cela dépend de l'interface de la méthode native), mais peut également faire tout ce qu'elle veut.

Collecte des ordures

La JVM gère l'ensemble du cycle de vie des objets en Java. Une fois qu'un objet est créé, le développeur n'a plus à s'en soucier. Au cas où l'objet deviendrait mort (c'est-à-dire qu'il n'y a plus de référence), il est éjecté du tas par le GC en utilisant l'un des nombreux algorithmes - GC série, CMS, G1, etc.

Pendant le processus GC, les objets sont déplacés en mémoire. Par conséquent, ces objets ne sont pas utilisables pendant le processus. L'application entière doit être arrêtée pendant toute la durée du processus. De telles pauses sont appelées pauses «stop-the-world» et représentent une surcharge énorme. Les algorithmes GC visent principalement à réduire ce temps. Nous en discuterons en détail dans les chapitres suivants.

Grâce au GC, les fuites de mémoire sont très rares en Java, mais elles peuvent arriver. Nous verrons dans les chapitres suivants comment créer une fuite mémoire en Java.

Dans ce chapitre, nous allons découvrir le compilateur JIT et la différence entre les langages compilés et interprétés.

Langues compilées ou interprétées

Les langages tels que C, C ++ et FORTRAN sont des langages compilés. Leur code est livré sous forme de code binaire ciblé sur la machine sous-jacente. Cela signifie que le code de haut niveau est compilé en code binaire à la fois par un compilateur statique écrit spécifiquement pour l'architecture sous-jacente. Le binaire produit ne fonctionnera sur aucune autre architecture.

D'un autre côté, les langages interprétés comme Python et Perl peuvent s'exécuter sur n'importe quelle machine, à condition qu'ils aient un interpréteur valide. Il parcourt ligne par ligne le code de haut niveau, le convertissant en code binaire.

Le code interprété est généralement plus lent que le code compilé. Par exemple, considérons une boucle. Un interprété convertira le code correspondant à chaque itération de la boucle. D'un autre côté, un code compilé fera de la traduction une seule. De plus, étant donné que les interprètes ne voient qu'une seule ligne à la fois, ils sont incapables d'exécuter un code significatif tel que, changer l'ordre d'exécution des instructions comme les compilateurs.

Nous examinerons un exemple d'une telle optimisation ci-dessous -

Adding two numbers stored in memory. Étant donné que l'accès à la mémoire peut consommer plusieurs cycles de processeur, un bon compilateur émettra des instructions pour récupérer les données de la mémoire et n'exécutera l'ajout que lorsque les données sont disponibles. Il n'attendra pas et en attendant, exécutera d'autres instructions. En revanche, une telle optimisation ne serait pas possible lors de l'interprétation puisque l'interpréteur n'a pas connaissance de l'intégralité du code à un moment donné.

Mais alors, les langues interprétées peuvent fonctionner sur n'importe quelle machine disposant d'un interpréteur valide de cette langue.

Java est-il compilé ou interprété?

Java a essayé de trouver un terrain d'entente. Étant donné que la JVM se trouve entre le compilateur javac et le matériel sous-jacent, le compilateur javac (ou tout autre compilateur) compile le code Java dans le Bytecode, qui est compris par une JVM spécifique à la plate-forme. La JVM compile ensuite le Bytecode en binaire à l'aide de la compilation JIT (Just-in-time), au fur et à mesure que le code s'exécute.

Points chauds

Dans un programme typique, il n'y a qu'une petite section de code qui est exécutée fréquemment, et souvent, c'est ce code qui affecte considérablement les performances de l'ensemble de l'application. Ces sections de code sont appeléesHotSpots.

Si une section de code n'est exécutée qu'une seule fois, la compilation serait un gaspillage d'effort, et il serait plus rapide d'interpréter le Bytecode à la place. Mais si la section est une section active et est exécutée plusieurs fois, la machine virtuelle Java la compilera à la place. Par exemple, si une méthode est appelée plusieurs fois, les cycles supplémentaires nécessaires pour compiler le code seraient compensés par le binaire le plus rapide généré.

De plus, plus la JVM exécute une méthode particulière ou une boucle, plus elle rassemble d'informations pour effectuer diverses optimisations afin qu'un binaire plus rapide soit généré.

Considérons le code suivant -

for(int i = 0 ; I <= 100; i++) {
   System.out.println(obj1.equals(obj2)); //two objects
}

Si ce code est interprété, l'interpréteur en déduirait pour chaque itération que les classes de obj1. C'est parce que chaque classe en Java a une méthode .equals (), qui est étendue à partir de la classe Object et peut être remplacée. Ainsi, même si obj1 est une chaîne pour chaque itération, la déduction sera toujours effectuée.

D'un autre côté, ce qui se passerait réellement, c'est que la JVM remarquerait que pour chaque itération, obj1 est de la classe String et par conséquent, elle générerait directement du code correspondant à la méthode .equals () de la classe String. Ainsi, aucune recherche ne sera requise et le code compilé s'exécuterait plus rapidement.

Ce type de comportement n'est possible que lorsque la machine virtuelle Java sait comment le code se comporte. Ainsi, il attend avant de compiler certaines sections du code.

Voici un autre exemple -

int sum = 7;
for(int i = 0 ; i <= 100; i++) {
   sum += i;
}

Un interpréteur, pour chaque boucle, récupère la valeur de 'sum' dans la mémoire, y ajoute 'I' et la stocke en mémoire. L'accès à la mémoire est une opération coûteuse et prend généralement plusieurs cycles CPU. Étant donné que ce code s'exécute plusieurs fois, il s'agit d'un HotSpot. Le JIT compilera ce code et effectuera l'optimisation suivante.

Une copie locale de «sum» serait stockée dans un registre, spécifique à un thread particulier. Toutes les opérations seraient effectuées sur la valeur du registre et lorsque la boucle se terminerait, la valeur serait réécrite dans la mémoire.

Et si d'autres threads accèdent également à la variable? Comme les mises à jour sont effectuées sur une copie locale de la variable par un autre thread, ils verront une valeur périmée. La synchronisation des threads est nécessaire dans de tels cas. Une primitive de synchronisation très basique serait de déclarer «somme» comme volatile. Maintenant, avant d'accéder à une variable, un thread viderait ses registres locaux et chercherait la valeur dans la mémoire. Après y avoir accédé, la valeur est immédiatement écrite dans la mémoire.

Voici quelques optimisations générales effectuées par les compilateurs JIT -

  • Inlining de méthode
  • Élimination du code mort
  • Heuristique pour optimiser les sites d'appels
  • Pliage constant

JVM prend en charge cinq niveaux de compilation -

  • Interpreter
  • C1 avec optimisation complète (pas de profilage)
  • C1 avec invocation et compteurs d'arrière-plan (profilage léger)
  • C1 avec profilage complet
  • C2 (utilise les données de profilage des étapes précédentes)

Utilisez -Xint si vous souhaitez désactiver tous les compilateurs JIT et n'utiliser que l'interpréteur.

Client vs serveur JIT

Utilisez -client et -server pour activer les modes respectifs.

Le compilateur client (C1) commence la compilation du code plus tôt que le compilateur serveur (C2). Ainsi, au moment où C2 a commencé la compilation, C1 aurait déjà compilé des sections de code.

Mais pendant qu'il attend, C2 profile le code pour en savoir plus que le C1. Par conséquent, le temps d'attente en cas de décalage par les optimisations peut être utilisé pour générer un binaire beaucoup plus rapide. Du point de vue de l'utilisateur, le compromis se situe entre le temps de démarrage du programme et le temps nécessaire à l'exécution du programme. Si le temps de démarrage est la prime, alors C1 doit être utilisé. Si l'application est censée fonctionner pendant une longue période (typique des applications déployées sur des serveurs), il est préférable d'utiliser C2 car il génère un code beaucoup plus rapide qui compense considérablement tout temps de démarrage supplémentaire.

Pour les programmes tels que les IDE (NetBeans, Eclipse) et d'autres programmes GUI, le temps de démarrage est critique. NetBeans peut prendre une minute ou plus pour démarrer. Des centaines de classes sont compilées au démarrage de programmes tels que NetBeans. Dans de tels cas, le compilateur C1 est le meilleur choix.

Notez qu'il existe deux versions de C1 - 32b and 64b. C2 vient uniquement dans64b.

Compilation à plusieurs niveaux

Dans les anciennes versions de Java, l'utilisateur aurait pu sélectionner l'une des options suivantes -

  • Interprète (-Xint)
  • C1 (-client)
  • C2 (-serveur)

Il est venu en Java 7. Il utilise le compilateur C1 pour démarrer, et à mesure que le code devient plus chaud, il passe au C2. Il peut être activé avec les options JVM suivantes: -XX: + TieredCompilation. La valeur par défaut estset to false in Java 7, and to true in Java 8.

Sur les cinq niveaux de compilation, la compilation à plusieurs niveaux utilise 1 -> 4 -> 5.

Sur une machine 32b, seule la version 32b de la JVM peut être installée. Sur une machine 64b, l'utilisateur a le choix entre la version 32b et la version 64b. Mais il y a certaines nuances à cela qui peuvent affecter les performances de nos applications Java.

Si l'application Java utilise moins de 4G de mémoire, nous devrions utiliser la JVM 32b même sur des machines 64b. En effet, les références mémoire dans ce cas ne seraient que 32b et leur manipulation serait moins coûteuse que la manipulation d'adresses 64b. Dans ce cas, la JVM 64b fonctionnerait moins bien même si nous utilisons OOPS (pointeurs d'objet ordinaires). En utilisant OOPS, la JVM peut utiliser des adresses 32b dans la JVM 64b. Cependant, leur manipulation serait plus lente que les références 32b réelles puisque les références natives sous-jacentes seraient toujours 64b.

Si notre application va consommer plus de mémoire 4G, nous devrons utiliser la version 64b car les références 32b ne peuvent adresser plus de 4G de mémoire. Nous pouvons installer les deux versions sur la même machine et basculer entre elles en utilisant la variable PATH.

Dans ce chapitre, nous allons découvrir les optimisations JIT.

Inlining de méthode

Dans cette technique d'optimisation, le compilateur décide de remplacer vos appels de fonction par le corps de la fonction. Voici un exemple pour le même -

int sum3;

static int add(int a, int b) {
   return a + b;
}

public static void main(String…args) {
   sum3 = add(5,7) + add(4,2);
}

//after method inlining
public static void main(String…args) {
   sum3 = 5+ 7 + 4 + 2;
}

En utilisant cette technique, le compilateur sauve la machine de la surcharge de tout appel de fonction (il nécessite de pousser et de sauter des paramètres dans la pile). Ainsi, le code généré s'exécute plus rapidement.

L'intégration de méthode ne peut être effectuée que pour les fonctions non virtuelles (fonctions qui ne sont pas remplacées). Considérez ce qui se passerait si la méthode 'add' était remplacée dans une sous-classe et que le type de l'objet contenant la méthode n'est pas connu avant l'exécution. Dans ce cas, le compilateur ne saurait pas quelle méthode insérer. Mais si la méthode était marquée comme "finale", alors le compilateur saurait facilement qu'elle peut être en ligne car elle ne peut être remplacée par aucune sous-classe. Notez qu'il n'est pas du tout garanti qu'une méthode finale soit toujours en ligne.

Élimination du code inaccessible et mort

Un code inaccessible est un code qui ne peut être atteint par aucun flux d'exécution possible. Nous considérerons l'exemple suivant -

void foo() {
   if (a) return;
   else return;
   foobar(a,b); //unreachable code, compile time error
}

Le code mort est également un code inaccessible, mais le compilateur crache une erreur dans ce cas. Au lieu de cela, nous recevons juste un avertissement. Chaque bloc de code tel que les constructeurs, les fonctions, try, catch, if, while, etc., ont leurs propres règles pour le code inaccessible défini dans le JLS (Java Language Specification).

Pliage constant

Pour comprendre le concept de pliage constant, consultez l'exemple ci-dessous.

final int num = 5;
int b = num * 6; //compile-time constant, num never changes
//compiler would assign b a value of 30.

Le cycle de vie d'un objet Java est géré par la JVM. Une fois qu'un objet est créé par le programmeur, nous n'avons pas à nous soucier du reste de son cycle de vie. La JVM trouvera automatiquement les objets qui ne sont plus utilisés et récupérera leur mémoire à partir du tas.

Le nettoyage de la mémoire est une opération majeure effectuée par JVM et l'adapter à nos besoins peut améliorer considérablement les performances de notre application. Il existe une variété d'algorithmes de récupération de place fournis par les JVM modernes. Nous devons être conscients des besoins de notre application pour décider de l'algorithme à utiliser.

Vous ne pouvez pas désallouer un objet par programme en Java, comme vous pouvez le faire dans des langages non GC tels que C et C ++. Par conséquent, vous ne pouvez pas avoir de références pendantes en Java. Cependant, vous pouvez avoir des références nulles (références qui font référence à une zone de mémoire où la JVM ne stockera jamais d'objets). Chaque fois qu'une référence nulle est utilisée, la machine virtuelle Java lève une exception NullPointerException.

Notez que s'il est rare de trouver des fuites de mémoire dans les programmes Java grâce au GC, elles se produisent. Nous allons créer une fuite de mémoire à la fin de ce chapitre.

Les GC suivants sont utilisés dans les JVM modernes

  • Collecteur série
  • Collecteur de débit
  • Collecteur CMS
  • Collecteur G1

Chacun des algorithmes ci-dessus effectue la même tâche: trouver des objets qui ne sont plus utilisés et récupérer la mémoire qu'ils occupent dans le tas. Une des approches naïves à cela serait de compter le nombre de références que chaque objet possède et de le libérer dès que le nombre de références devient 0 (cela est également connu sous le nom de comptage de références). Pourquoi est-ce naïf? Considérez une liste chaînée circulaire. Chacun de ses nœuds y aura une référence, mais l'objet entier n'est référencé de nulle part et devrait être libéré, idéalement.

La JVM libère non seulement la mémoire, mais fusionne également de petits mandrins de mémoire en plus gros. Ceci est fait pour éviter la fragmentation de la mémoire.

Sur une note simple, un algorithme GC typique effectue les activités suivantes -

  • Recherche d'objets inutilisés
  • Libérer la mémoire qu'ils occupent dans le tas
  • Coalescence des fragments

Le GC doit arrêter les threads d'application pendant son exécution. En effet, il déplace les objets lors de son exécution et, par conséquent, ces objets ne peuvent pas être utilisés. Ces arrêts sont appelés `` pauses stop-the-world '' et minimiser la fréquence et la durée de ces pauses est ce que nous visons lors du réglage de notre GC.

Mémoire coalescente

Une simple démonstration de la fusion de la mémoire est présentée ci-dessous

La partie ombrée correspond aux objets qui doivent être libérés. Même après avoir récupéré tout l'espace, nous ne pouvons allouer qu'un objet de taille maximale = 75 Ko. C'est même après que nous ayons 200 Ko d'espace libre comme indiqué ci-dessous

La plupart des JVM divisent le tas en trois générations - the young generation (YG), the old generation (OG) and permanent generation (also called tenured generation). Quelles sont les raisons d'une telle réflexion?

Des études empiriques ont montré que la plupart des objets créés ont une durée de vie très courte -

La source

https://www.oracle.com

Comme vous pouvez le voir, à mesure que de plus en plus d'objets sont alloués avec le temps, le nombre d'octets survivants diminue (en général). Les objets Java ont un taux de mortalité élevé.

Nous examinerons un exemple simple. La classe String en Java est immuable. Cela signifie que chaque fois que vous devez modifier le contenu d'un objet String, vous devez créer un nouvel objet. Supposons que vous apportiez des modifications à la chaîne 1000 fois dans une boucle comme indiqué dans le code ci-dessous -

String str = “G11 GC”;

for(int i = 0 ; i < 1000; i++) {
   str = str + String.valueOf(i);
}

Dans chaque boucle, nous créons un nouvel objet chaîne, et la chaîne créée lors de l'itération précédente devient inutile (c'est-à-dire qu'elle n'est référencée par aucune référence). La durée de vie de cet objet n'était qu'une itération - ils seront collectés par le GC en un rien de temps. Ces objets de courte durée sont conservés dans la zone des jeunes générations du tas. Le processus de collecte des objets de la jeune génération s'appelle le ramassage des ordures mineures et provoque toujours une pause «stopthe-world».

Au fur et à mesure que la jeune génération se remplit, le GC fait un petit garbage collection. Les objets morts sont supprimés et les objets vivants sont déplacés vers l'ancienne génération. Les threads d'application s'arrêtent pendant ce processus.

Ici, nous pouvons voir les avantages qu'offre un tel design de génération. La jeune génération n'est qu'une petite partie du tas et se remplit rapidement. Mais le traitement prend beaucoup moins de temps que le temps nécessaire pour traiter l'ensemble du tas. Ainsi, les pauses «stop-theworld» dans ce cas sont beaucoup plus courtes, bien que plus fréquentes. Nous devrions toujours viser des pauses plus courtes sur des pauses plus longues, même si elles peuvent être plus fréquentes. Nous en discuterons en détail dans les sections suivantes de ce didacticiel.

La jeune génération est divisée en deux espaces - eden and survivor space. Les objets qui ont survécu pendant la collection d'Eden sont déplacés vers l'espace de survivant, et ceux qui survivent à l'espace de survivant sont déplacés vers l'ancienne génération. La jeune génération est compactée pendant sa récolte.

Lorsque les objets sont déplacés vers l'ancienne génération, ils finissent par se remplir et doivent être collectés et compactés. Différents algorithmes adoptent différentes approches à cet égard. Certains d'entre eux arrêtent les threads d'application (ce qui conduit à une longue pause `` stop-the-world '' puisque l'ancienne génération est assez grande par rapport à la jeune génération), tandis que certains le font simultanément pendant que les threads d'application continuent de fonctionner. Ce processus est appelé GC complet. Deux de ces collectionneurs sontCMS and G1.

Analysons maintenant ces algorithmes en détail.

GC série

c'est le GC par défaut sur les machines de classe client (machines monoprocesseur ou JVM 32b, Windows). En règle générale, les GC sont fortement multithread, mais pas le GC série. Il a un seul thread pour traiter le tas et il arrêtera les threads d'application chaque fois qu'il effectue un GC mineur ou un GC majeur. Nous pouvons commander à la JVM d'utiliser ce GC en spécifiant le drapeau:-XX:+UseSerialGC. Si nous voulons qu'il utilise un algorithme différent, spécifiez le nom de l'algorithme. A noter que l'ancienne génération est entièrement compactée lors d'un GC majeur.

GC de débit

Ce GC est par défaut sur les JVM 64b et les machines multi-processeurs. Contrairement au GC série, il utilise plusieurs threads pour traiter la jeune et l'ancienne génération. Pour cette raison, le GC est également appelé leparallel collector. Nous pouvons commander à notre JVM d'utiliser ce collecteur en utilisant l'indicateur:-XX:+UseParallelOldGC ou -XX:+UseParallelGC(à partir de JDK 8). Les threads d'application sont arrêtés pendant qu'il effectue un garbage collection majeur ou mineur. Comme le collectionneur en série, il compacte entièrement la jeune génération lors d'un grand GC.

Le GC de débit collecte le YG et l'OG. Lorsque l'eden est rempli, le collecteur en éjecte des objets vivants dans l'OG ou dans l'un des espaces survivants (SS0 et SS1 dans le diagramme ci-dessous). Les objets morts sont jetés pour libérer l'espace qu'ils occupaient.

Avant GC de YG

Après GC de YG

Pendant un CPG complet, le collecteur de débit vide l'intégralité de YG, SS0 et SS1. Après l'opération, l'OG ne contient que des objets vivants. Nous devons noter que les deux collecteurs ci-dessus arrêtent les threads d'application pendant le traitement du tas. Cela signifie de longues pauses «arrêt du monde» pendant un GC majeur. Les deux algorithmes suivants visent à les éliminer, au prix de plus de ressources matérielles -

Collecteur CMS

Il signifie «balayage de marque simultané». Sa fonction est d'utiliser des threads d'arrière-plan pour parcourir périodiquement l'ancienne génération et se débarrasser des objets morts. Mais pendant un GC mineur, les threads d'application sont arrêtés. Cependant, les pauses sont assez petites. Cela fait du CMS un collecteur à faible pause.

Ce collecteur a besoin de temps CPU supplémentaire pour parcourir le tas lors de l'exécution des threads d'application. De plus, les threads d'arrière-plan collectent simplement le tas et n'effectuent aucun compactage. Ils peuvent conduire à la fragmentation du tas. Au fur et à mesure que cela continue, après un certain temps, le CMS arrêtera tous les threads d'application et compactera le tas en utilisant un seul thread. Utilisez les arguments JVM suivants pour indiquer à la JVM d'utiliser le collecteur CMS -

“XX:+UseConcMarkSweepGC -XX:+UseParNewGC” comme arguments JVM pour lui dire d'utiliser le collecteur CMS.

Avant GC

Après GC

Notez que la collecte est effectuée simultanément.

GC G1

Cet algorithme fonctionne en divisant le tas en un certain nombre de régions. Comme le collecteur CMS, il arrête les threads d'application tout en effectuant un GC mineur et utilise des threads d'arrière-plan pour traiter l'ancienne génération tout en maintenant les threads d'application. Puisqu'il a divisé l'ancienne génération en régions, il continue de les compacter tout en déplaçant des objets d'une région à une autre. Par conséquent, la fragmentation est minimale. Vous pouvez utiliser le drapeau:XX:+UseG1GCpour dire à votre JVM d'utiliser cet algorithme. Comme CMS, il a également besoin de plus de temps CPU pour traiter le tas et exécuter simultanément les threads d'application.

Cet algorithme a été conçu pour traiter des tas plus volumineux (> 4G), qui sont divisés en plusieurs régions différentes. Certaines de ces régions comprennent la jeune génération et les autres comprennent les personnes âgées. Le YG est effacé en utilisant traditionnellement - tous les threads d'application sont arrêtés et tous les objets qui sont encore vivants pour l'ancienne génération ou l'espace de survie.

Notez que tous les algorithmes GC ont divisé le tas en YG et OG et utilisent un STWP pour effacer le YG. Ce processus est généralement très rapide.

Dans le dernier chapitre, nous avons découvert divers Gcs générationnels. Dans ce chapitre, nous discuterons de la façon de régler le GC.

Taille du tas

La taille du tas est un facteur important dans les performances de nos applications Java. S'il est trop petit, il sera rempli fréquemment et devra donc être collecté fréquemment par le GC. D'un autre côté, si nous augmentons simplement la taille du tas, bien qu'il doive être collecté moins fréquemment, la durée des pauses augmenterait.

De plus, l'augmentation de la taille du tas a une pénalité sévère sur le système d'exploitation sous-jacent. En utilisant la pagination, le système d'exploitation permet à nos programmes d'application de voir beaucoup plus de mémoire que ce qui est réellement disponible. Le système d'exploitation gère cela en utilisant de l'espace d'échange sur le disque, en y copiant des parties inactives des programmes. Lorsque ces parties sont nécessaires, le système d'exploitation les recopie du disque vers la mémoire.

Supposons qu'une machine dispose de 8G de mémoire, et que la JVM voit 16G de mémoire virtuelle, la JVM ne saurait qu'il n'y a en fait que 8G disponibles sur le système. Il demandera simplement 16G au système d'exploitation, et une fois qu'il aura obtenu cette mémoire, il continuera à l'utiliser. Le système d'exploitation devra échanger beaucoup de données d'entrée et de sortie, ce qui représente une énorme pénalité en termes de performances pour le système.

Et puis viennent les pauses qui se produiraient pendant le GC complet d'une telle mémoire virtuelle. Étant donné que le GC agira sur l'ensemble du tas pour la collecte et le compactage, il devra attendre beaucoup pour que la mémoire virtuelle soit permutée hors du disque. Dans le cas d'un collecteur simultané, les threads d'arrière-plan devront attendre beaucoup pour que les données soient copiées de l'espace d'échange vers la mémoire.

Voici donc la question de savoir comment décider de la taille optimale du tas. La première règle est de ne jamais demander au système d'exploitation plus de mémoire qu'il n'en existe réellement. Cela éviterait totalement le problème des échanges fréquents. Si plusieurs JVM sont installés et en cours d'exécution sur la machine, la demande de mémoire totale de tous ceux-ci combinés est inférieure à la RAM réelle présente dans le système.

Vous pouvez contrôler la taille de la demande de mémoire par la JVM à l'aide de deux indicateurs -

  • -XmsN - Contrôle la mémoire initiale demandée.

  • -XmxN - Contrôle la mémoire maximale qui peut être demandée.

Les valeurs par défaut de ces deux indicateurs dépendent du système d'exploitation sous-jacent. Par exemple, pour les JVM 64b s'exécutant sur MacOS, -XmsN = 64M et -XmxN = minimum de 1G ou 1/4 de la mémoire physique totale.

Notez que la JVM peut s'ajuster automatiquement entre les deux valeurs. Par exemple, s'il remarque que trop de GC se produit, il continuera d'augmenter la taille de la mémoire tant qu'elle est sous -XmxN et que les objectifs de performances souhaités sont atteints.

Si vous connaissez exactement la quantité de mémoire dont votre application a besoin, vous pouvez définir -XmsN = -XmxN. Dans ce cas, la JVM n'a pas besoin de déterminer une valeur «optimale» du tas, et par conséquent, le processus GC devient un peu plus efficace.

Tailles de génération

Vous pouvez décider de la quantité de tas que vous souhaitez allouer au GY et de la quantité que vous souhaitez allouer à l'OG. Ces deux valeurs affectent les performances de nos applications de la manière suivante.

Si la taille du YG est très grande, elle sera collectée moins fréquemment. Cela se traduirait par un nombre moindre d'objets promus vers l'OG. D'un autre côté, si vous augmentez trop la taille de l'OG, la collecte et le compactage prendraient trop de temps et cela entraînerait de longues pauses STW. Ainsi, l'utilisateur doit trouver un équilibre entre ces deux valeurs.

Voici les indicateurs que vous pouvez utiliser pour définir ces valeurs -

  • -XX:NewRatio=N: Rapport du YG à l'OG (valeur par défaut = 2)

  • -XX:NewSize=N: Taille initiale de YG

  • -XX:MaxNewSize=N: Taille maximale de YG

  • -XmnN: Définissez NewSize et MaxNewSize sur la même valeur à l'aide de cet indicateur

La taille initiale du YG est déterminée par la valeur de NewRatio par la formule donnée -

(total heap size) / (newRatio + 1)

Comme la valeur initiale de newRatio est 2, la formule ci-dessus donne la valeur initiale de YG à 1/3 de la taille totale du tas. Vous pouvez toujours remplacer cette valeur en spécifiant explicitement la taille du YG à l'aide de l'indicateur NewSize. Cet indicateur n'a pas de valeur par défaut, et s'il n'est pas défini explicitement, la taille du YG continuera à être calculée en utilisant la formule ci-dessus.

Permagen et Metaspace

Le permagen et le metaspace sont des zones de tas où la JVM conserve les métadonnées des classes. L'espace est appelé le «permagen» en Java 7, et en Java 8, il est appelé le «metaspace». Ces informations sont utilisées par le compilateur et le runtime.

Vous pouvez contrôler la taille du permagen à l'aide des indicateurs suivants: -XX: PermSize=N et -XX:MaxPermSize=N. La taille de Metaspace peut être contrôlée en utilisant:-XX:Metaspace- Size=N et -XX:MaxMetaspaceSize=N.

Il existe certaines différences dans la gestion du permagen et de la méta-espace lorsque les valeurs d'indicateur ne sont pas définies. Par défaut, les deux ont une taille initiale par défaut. Mais alors que le méta-espace peut occuper autant de tas que nécessaire, le permagen ne peut occuper plus que les valeurs initiales par défaut. Par exemple, la JVM 64b a 82 Mo d'espace de tas comme taille permagène maximale.

Notez que dans la mesure où le métaspace peut occuper des quantités illimitées de mémoire, sauf indication contraire, il peut y avoir une erreur de mémoire insuffisante. Un GC complet a lieu chaque fois que ces régions sont redimensionnées. Par conséquent, lors du démarrage, s'il y a beaucoup de classes qui sont chargées, la métaspace peut continuer à se redimensionner, ce qui entraîne un GC complet à chaque fois. Ainsi, le démarrage des applications volumineuses prend beaucoup de temps au cas où la taille initiale de la méta-espace serait trop faible. C'est une bonne idée d'augmenter la taille initiale car cela réduit le temps de démarrage.

Bien que le permagen et le metaspace contiennent les métadonnées de la classe, ce n'est pas permanent et l'espace est récupéré par le GC, comme dans le cas des objets. C'est généralement le cas des applications serveur. Chaque fois que vous effectuez un nouveau déploiement sur le serveur, les anciennes métadonnées doivent être nettoyées car les nouveaux chargeurs de classe auront désormais besoin d'espace. Cet espace est libéré par le GC.

Nous discuterons du concept de fuite de mémoire en Java dans ce chapitre.

Le code suivant crée une fuite de mémoire en Java -

void queryDB() {
   try{
      Connection conn = ConnectionFactory.getConnection();
      PreparedStatement ps = conn.preparedStatement("query"); // executes a
      SQL
      ResultSet rs = ps.executeQuery();
      while(rs.hasNext()) {
         //process the record
      }
   } catch(SQLException sqlEx) {
      //print stack trace
   }
}

Dans le code ci-dessus, lorsque la méthode se termine, nous n'avons pas fermé l'objet de connexion. Ainsi, la connexion physique reste ouverte avant que le GC ne soit déclenché et considère l'objet de connexion comme inaccessible. Maintenant, il appellera la méthode finale sur l'objet de connexion, mais il se peut qu'elle ne soit pas implémentée. Par conséquent, l'objet ne sera pas ramassé dans ce cycle.

La même chose se produira dans la suite jusqu'à ce que le serveur distant voit que la connexion est ouverte depuis longtemps et la met fin de force. Ainsi, un objet sans référence reste longtemps en mémoire ce qui crée une fuite.