Cet article sera le deuxième article de la série sur l'optimisation des performances JVM (le premier article : Portail), et le compilateur Java sera le contenu principal abordé dans cet article.
Dans cet article, l'auteur (Eva Andreasson) présente d'abord différents types de compilateurs et compare les performances d'exécution de la compilation côté client, du compilateur côté serveur et de la compilation multicouche. Ensuite, à la fin de l'article, plusieurs méthodes d'optimisation JVM courantes sont présentées, telles que l'élimination du code mort, l'intégration de code et l'optimisation du corps de boucle.
La fonctionnalité la plus fière de Java, « l'indépendance de la plate-forme », provient du compilateur Java. Les développeurs de logiciels font de leur mieux pour écrire les meilleures applications Java possibles, et un compilateur s'exécute en arrière-plan pour produire un code exécutable efficace basé sur la plate-forme cible. Différents compilateurs conviennent à différentes exigences d'application, produisant ainsi différents résultats d'optimisation. Par conséquent, si vous comprenez mieux le fonctionnement des compilateurs et connaissez davantage de types de compilateurs, vous pourrez alors mieux optimiser votre programme Java.
Cet article met en évidence et explique les différences entre les différents compilateurs de machines virtuelles Java. Parallèlement, j'aborderai également certaines solutions d'optimisation couramment utilisées par les compilateurs juste à temps (JIT).
Qu'est-ce qu'un compilateur ?
En termes simples, un compilateur prend un programme en langage de programmation en entrée et un autre programme en langage exécutable en sortie. Javac est le compilateur le plus courant. Il existe dans tous les JDK. Javac prend le code Java en sortie et le convertit en code exécutable JVM - bytecode. Ces bytecodes sont stockés dans des fichiers se terminant par .class et chargés dans l'environnement d'exécution Java au démarrage du programme Java.
Le bytecode ne peut pas être lu directement par le processeur. Il doit également être traduit dans un langage d'instructions machine que la plate-forme actuelle peut comprendre. Il existe un autre compilateur dans la JVM qui est chargé de traduire le bytecode en instructions exécutables par la plate-forme cible. Certains compilateurs JVM nécessitent plusieurs niveaux d'étapes de code bytecode. Par exemple, un compilateur peut devoir passer par plusieurs formes différentes d'étapes intermédiaires avant de traduire le bytecode en instructions machine.
D'un point de vue indépendant de la plate-forme, nous voulons que notre code soit aussi indépendant que possible de la plate-forme.
Pour y parvenir, nous travaillons au dernier niveau de traduction (de la représentation de bytecode la plus basse au code machine réel) qui lie véritablement le code exécutable à l'architecture d'une plate-forme spécifique. Du niveau le plus élevé, nous pouvons diviser les compilateurs en compilateurs statiques et compilateurs dynamiques. Nous pouvons choisir le compilateur approprié en fonction de notre environnement d'exécution cible, des résultats d'optimisation que nous souhaitons et des contraintes de ressources que nous devons respecter. Dans l'article précédent, nous avons brièvement abordé les compilateurs statiques et les compilateurs dynamiques, et dans les sections suivantes, nous les expliquerons plus en détail.
Compilation statique VS compilation dynamique
Le javac que nous avons mentionné plus tôt est un exemple de compilation statique. Avec un compilateur statique, le code d'entrée est interprété une fois et la sortie est la forme sous laquelle le programme sera exécuté à l'avenir. Sauf si vous mettez à jour le code source et recompilez (via le compilateur), le résultat de l'exécution du programme ne changera jamais : en effet, l'entrée est une entrée statique et le compilateur est un compilateur statique.
Avec une compilation statique, le programme suivant :
Copiez le code comme suit :
staticint add7(int x ){ return x+7;}
sera converti en bytecode similaire à celui-ci :
Copiez le code comme suit :
iload0 bipush 7 iadd ireturn
Un compilateur dynamique compile dynamiquement un langage dans un autre langage. Ce qu'on appelle la dynamique fait référence à la compilation pendant l'exécution du programme - la compilation pendant l'exécution ! L'avantage de la compilation et de l'optimisation dynamiques est qu'elles peuvent gérer certaines modifications lors du chargement de l'application. Le runtime Java s'exécute souvent dans des environnements imprévisibles, voire changeants, la compilation dynamique est donc très adaptée au runtime Java. La plupart des JVM utilisent des compilateurs dynamiques, tels que les compilateurs JIT. Il convient de noter que la compilation dynamique et l'optimisation du code nécessitent l'utilisation de structures de données, de threads et de ressources CPU supplémentaires. Plus l'optimiseur ou l'analyseur de contexte de bytecode est avancé, plus il consomme de ressources. Mais ces coûts sont négligeables par rapport aux améliorations significatives des performances.
Types de JVM et indépendance de la plate-forme Java
Une caractéristique commune à toutes les implémentations JVM est de compiler le bytecode en instructions machine. Certaines JVM interprètent le code lorsque l'application est chargée et utilisent des compteurs de performances pour trouver du code « chaud » ; d'autres le font via la compilation. Le principal problème de la compilation est que la centralisation nécessite beaucoup de ressources, mais elle conduit également à de meilleures optimisations de performances.
Si vous êtes nouveau sur Java, les subtilités de la JVM vous rendront certainement confus. Mais la bonne nouvelle est que vous n’avez pas besoin de le comprendre ! La JVM gérera la compilation et l'optimisation du code, et vous n'aurez pas à vous soucier des instructions machine ni de la manière d'écrire le code pour qu'il corresponde au mieux à l'architecture de la plate-forme sur laquelle le programme s'exécute.
Du bytecode Java à l'exécutable
Une fois votre code Java compilé en bytecode, l’étape suivante consiste à traduire les instructions du bytecode en code machine. Cette étape peut être implémentée via un interpréteur ou via un compilateur.
expliquer
L'interprétation est le moyen le plus simple de compiler du bytecode. L'interpréteur trouve l'instruction matérielle correspondant à chaque instruction de bytecode sous la forme d'une table de recherche, puis l'envoie au CPU pour exécution.
Vous pouvez considérer l’interpréteur comme un dictionnaire : pour chaque mot spécifique (instruction de bytecode), il existe une traduction spécifique (instruction de code machine) qui lui correspond. Étant donné que l’interpréteur exécute immédiatement une instruction à chaque fois qu’il la lit, cette méthode ne peut pas optimiser un ensemble d’instructions. Dans le même temps, chaque fois qu'un bytecode est appelé, il doit être interprété immédiatement, donc l'interpréteur s'exécute très lentement. L'interpréteur exécute le code de manière très précise, mais comme le jeu d'instructions de sortie n'est pas optimisé, il peut ne pas produire des résultats optimaux pour le processeur de la plate-forme cible.
compiler
Le compilateur charge tout le code à exécuter dans le runtime. De cette façon, il peut faire référence à tout ou partie du contexte d'exécution lorsqu'il traduit le bytecode. Les décisions qu'il prend sont basées sur les résultats de l'analyse des graphes de code. Comme comparer différentes branches d'exécution et référencer les données de contexte d'exécution.
Une fois la séquence de bytecode traduite en un jeu d'instructions de code machine, une optimisation peut être effectuée sur la base de ce jeu d'instructions de code machine. Le jeu d’instructions optimisé est stocké dans une structure appelée tampon de code. Lorsque ces bytecodes sont à nouveau exécutés, le code optimisé peut être obtenu directement à partir de ce tampon de code et exécuté. Dans certains cas, le compilateur n'utilise pas l'optimiseur pour optimiser le code, mais utilise une nouvelle séquence d'optimisation : le « comptage des performances ».
L’avantage d’utiliser un cache de code est que les instructions du jeu de résultats peuvent être exécutées immédiatement sans avoir besoin de réinterprétation ou de compilation !
Cela peut réduire considérablement le temps d'exécution, en particulier pour les applications Java où une méthode est appelée plusieurs fois.
optimisation
Avec l'introduction de la compilation dynamique, nous avons la possibilité d'insérer des compteurs de performances. Par exemple, le compilateur insère un compteur de performances qui est incrémenté à chaque fois qu'un bloc de bytecode (correspondant à une méthode spécifique) est appelé. Le compilateur utilise ces compteurs pour rechercher les « blocs chauds » afin de déterminer quels blocs de code peuvent être optimisés pour apporter la plus grande amélioration des performances à l'application. Les données d'analyse des performances d'exécution peuvent aider le compilateur à prendre davantage de décisions d'optimisation à l'état en ligne, améliorant ainsi encore l'efficacité de l'exécution du code. Parce que nous obtenons des données d'analyse des performances du code de plus en plus précises, nous pouvons trouver plus de points d'optimisation et prendre de meilleures décisions d'optimisation, telles que : comment mieux séquencer les instructions et s'il faut utiliser un jeu d'instructions plus efficace, et remplacer le jeu d'instructions d'origine. s'il faut éliminer les opérations redondantes, etc.
Par exemple
Considérez le code Java suivant Copier le code Le code est le suivant :
staticint add7(int x ){ return x+7;}
Javac le traduira statiquement dans le bytecode suivant :
Copiez le code comme suit :
iload0
bipush 7
j'ajoute
retour
Lorsque cette méthode est appelée, le bytecode sera compilé dynamiquement en instructions machine. La méthode peut être optimisée lorsque le compteur de performances (s'il existe) atteint un seuil spécifié. Les résultats optimisés peuvent ressembler au jeu d’instructions machine suivant :
Copiez le code comme suit :
Léa Rax,[rdx+7] ret
Différents compilateurs conviennent à différentes applications
Différentes applications ont des besoins différents. Les applications côté serveur d'entreprise doivent généralement s'exécuter pendant une longue période, elles nécessitent donc généralement une meilleure optimisation des performances ; tandis que les applets côté client peuvent nécessiter des temps de réponse plus rapides et une consommation moindre de ressources. Discutons de trois compilateurs différents et de leurs avantages et inconvénients.
Compilateurs côté client
C1 est un compilateur d'optimisation bien connu. Lors du démarrage de la JVM, ajoutez le paramètre -client pour démarrer le compilateur. Par son nom, nous pouvons constater que C1 est un compilateur client. Il est idéal pour les applications client disposant de peu de ressources système disponibles ou nécessitant un démarrage rapide. C1 effectue l'optimisation du code à l'aide de compteurs de performances. Il s'agit d'une méthode d'optimisation simple avec moins d'intervention dans le code source.
Compilateurs côté serveur
Pour les applications à exécution longue (telles que les applications d'entreprise côté serveur), l'utilisation d'un compilateur côté client peut ne pas suffire. À l’heure actuelle, nous devrions choisir un compilateur côté serveur comme C2. L'optimiseur peut être démarré en ajoutant le serveur à la ligne de démarrage JVM. Étant donné que la plupart des applications côté serveur sont généralement de longue durée, vous pourrez collecter davantage de données d'optimisation des performances en utilisant le compilateur C2 qu'avec des applications côté client légères et de courte durée. Par conséquent, vous pourrez également appliquer des techniques et des algorithmes d’optimisation plus avancés.
Astuce : réchauffez votre compilateur côté serveur
Pour les déploiements côté serveur, le compilateur peut prendre un certain temps pour optimiser ces codes « chauds ». Le déploiement côté serveur nécessite donc souvent une phase de « préchauffage ». Ainsi, lorsque vous effectuez des mesures de performances sur des déploiements côté serveur, assurez-vous toujours que votre application a atteint un état stable ! Donner au compilateur suffisamment de temps pour compiler apportera de nombreux avantages à votre application.
Le compilateur côté serveur peut obtenir plus de données de réglage des performances que le compilateur côté client, de sorte qu'il puisse effectuer une analyse de branche plus complexe et trouver des chemins d'optimisation avec de meilleures performances. Plus vous disposez de données d’analyse des performances, meilleurs seront les résultats de l’analyse de votre application. Bien entendu, effectuer une analyse approfondie des performances nécessite davantage de ressources du compilateur. Par exemple, si la JVM utilise le compilateur C2, elle devra utiliser plus de cycles CPU, un cache de code plus important, etc.
Compilation multi-niveaux
La compilation multiniveau mélange la compilation côté client et la compilation côté serveur. Azul a été le premier à implémenter la compilation multicouche dans sa JVM Zing. Récemment, cette technologie a été adoptée par Oracle Java Hotspot JVM (après Java SE7). La compilation multiniveau combine les avantages des compilateurs côté client et côté serveur. Le compilateur client est actif dans deux situations : lorsque l'application démarre et lorsque les compteurs de performances atteignent des seuils de niveau inférieur pour effectuer des optimisations de performances. Le compilateur client insère également des compteurs de performances et prépare le jeu d'instructions pour une utilisation ultérieure par le compilateur côté serveur pour une optimisation avancée. La compilation multicouche est une méthode d'analyse des performances avec une utilisation élevée des ressources. Parce qu'il collecte des données lors d'une activité du compilateur à faible impact, ces données peuvent être utilisées ultérieurement dans des optimisations plus avancées. Cette approche fournit plus d'informations que l'analyse des compteurs à l'aide d'un code interprétatif.
La figure 1 décrit la comparaison des performances des interpréteurs, de la compilation côté client, de la compilation côté serveur et de la compilation multicouche. L'axe X est le temps d'exécution (unité de temps) et l'axe Y est la performance (nombre d'opérations par unité de temps)
Figure 1. Comparaison des performances du compilateur
Par rapport au code purement interprété, l’utilisation d’un compilateur côté client peut améliorer les performances d’environ 5 à 10 fois. Le gain de performances que vous obtenez dépend de l'efficacité du compilateur, des types d'optimiseurs disponibles et de l'adéquation de la conception de l'application à la plate-forme cible. Mais pour les développeurs de programmes, ce dernier point peut souvent être ignoré.
Par rapport aux compilateurs côté client, les compilateurs côté serveur peuvent souvent apporter des améliorations de performances de 30 à 50 %. Dans la plupart des cas, les améliorations de performances se font souvent au détriment de la consommation de ressources.
La compilation multiniveau combine les avantages des deux compilateurs. La compilation côté client a un temps de démarrage plus court et peut effectuer une optimisation rapide ; la compilation côté serveur peut effectuer des opérations d'optimisation plus avancées au cours du processus d'exécution ultérieur.
Quelques optimisations courantes du compilateur
Jusqu'à présent, nous avons discuté de ce que signifie optimiser le code et comment et quand la JVM effectue l'optimisation du code. Ensuite, je terminerai cet article en présentant quelques méthodes d'optimisation réellement utilisées par les compilateurs. L'optimisation JVM se produit en fait au stade du bytecode (ou au stade de représentation du langage de niveau inférieur), mais le langage Java sera utilisé ici pour illustrer ces méthodes d'optimisation. Il est impossible de couvrir toutes les méthodes d'optimisation JVM dans cette section ; bien sûr, j'espère que ces introductions vous inciteront à apprendre des centaines de méthodes d'optimisation plus avancées et à innover dans la technologie des compilateurs.
Élimination du code mort
L'élimination du code mort, comme son nom l'indique, consiste à éliminer le code qui ne sera jamais exécuté, c'est-à-dire le code « mort ».
Si le compilateur trouve des instructions redondantes pendant le fonctionnement, il supprimera ces instructions du jeu d'instructions d'exécution. Par exemple, dans le listing 1, l'une des variables ne sera jamais utilisée après une affectation, donc l'instruction d'affectation peut être complètement ignorée lors de l'exécution. Correspondant à l'opération au niveau du bytecode, la valeur de la variable n'a jamais besoin d'être chargée dans le registre. Ne pas avoir à charger signifie moins de temps CPU consommé, accélérant ainsi l'exécution du code, ce qui se traduit finalement par une application plus rapide - si le code de chargement est appelé plusieurs fois par seconde, l'effet d'optimisation sera plus évident.
Le listing 1 utilise du code Java pour illustrer un exemple d'attribution d'une valeur à une variable qui ne sera jamais utilisée.
Listing 1. Le code de copie du code mort est le suivant :
int timeToScaleMyApp (booléen sans finOfResources){
int reArchitect =24;
int patchByClustering =15;
int useZing =2;
si (sans fin de ressources)
retourner reArchitect + useZing ;
autre
retourner useZing ;
}
Pendant la phase de bytecode, si une variable est chargée mais jamais utilisée, le compilateur peut détecter et éliminer le code mort, comme le montre le listing 2. Si vous n'effectuez jamais cette opération de chargement, vous pouvez économiser du temps CPU et améliorer la vitesse d'exécution du programme.
Listing 2. Le code de copie de code optimisé est le suivant :
int timeToScaleMyApp (booléen sans finOfResources){
int reArchitect =24; //opération inutile supprimée ici…
int useZing =2;
si (sans fin de ressources)
retourner reArchitect + useZing ;
autre
retourner useZing ;
}
L'élimination de la redondance est une méthode d'optimisation qui améliore les performances des applications en supprimant les instructions en double.
De nombreuses optimisations tentent d'éliminer les instructions de saut au niveau des instructions machine (telles que JMP dans l'architecture x86). Les instructions de saut modifieront le registre du pointeur d'instruction, détournant ainsi le flux d'exécution du programme. Cette instruction de saut est une commande très gourmande en ressources par rapport aux autres instructions ASSEMBLY. C'est pourquoi nous souhaitons réduire ou supprimer ce type d'enseignement. L'intégration de code est une méthode d'optimisation très pratique et bien connue pour éliminer les instructions de transfert. Étant donné que l'exécution d'instructions de saut coûte cher, l'intégration de certaines petites méthodes fréquemment appelées dans le corps de la fonction apportera de nombreux avantages. Le listing 3-5 démontre les avantages de l'intégration.
Listing 3. Code de copie de la méthode d'appel Le code est le suivant :
int whenToEvaluateZing(int y){ return joursLeft(y)+ joursLeft(0)+ joursLeft(y+1);}
Listing 4. Le code de copie de la méthode appelée est le suivant :
int joursLeft(int x){ if(x ==0) return0 sinon return x -1;}
Listing 5. Le code de copie de la méthode en ligne est le suivant :
int quandToEvaluateZing(int y){
température int =0 ;
si(y==0)
température +=0 ;
autre
temp += y -1 ;
si(0==0)
température +=0 ;
autre
température +=0-1 ;
si(y+1==0)
température +=0 ;
autre
temp +=(y +1)-1;
température de retour ;
}
Dans le Listing 3-5, nous pouvons voir qu'une petite méthode est appelée trois fois dans un autre corps de méthode, et ce que nous voulons illustrer est le suivant : le coût de l'intégration de la méthode appelée directement dans le code sera inférieur à l'exécution de trois sauts. transférer des instructions.
L'intégration d'une méthode qui n'est pas souvent appelée ne fera peut-être pas une grande différence, mais l'intégration d'une méthode dite « chaude » (une méthode souvent appelée) peut apporter de nombreuses améliorations de performances. Le code intégré peut souvent être optimisé davantage, comme le montre le listing 6.
Listing 6. Une fois le code intégré, une optimisation supplémentaire peut être obtenue en copiant le code comme suit :
int whenToEvaluateZing(int y){ if(y ==0)return y; elseif(y ==-1)return y -1; elsereturn y + y -1;}
Optimisation de la boucle
L'optimisation des boucles joue un rôle important dans la réduction du coût supplémentaire lié à l'exécution du corps de la boucle. Le coût supplémentaire fait ici référence à des sauts coûteux, à de nombreux contrôles de condition et à des pipelines non optimisés (c'est-à-dire une série de jeux d'instructions qui n'effectuent aucune opération réelle et consomment des cycles CPU supplémentaires). Il existe de nombreux types d'optimisations de boucles. Voici quelques-unes des optimisations de boucles les plus populaires :
Fusion de corps de boucle : lorsque deux corps de boucle adjacents exécutent le même nombre de boucles, le compilateur essaie de fusionner les deux corps de boucle. Si deux corps de boucle sont totalement indépendants l’un de l’autre, ils peuvent également être exécutés simultanément (en parallèle).
Boucle d'inversion : à la base, vous remplacez une boucle while par une boucle do-while. Cette boucle do-while est placée dans une instruction if. Ce remplacement réduira deux opérations de saut ; mais il augmentera le jugement conditionnel, augmentant ainsi la quantité de code. Ce type d'optimisation est un excellent exemple d'échange de plus de ressources contre un code plus efficace : le compilateur pèse les coûts et les avantages et prend des décisions de manière dynamique au moment de l'exécution.
Réorganiser le corps de la boucle : réorganisez le corps de la boucle afin que l'intégralité du corps de la boucle puisse être stockée dans le cache.
Développez le corps de la boucle : réduisez le nombre de vérifications et de sauts de conditions de boucle. Vous pouvez considérer cela comme l'exécution de plusieurs itérations "en ligne" sans avoir à effectuer de vérification conditionnelle. Le déroulement du corps de la boucle comporte également certains risques, car cela peut réduire les performances en affectant le pipeline et un grand nombre d'instructions redondantes. Encore une fois, c'est au compilateur de décider s'il doit dérouler le corps de la boucle au moment de l'exécution, et cela vaut la peine de le dérouler si cela apporte une plus grande amélioration des performances.
Ce qui précède est un aperçu de la manière dont les compilateurs au niveau du bytecode (ou à un niveau inférieur) peuvent améliorer les performances des applications sur la plate-forme cible. Nous avons discuté de quelques méthodes d'optimisation courantes et populaires. En raison de l'espace limité, nous ne donnons que quelques exemples simples. Notre objectif est de susciter votre intérêt pour une étude approfondie de l’optimisation à travers la simple discussion ci-dessus.
Conclusion : points de réflexion et points clés
Choisissez différents compilateurs en fonction de différents objectifs.
1. Un interpréteur est la forme la plus simple de traduction du bytecode en instructions machine. Sa mise en œuvre est basée sur une table de recherche d'instructions.
2. Le compilateur peut optimiser en fonction des compteurs de performances, mais cela nécessite de consommer des ressources supplémentaires (cache de code, thread d'optimisation, etc.).
3. Le compilateur client peut améliorer les performances de 5 à 10 fois par rapport à l'interpréteur.
4. Le compilateur côté serveur peut apporter une amélioration des performances de 30 à 50 % par rapport au compilateur côté client, mais il nécessite plus de ressources.
5. La compilation multicouche combine les avantages des deux. Utilisez la compilation côté client pour des temps de réponse plus rapides, puis utilisez le compilateur côté serveur pour optimiser le code fréquemment appelé.
Il existe de nombreuses façons possibles d'optimiser le code ici. Une tâche importante du compilateur consiste à analyser toutes les méthodes d'optimisation possibles, puis à peser les coûts des différentes méthodes d'optimisation par rapport à l'amélioration des performances apportée par les instructions machine finales.