Optimisation des performances JVM, partie 2: Compilateurs

Les compilateurs Java occupent une place centrale dans ce deuxième article de la série d'optimisation des performances JVM. Eva Andreasson présente les différentes races de compilateurs et compare les performances de la compilation client, serveur et à plusieurs niveaux. Elle conclut par un aperçu des optimisations JVM courantes telles que l'élimination du code mort, l'inlining et l'optimisation des boucles.

Un compilateur Java est la source de la célèbre indépendance de la plate-forme Java. Un développeur de logiciel écrit la meilleure application Java possible, puis le compilateur travaille en coulisses pour produire un code d'exécution efficace et performant pour la plate-forme cible prévue. Différents types de compilateurs répondent à divers besoins d'application, produisant ainsi des résultats de performances spécifiques souhaités. Plus vous en saurez sur les compilateurs, sur leur fonctionnement et sur les types disponibles, plus vous pourrez optimiser les performances des applications Java.

Ce deuxième article de la série d' optimisation des performances JVM met en évidence et explique les différences entre les différents compilateurs de machines virtuelles Java. Je discuterai également de certaines optimisations courantes utilisées par les compilateurs Just-In-Time (JIT) pour Java. (Voir «Optimisation des performances JVM, Partie 1» pour une présentation et une introduction à la série JVM.)

Qu'est-ce qu'un compilateur?

En termes simples, un compilateur prend un langage de programmation comme entrée et produit un langage exécutable comme sortie. Un compilateur communément connu est javac, qui est inclus dans tous les kits de développement Java standard (JDK). javacprend le code Java comme entrée et le traduit en bytecode - le langage exécutable pour une JVM. Le bytecode est stocké dans des fichiers .class qui sont chargés dans l'environnement d'exécution Java au démarrage du processus Java.

Le bytecode ne peut pas être lu par les processeurs standard et doit être traduit dans un langage d'instructions que la plate-forme d'exécution sous-jacente peut comprendre. Le composant de la JVM qui est responsable de la traduction du bytecode en instructions de plate-forme exécutables est encore un autre compilateur. Certains compilateurs JVM gèrent plusieurs niveaux de traduction; par exemple, un compilateur peut créer divers niveaux de représentation intermédiaire du bytecode avant qu'il ne se transforme en instructions machine réelles, l'étape finale de la traduction.

Bytecode et la JVM

Si vous souhaitez en savoir plus sur le bytecode et la JVM, consultez «Notions de base sur le bytecode» (Bill Venners, JavaWorld).

D'un point de vue indépendant de la plate-forme, nous voulons que le code reste indépendant de la plate-forme autant que possible, de sorte que le dernier niveau de traduction - de la représentation la plus basse au code machine réel - soit l'étape qui verrouille l'exécution à l'architecture de processeur d'une plate-forme spécifique. . Le niveau de séparation le plus élevé se situe entre les compilateurs statiques et dynamiques. À partir de là, nous avons des options en fonction de l'environnement d'exécution que nous ciblons, des résultats de performance que nous souhaitons et des restrictions de ressources que nous devons respecter. J'ai brièvement discuté des compilateurs statiques et dynamiques dans la partie 1 de cette série. Dans les sections suivantes, j'expliquerai un peu plus.

Compilation statique vs dynamique

Un exemple de compilateur statique est celui mentionné précédemment javac. Avec les compilateurs statiques, le code d'entrée est interprété une fois et l'exécutable de sortie est sous la forme qui sera utilisée lors de l'exécution du programme. À moins que vous n'apportiez des modifications à votre source d'origine et que vous ne recompiliez le code (à l'aide du compilateur), la sortie aboutira toujours au même résultat; c'est parce que l'entrée est une entrée statique et le compilateur est un compilateur statique.

Dans une compilation statique, le code Java suivant

static int add7( int x ) { return x+7; }

entraînerait quelque chose de similaire à ce bytecode:

iload0 bipush 7 iadd ireturn

Un compilateur dynamique traduit dynamiquement d'une langue à une autre, ce qui signifie que cela se produit lorsque le code est exécuté - pendant l'exécution! La compilation et l'optimisation dynamiques donnent aux runtimes l'avantage de pouvoir s'adapter aux changements de charge applicative. Les compilateurs dynamiques sont très bien adaptés aux environnements d'exécution Java, qui s'exécutent généralement dans des environnements imprévisibles et en constante évolution. La plupart des JVM utilisent un compilateur dynamique tel qu'un compilateur Just-In-Time (JIT). Le hic, c'est que les compilateurs dynamiques et l'optimisation du code ont parfois besoin de structures de données, de threads et de ressources CPU supplémentaires. Plus l'optimisation ou l'analyse du contexte de bytecode sont avancées, plus la compilation consomme de ressources. Dans la plupart des environnements, la surcharge est encore très faible par rapport au gain de performances significatif du code de sortie.

Variétés JVM et indépendance de la plateforme Java

Toutes les implémentations JVM ont une chose en commun, à savoir leur tentative de traduire le bytecode de l'application en instructions machine. Certaines machines virtuelles Java interprètent le code d'application lors du chargement et utilisent des compteurs de performances pour se concentrer sur le code «chaud». Certaines JVM ignorent l'interprétation et s'appuient uniquement sur la compilation. L'intensité des ressources de la compilation peut être un plus gros succès (en particulier pour les applications côté client), mais elle permet également des optimisations plus avancées. Voir Ressources pour plus d'informations.

Si vous êtes un débutant en Java, les subtilités des JVM seront beaucoup à comprendre. La bonne nouvelle est que vous n'en avez pas vraiment besoin! La JVM gère la compilation et l'optimisation du code, vous n'avez donc pas à vous soucier des instructions de la machine et de la manière optimale d'écrire le code d'application pour une architecture de plate-forme sous-jacente.

Du bytecode Java à l'exécution

Une fois que vous avez compilé votre code Java en bytecode, les étapes suivantes consistent à traduire les instructions de bytecode en code machine. Cela peut être fait par un interpréteur ou un compilateur.

Interprétation

La forme la plus simple de compilation de bytecode est appelée interprétation. Un interpréteur recherche simplement les instructions matérielles pour chaque instruction de bytecode et l'envoie pour être exécuté par le CPU.

Vous pourriez penser à une interprétation similaire à l'utilisation d'un dictionnaire: pour un mot spécifique (instruction bytecode), il existe une traduction exacte (instruction de code machine). Puisque l'interpréteur lit et exécute immédiatement une instruction de bytecode à la fois, il n'y a aucune possibilité d'optimisation sur un ensemble d'instructions. Un interpréteur doit également faire l'interprétation chaque fois qu'un bytecode est appelé, ce qui le rend assez lent. L'interprétation est un moyen précis d'exécuter du code, mais le jeu d'instructions de sortie non optimisé ne sera probablement pas la séquence la plus performante pour le processeur de la plate-forme cible.

Compilation

Un compilateur, quant à lui, charge l'intégralité du code à exécuter dans le runtime. Lorsqu'il traduit le bytecode, il a la capacité d'examiner le contexte d'exécution entier ou partiel et de prendre des décisions sur la manière de traduire réellement le code. Ses décisions sont basées sur l'analyse de graphes de code tels que les différentes branches d'exécution des instructions et les données de contexte d'exécution.

Lorsqu'une séquence de bytecode est traduite en un jeu d'instructions de code machine et que des optimisations peuvent être effectuées sur ce jeu d'instructions, le jeu d'instructions de remplacement (par exemple, la séquence optimisée) est stocké dans une structure appelée cache de code . La prochaine fois que le bytecode est exécuté, le code précédemment optimisé peut être immédiatement localisé dans le cache de code et utilisé pour l'exécution. Dans certains cas, un compteur de performances peut démarrer et remplacer l'optimisation précédente, auquel cas le compilateur exécutera une nouvelle séquence d'optimisation. L'avantage d'un cache de code est que le jeu d'instructions résultant peut être exécuté en une seule fois - pas besoin de recherches interprétatives ou de compilation! Cela accélère le temps d'exécution, en particulier pour les applications Java où les mêmes méthodes sont appelées plusieurs fois.

Optimisation

La compilation dynamique s'accompagne de la possibilité d'insérer des compteurs de performance. Le compilateur peut, par exemple, insérer un compteur de performancespour compter chaque fois qu'un bloc de bytecode (par exemple, correspondant à une méthode spécifique) a été appelé. Les compilateurs utilisent des données sur le degré de «chaud» d'un bytecode donné pour déterminer où dans les optimisations de code auront le plus d'impact sur l'application en cours d'exécution. Les données de profilage d'exécution permettent au compilateur de prendre à la volée un ensemble complet de décisions d'optimisation du code, améliorant ainsi les performances d'exécution du code. Au fur et à mesure que des données de profilage de code plus raffinées deviennent disponibles, elles peuvent être utilisées pour prendre des décisions d'optimisation supplémentaires et meilleures, telles que: comment mieux séquencer les instructions dans le langage compilé, s'il faut remplacer un ensemble d'instructions par des ensembles plus efficaces, ou même s'il faut éliminer les opérations redondantes.

Exemple

Considérez le code Java:

static int add7( int x ) { return x+7; }

Cela pourrait être compilé statiquement par javacle bytecode:

iload0 bipush 7 iadd ireturn

Lorsque la méthode est appelée, le bloc bytecode est compilé dynamiquement en instructions machine. Lorsqu'un compteur de performances (s'il est présent pour le bloc de code) atteint un seuil, il peut également être optimisé. Le résultat final pourrait ressembler au jeu d'instructions machine suivant pour une plate-forme d'exécution donnée:

lea rax,[rdx+7] ret

Différents compilateurs pour différentes applications

Différentes applications Java ont des besoins différents. Les applications côté serveur d'entreprise de longue durée pourraient permettre davantage d'optimisations, tandis que les applications côté client plus petites peuvent nécessiter une exécution rapide avec une consommation de ressources minimale. Considérons trois paramètres de compilateur différents et leurs avantages et inconvénients respectifs.

Compilateurs côté client

Un compilateur d'optimisation bien connu est C1, le compilateur activé via l' -clientoption de démarrage JVM. Comme son nom de démarrage l'indique, C1 est un compilateur côté client. Il est conçu pour les applications côté client qui ont moins de ressources disponibles et sont, dans de nombreux cas, sensibles au temps de démarrage des applications. C1 utilise des compteurs de performances pour le profilage de code afin de permettre des optimisations simples et relativement peu intrusives.

Compilateurs côté serveur

Pour les applications de longue durée telles que les applications Java d'entreprise côté serveur, un compilateur côté client peut ne pas suffire. Un compilateur côté serveur comme C2 pourrait être utilisé à la place. C2 est généralement activé en ajoutant l'option de démarrage JVM -serverà votre ligne de commande de démarrage. Étant donné que la plupart des programmes côté serveur sont censés s'exécuter pendant une longue période, l'activation de C2 signifie que vous serez en mesure de collecter plus de données de profilage que vous ne le feriez avec une application cliente légère de courte durée. Vous pourrez ainsi appliquer des techniques d'optimisation et des algorithmes plus avancés.

Conseil: réchauffez votre compilateur côté serveur

Pour les déploiements côté serveur, il peut s'écouler un certain temps avant que le compilateur n'ait optimisé les parties «chaudes» initiales du code, de sorte que les déploiements côté serveur nécessitent souvent une phase de «préchauffage». Avant d'effectuer tout type de mesure des performances sur un déploiement côté serveur, assurez-vous que votre application a atteint l'état d'équilibre! Laisser suffisamment de temps au compilateur pour compiler correctement fonctionnera à votre avantage! (Voir l'article JavaWorld «Watch your HotSpot compiler go» pour en savoir plus sur le préchauffage de votre compilateur et les mécanismes de profilage.)

Un compilateur serveur représente plus de données de profilage qu'un compilateur côté client et permet une analyse de branche plus complexe, ce qui signifie qu'il considérera quel chemin d'optimisation serait le plus avantageux. Avoir plus de données de profilage disponibles donne de meilleurs résultats d'application. Bien sûr, effectuer un profilage et une analyse plus approfondis nécessite de consacrer plus de ressources au compilateur. Une JVM avec C2 activé utilisera plus de threads et plus de cycles CPU, nécessitera un cache de code plus grand, etc.

Compilation à plusieurs niveaux

Compilation à plusieurs niveauxcombine la compilation côté client et côté serveur. Azul a d'abord rendu la compilation à plusieurs niveaux disponible dans sa JVM Zing. Plus récemment (à partir de Java SE 7), il a été adopté par Oracle Java Hotspot JVM. La compilation à plusieurs niveaux tire parti des avantages du compilateur client et serveur dans votre JVM. Le compilateur client est le plus actif au démarrage de l'application et gère les optimisations déclenchées par des seuils de compteur de performances inférieurs. Le compilateur côté client insère également des compteurs de performances et prépare des jeux d'instructions pour des optimisations plus avancées, qui seront traitées ultérieurement par le compilateur côté serveur. La compilation à plusieurs niveaux est un moyen de profilage très économe en ressources car le compilateur est capable de collecter des données pendant une activité de compilateur à faible impact, qui peuvent être utilisées ultérieurement pour des optimisations plus avancées.Cette approche fournit également plus d'informations que ce que vous obtiendrez en utilisant uniquement des compteurs de profil de code interprété.

Le schéma de graphique de la figure 1 illustre les différences de performances entre l'interprétation pure, la compilation côté client, côté serveur et à plusieurs niveaux. L'axe X montre le temps d'exécution (unité de temps) et les performances de l'axe Y (ops / unité de temps).

Figure 1. Différences de performances entre les compilateurs (cliquez pour agrandir)

Par rapport au code purement interprété, l'utilisation d'un compilateur côté client conduit à des performances d'exécution environ 5 à 10 fois meilleures (en opérations / s), améliorant ainsi les performances des applications. La variation de gain dépend bien sûr de l'efficacité du compilateur, des optimisations activées ou implémentées et (dans une moindre mesure) de la conception de l'application par rapport à la plate-forme d'exécution cible. Ce dernier est vraiment quelque chose dont un développeur Java ne devrait jamais avoir à se soucier.

Par rapport à un compilateur côté client, un compilateur côté serveur augmente généralement les performances du code de 30 à 50% mesurables. Dans la plupart des cas, l'amélioration des performances équilibrera le coût des ressources supplémentaires.