Les applications Java fonctionnent sur JVM, mais connaissez-vous la technologie JVM ? Cet article (la première partie de cette série) explique le fonctionnement de la machine virtuelle Java classique, notamment : les avantages et les inconvénients de l'écriture unique Java, les moteurs multiplateformes, les bases du garbage collection, les algorithmes GC classiques et l'optimisation de la compilation. Les articles suivants traiteront de l'optimisation des performances JVM, y compris de la dernière conception JVM, prenant en charge les performances et l'évolutivité des applications Java hautement concurrentes d'aujourd'hui.
Si vous êtes développeur, vous avez dû ressentir ce sentiment particulier, vous avez soudain un éclair d'inspiration, toutes vos idées sont liées, et vous pouvez rappeler vos idées précédentes sous un nouvel angle. Personnellement, j’aime le sentiment d’apprendre de nouvelles connaissances. J'ai eu cette expérience à plusieurs reprises en travaillant avec la technologie JVM, en particulier avec le garbage collection et l'optimisation des performances JVM. Dans ce nouveau monde de Java, j'espère partager ces inspirations avec vous. J'espère que vous êtes aussi impatient d'en savoir plus sur les performances de la JVM au moment où j'écris cet article.
Cette série d'articles est écrite pour tous les développeurs Java qui souhaitent en savoir plus sur les connaissances sous-jacentes de la JVM et sur ce que fait réellement la JVM. À un niveau élevé, je discuterai du garbage collection et de la recherche sans fin de la sécurité et de la vitesse de la mémoire libre sans affecter le fonctionnement des applications. Vous apprendrez les éléments clés de la JVM : les algorithmes de garbage collection et GC, l'optimisation de la compilation et certaines optimisations couramment utilisées. J'expliquerai également pourquoi le balisage Java est si difficile et je vous donnerai des conseils sur les moments où vous devriez envisager de tester les performances. Enfin, je parlerai de quelques nouvelles innovations dans JVM et GC, notamment Zing JVM d'Azul, IBM JVM et Garbage First (G1) d'Oracle.
J'espère que vous terminerez la lecture de cette série avec une compréhension plus approfondie de la nature des contraintes d'évolutivité de Java et de la manière dont ces contraintes nous obligent à créer un déploiement Java de manière optimale. J'espère que vous aurez un sentiment d'illumination et une bonne inspiration Java : arrêtez d'accepter ces limitations et changez-les ! Si vous n'êtes pas encore un travailleur open source, cette série peut vous encourager à vous développer dans ce domaine.
Performances JVM et défi « compiler une fois, exécuter n'importe où »
J'ai de nouvelles nouvelles pour ceux qui croient obstinément que la plate-forme Java est intrinsèquement lente. Lorsque Java est devenu une application d'entreprise, les problèmes de performances Java pour lesquels la JVM a été critiquée existaient déjà il y a plus de dix ans, mais cette conclusion est désormais dépassée. Il est vrai que si vous exécutez aujourd'hui des tâches simples statiques et déterministes sur différentes plates-formes de développement, vous constaterez probablement que l'utilisation de code optimisé par machine fonctionnera mieux que l'utilisation de n'importe quel environnement virtuel, sous la même JVM. Cependant, les performances de Java se sont considérablement améliorées au cours des 10 dernières années. La demande du marché et la croissance de l'industrie Java ont donné naissance à une poignée d'algorithmes de récupération de place, à de nouvelles innovations en matière de compilation et à une multitude d'heuristiques et d'optimisations qui ont fait progresser la technologie JVM. J'en couvrirai certains dans les prochains chapitres.
La beauté technique de la JVM est également son plus grand défi : rien ne peut être considéré comme une application « compilée une fois, exécutée n'importe où ». Plutôt que d'optimiser pour un cas d'utilisation, une application ou une charge utilisateur spécifique, la JVM suit en permanence ce que fait actuellement l'application Java et l'optimise en conséquence. Cette opération dynamique conduit à une série de problèmes dynamiques. Les développeurs travaillant sur la JVM ne s'appuient pas sur une compilation statique ni sur des taux d'allocation prévisibles lors de la conception d'innovations (du moins pas lorsque nous exigeons des performances dans les environnements de production).
La cause des performances de la JVM
Lors de mes premiers travaux, j'ai réalisé que le garbage collection était très difficile à « résoudre », et j'ai toujours été fasciné par les JVM et la technologie middleware. Ma passion pour les JVM a commencé lorsque j'étais dans l'équipe JRockit, codant une nouvelle façon de m'apprendre et de déboguer moi-même les algorithmes de garbage collection (voir Ressources). Ce projet (qui s'est transformé en une fonctionnalité expérimentale de JRockit et est devenu la base de l'algorithme Deterministic Garbage Collection) a commencé mon voyage dans la technologie JVM. J'ai travaillé chez BEA Systems, Intel, Sun et Oracle (parce qu'Oracle a acquis BEA Systems, j'ai brièvement travaillé pour Oracle). J'ai ensuite rejoint l'équipe d'Azul Systems pour gérer la JVM Zing, et maintenant je travaille pour Cloudera.
Le code optimisé par machine peut atteindre de meilleures performances (mais au détriment de la flexibilité), mais ce n'est pas une raison pour le considérer pour les applications d'entreprise avec un chargement dynamique et des fonctionnalités évoluant rapidement. Pour les avantages de Java, la plupart des entreprises sont plus disposées à sacrifier les performances à peine parfaites apportées par le code optimisé par machine.
1. Facile à coder et à développer des fonctions (ce qui signifie un temps plus court pour répondre au marché)
2. Faites appel à des programmeurs compétents
3. Utilisez les API Java et les bibliothèques standard pour un développement plus rapide
4. Portabilité : pas besoin de réécrire les applications Java pour les nouvelles plates-formes
Du code Java au bytecode
En tant que programmeur Java, vous êtes probablement familiarisé avec le codage, la compilation et l'exécution d'applications Java. Exemple : Supposons que vous ayez un programme (MyApp.java) et que vous souhaitiez maintenant qu'il s'exécute. Pour exécuter ce programme, vous devez d'abord le compiler avec javac (le compilateur de langage Java statique pour bytecode intégré au JDK). Sur la base du code Java, javac génère le bytecode exécutable correspondant et l'enregistre dans le fichier de classe du même nom : MyApp.class. Après avoir compilé le code Java en bytecode, vous pouvez démarrer le fichier de classe exécutable via la commande java (via la ligne de commande ou le script de démarrage, sans utiliser l'option de démarrage) pour exécuter votre application. De cette façon, votre classe est chargée dans le runtime (c'est-à-dire l'exécution de la machine virtuelle Java) et le programme commence à s'exécuter.
C'est ce que chaque application exécute en surface, mais explorons maintenant ce qui se passe exactement lorsque vous exécutez une commande Java. Qu'est-ce qu'une machine virtuelle Java ? La plupart des développeurs interagissent avec la JVM via un débogage continu, c'est-à-dire en sélectionnant et en attribuant des valeurs aux options de démarrage pour accélérer l'exécution de vos programmes Java tout en évitant les fameuses erreurs de « mémoire insuffisante ». Mais vous êtes-vous déjà demandé pourquoi nous avons besoin d’une JVM pour exécuter des applications Java en premier lieu ?
Qu'est-ce qu'une machine virtuelle Java ?
En termes simples, une JVM est un module logiciel qui exécute le bytecode d'une application Java et convertit le bytecode en instructions spécifiques au matériel et au système d'exploitation. Ce faisant, la JVM permet à un programme Java d'être exécuté dans un environnement différent après sa première écriture, sans nécessiter de modification du code d'origine. La portabilité de Java est la clé d'un langage d'application d'entreprise : les développeurs n'ont pas besoin de réécrire le code d'application pour différentes plates-formes car la JVM se charge de la traduction et de l'optimisation de la plate-forme.
Une JVM est essentiellement un environnement d'exécution virtuel qui agit comme une machine d'instructions de bytecode et est utilisée pour allouer des tâches d'exécution et effectuer des opérations de mémoire en interagissant avec la couche sous-jacente.
Une JVM s'occupe également de la gestion dynamique des ressources pour l'exécution des applications Java. Cela signifie qu'il maîtrise l'allocation et la libération de mémoire, maintient un modèle de thread cohérent sur chaque plate-forme et organise les instructions exécutables dans lesquelles l'application est exécutée d'une manière adaptée à l'architecture du processeur. La JVM libère les développeurs du suivi des références aux objets et de la durée pendant laquelle ils doivent exister dans le système. De même, cela ne nous oblige pas à gérer le moment où libérer de la mémoire - un problème dans les langages non dynamiques comme C.
Vous pouvez considérer la JVM comme un système d'exploitation spécialement conçu pour exécuter Java ; son travail consiste à gérer l'environnement d'exécution des applications Java. Une JVM est essentiellement un environnement d'exécution virtuel qui interagit avec l'environnement sous-jacent en tant que machine d'instructions de bytecode pour allouer des tâches d'exécution et effectuer des opérations de mémoire.
Présentation des composants JVM
Il existe de nombreux articles écrits sur les composants internes de la JVM et l'optimisation des performances. Comme base de cette série, je résumerai et présenterai les composants JVM. Ce bref aperçu est particulièrement utile pour les développeurs qui débutent sur la JVM et vous donnera envie d'en savoir plus sur les discussions plus approfondies qui suivent.
D'un langage à un autre - À propos des compilateurs Java
Un compilateur prend un langage en entrée, puis génère une autre instruction exécutable. Le compilateur Java a deux tâches principales :
1. Rendre le langage Java plus portable et n'avoir plus besoin d'être fixé sur une plate-forme spécifique lors de la première écriture ;
2. Assurez-vous qu'un code exécutable valide est produit pour une plate-forme spécifique.
Les compilateurs peuvent être statiques ou dynamiques. Un exemple de compilation statique est javac. Il prend le code Java en entrée et le convertit en bytecode (un langage exécuté dans la machine virtuelle Java). Le compilateur statique interprète le code d'entrée une fois et génère un formulaire exécutable, qui sera utilisé lors de l'exécution du programme. L’entrée étant statique, vous verrez toujours le même résultat. Ce n'est que si vous modifiez le code d'origine et recompilez que vous verrez un résultat différent.
Les compilateurs dynamiques , tels que les compilateurs Just-In-Time (JIT), convertissent dynamiquement un langage en un autre, ce qui signifie qu'ils le font pendant l'exécution du code. Le compilateur JIT vous permet de collecter ou de créer des analyses d'exécution (en insérant des comptes de performances), en utilisant les décisions du compilateur, en utilisant les données d'environnement disponibles. Un compilateur dynamique peut implémenter de meilleures séquences d'instructions pendant le processus de compilation dans un langage, remplacer une série d'instructions par des instructions plus efficaces et même éliminer les opérations redondantes. Au fil du temps, vous collecterez davantage de données de configuration du code et prendrez des décisions de compilation plus nombreuses et meilleures ; l'ensemble du processus est ce que nous appelons habituellement l'optimisation et la recompilation du code.
La compilation dynamique vous offre l'avantage de vous adapter aux changements dynamiques basés sur le comportement ou aux nouvelles optimisations à mesure que le nombre de chargements d'applications augmente. C'est pourquoi les compilateurs dynamiques sont parfaits pour les opérations Java. Il convient de noter que le compilateur dynamique demande des structures de données externes, des ressources de thread, une analyse et une optimisation du cycle CPU. Plus l’optimisation est profonde, plus vous aurez besoin de ressources. Cependant, dans la plupart des environnements, la couche supérieure ajoute très peu aux performances : des performances 5 à 10 fois plus rapides que votre interprétation pure.
L'allocation provoque le ramassage des ordures
Alloué dans chaque thread en fonction de chaque "espace d'adressage mémoire alloué par le processus Java", ou appelé tas Java, ou directement appelé tas. Dans le monde Java, l'allocation monothread est courante dans les applications client. Toutefois, l'allocation monothread n'est pas avantageuse dans les applications d'entreprise et les serveurs de charges de travail, car elle ne tire pas parti du parallélisme des environnements multicœurs actuels.
La conception d'applications parallèles oblige également la JVM à garantir que plusieurs threads n'attribuent pas le même espace d'adressage en même temps. Vous pouvez contrôler cela en plaçant un verrou sur tout l’espace alloué. Mais cette technique (souvent appelée verrouillage du tas) est très gourmande en performances, et le maintien ou la mise en file d'attente des threads peut affecter l'utilisation des ressources et les performances d'optimisation des applications. L’avantage des systèmes multicœurs est qu’ils créent le besoin d’une variété de nouvelles méthodes pour éviter les goulots d’étranglement monothread lors de l’allocation des ressources et de la sérialisation.
Une approche courante consiste à diviser le tas en parties, où chaque partition a une taille raisonnable pour l'application - elles doivent évidemment être ajustées, les taux d'allocation et la taille des objets varient considérablement d'une application à l'autre, et le nombre de threads pour une même application est également différent. Le Thread Local Allocation Buffer (TLAB), ou parfois la Thread Local Area (TLA), est une partition spécialisée dans laquelle les threads peuvent librement allouer sans déclarer un verrou de tas complet. Lorsque la zone est pleine, le tas est plein, ce qui signifie qu'il n'y a pas assez d'espace libre sur le tas pour placer des objets et qu'il faut allouer de l'espace. Lorsque le tas est plein, le garbage collection commence.
fragments
L'utilisation de TLAB pour intercepter les exceptions fragmente le tas afin de réduire l'efficacité de la mémoire. Si une application ne parvient pas à augmenter ou à allouer entièrement un espace TLAB lors de l'allocation d'objets, il existe un risque que l'espace soit trop petit pour générer de nouveaux objets. Un tel espace libre est considéré comme une « fragmentation ». Si l'application conserve une référence à l'objet puis alloue l'espace restant, cet espace finira par être libre pendant une longue période.
La fragmentation se produit lorsque des fragments sont dispersés sur le tas, ce qui gaspille de l'espace sur le tas à travers de petites sections d'espace mémoire inutilisées. L'allocation d'un « mauvais » espace TLAB pour votre application (en ce qui concerne la taille de l'objet, la taille des objets mixtes et le taux de conservation des références) est à l'origine d'une fragmentation accrue du tas. Au fur et à mesure que l'application s'exécute, le nombre de fragments augmente et occupe de l'espace dans le tas. La fragmentation entraîne une dégradation des performances et le système ne peut pas allouer suffisamment de threads et d'objets aux nouvelles applications. Le ramasse-miettes aura alors du mal à empêcher les exceptions de mémoire insuffisante.
Les déchets TLAB sont générés sur le chantier. Une façon d’éviter complètement ou temporairement la fragmentation consiste à optimiser l’espace TLAB sur chaque opération sous-jacente. Une approche typique de cette approche est que tant que l'application a un comportement d'allocation, elle doit être réajustée. Ceci peut être réalisé grâce à des algorithmes JVM complexes. Une autre méthode consiste à organiser des partitions de tas pour obtenir une allocation de mémoire plus efficace. Par exemple, la JVM peut implémenter des listes libres, qui sont liées entre elles sous la forme d'une liste de blocs de mémoire libres d'une taille spécifique. Un bloc de mémoire libre contigu est connecté à un autre bloc de mémoire contigu de même taille, créant ainsi un petit nombre de listes chaînées, chacune avec ses propres limites. Dans certains cas, les listes libres entraînent une meilleure allocation de mémoire. Les threads peuvent allouer des objets dans des blocs de taille similaire, créant potentiellement moins de fragmentation que si vous comptiez uniquement sur des TLAB de taille fixe.
Anecdotes sur le GC
Certains premiers éboueurs possédaient plusieurs anciennes générations, mais avoir plus de deux anciennes générations entraînerait des frais généraux supérieurs à la valeur. Une autre façon d'optimiser les allocations et de réduire la fragmentation consiste à créer ce qu'on appelle la jeune génération, qui est un espace de tas dédié à l'allocation de nouveaux objets. Le tas restant devient ce qu'on appelle l'ancienne génération. L'ancienne génération est utilisée pour attribuer des objets à longue durée de vie. Les objets supposés exister pendant une longue période incluent les objets qui ne sont pas des objets de grande taille ou des objets de grande taille. Afin de mieux comprendre cette méthode d'allocation, nous devons parler de certaines connaissances en matière de garbage collection.
Collecte des déchets et performances des applications
Le garbage collection est le garbage collector de la JVM qui permet de libérer la mémoire occupée qui n'est pas référencée. Lorsque le garbage collection est déclenché pour la première fois, toutes les références d'objet sont toujours conservées et l'espace occupé par les références précédentes est libéré ou réaffecté. Une fois que toute la mémoire récupérable a été collectée, l'espace attend d'être récupéré et alloué à nouveau à de nouveaux objets.
Le garbage collector ne peut jamais redéclarer un objet de référence, car cela enfreindrait la spécification standard JVM. L'exception à cette règle est une référence logicielle ou faible qui peut être interceptée si le garbage collector est sur le point de manquer de mémoire. Je vous recommande cependant fortement d'essayer d'éviter les références faibles, car l'ambiguïté de la spécification Java conduit à des interprétations erronées et à des erreurs d'utilisation. De plus, Java est conçu pour une gestion dynamique de la mémoire, car vous n'avez pas besoin de penser au moment et à l'endroit où libérer de la mémoire.
L’un des défis du garbage collector est d’allouer la mémoire d’une manière qui n’affecte pas les applications en cours d’exécution. Si vous n'effectuez pas de garbage collection autant que possible, votre application consommera de la mémoire ; si vous collectez trop souvent, vous perdrez en débit et en temps de réponse, ce qui aura un impact négatif sur l'application en cours d'exécution.
Algorithme GC
Il existe de nombreux algorithmes différents de garbage collection. Plusieurs points seront abordés en profondeur plus loin dans cette série. Au plus haut niveau, les deux principales méthodes de collecte des déchets sont le comptage de références et les collecteurs de suivi.
Le collecteur de comptage de références garde une trace du nombre de références vers lesquelles pointe un objet. Lorsque la référence d'un objet atteint 0, la mémoire sera immédiatement récupérée, ce qui est l'un des avantages de cette approche. La difficulté de l’approche de comptage de références réside dans la structure circulaire des données et dans la mise à jour de toutes les références en temps réel.
Le collecteur de suivi marque les objets qui sont encore référencés et utilise les objets marqués pour suivre et marquer de manière répétée tous les objets référencés. Lorsque tous les objets encore référencés sont marqués comme « actifs », tout l'espace non marqué sera récupéré. Cette approche gère les structures de données en anneau, mais dans de nombreux cas, le collecteur doit attendre que tout le marquage soit terminé avant de récupérer la mémoire non référencée.
Il existe différentes manières de mettre en œuvre la méthode ci-dessus. Les algorithmes les plus connus sont les algorithmes de marquage ou de copie, les algorithmes parallèles ou concurrents. J’en discuterai dans un article ultérieur.
D'une manière générale, la signification du garbage collection est d'allouer de l'espace d'adressage aux nouveaux et anciens objets du tas. Les « objets anciens » sont des objets qui ont survécu à de nombreuses collectes de déchets. Utilisez la nouvelle génération pour allouer de nouveaux objets et l'ancienne génération aux anciens objets. Cela peut réduire la fragmentation en recyclant rapidement les objets de courte durée qui occupent de la mémoire. Cela regroupe également les objets de longue durée et les place aux adresses d'ancienne génération dans l'espace. Tout cela réduit la fragmentation entre les objets à longue durée de vie et économise la mémoire du tas contre la fragmentation. Un effet positif de la nouvelle génération est qu’elle retarde la collecte plus coûteuse des objets de l’ancienne génération et que vous pouvez réutiliser le même espace pour des objets éphémères. (La collecte de l'ancien espace coûtera plus cher car les objets de longue durée contiendront plus de références et nécessiteront plus de traversées.)
Le dernier algorithme à mentionner est le compactage, qui est une méthode de gestion de la fragmentation de la mémoire. Le compactage rapproche essentiellement les objets pour libérer un espace mémoire contigu plus grand. Si vous êtes familier avec la fragmentation de disque et les outils qui la traitent, vous constaterez que le compactage y est très similaire, sauf que celui-ci s'exécute dans la mémoire tas Java. Je discuterai du compactage en détail plus tard dans la série.
Résumé : examen et faits saillants
La JVM permet la portabilité (programme unique, exécuté n'importe où) et la gestion dynamique de la mémoire, autant de fonctionnalités clés de la plateforme Java qui contribuent à sa popularité et à sa productivité accrue.
Dans le premier article sur les systèmes d'optimisation des performances JVM, j'ai expliqué comment un compilateur convertit le bytecode en langage d'instructions de la plate-forme cible et permet d'optimiser dynamiquement l'exécution des programmes Java. Différentes applications nécessitent différents compilateurs.
J'ai également brièvement abordé l'allocation de mémoire et le garbage collection, ainsi que leur lien avec les performances des applications Java. Fondamentalement, plus vous remplissez rapidement le tas et déclenchez le garbage collection plus fréquemment, plus le taux d'utilisation de votre application Java est élevé. L'un des défis du garbage collector est d'allouer la mémoire d'une manière qui n'affecte pas l'application en cours d'exécution, mais avant que l'application ne manque de mémoire. Dans les prochains articles, nous discuterons plus en détail des optimisations traditionnelles et nouvelles du garbage collection et des performances de la JVM.