Bonjour à tous!
Aujourd'hui, votre attention est invitée à une traduction de l'article, qui montre des exemples d'options de compilation dans la JVM. Une attention particulière est accordée à la compilation AOT prise en charge dans Java 9 et supérieur.
Bonne lecture!
Je crois que quiconque a déjà programmé en Java a entendu parler de compilation instantanée (JIT), et peut-être de compilation avant exécution (AOT). De plus, il n'est pas nécessaire d'expliquer ce que sont les langues «interprétées». Cet article explique comment toutes ces fonctionnalités sont implémentées dans la machine virtuelle Java, JVM.
Vous savez probablement que lors de la programmation en Java, vous devez exécuter un compilateur (en utilisant le programme «javac») qui recueille le code source Java (fichiers .java) dans le code binaire Java (fichiers .class). Le bytecode Java est un langage intermédiaire. Elle est dite "intermédiaire" car elle n'est pas comprise par un véritable dispositif informatique (CPU) et ne peut pas être exécutée par un ordinateur et représente ainsi une forme transitoire entre le code source et le code machine "natif" exécuté dans le processeur.
Pour que le bytecode Java effectue un travail spécifique, il y a 3 façons de le faire:
- Exécutez directement le code intermédiaire. Il vaut mieux et plus juste de dire qu'il faut "l'interpréter". La JVM dispose d'un interpréteur Java. Comme vous le savez, pour que la JVM fonctionne, vous devez exécuter le programme «java».
- Juste avant d'exécuter le code intermédiaire, compilez-le en code natif et forcez le CPU à exécuter ce code natif fraîchement préparé. Ainsi, la compilation a lieu juste avant l'exécution (Just in Time) et est appelée «dynamique».
- 3La toute première chose, avant même le lancement du programme, le code intermédiaire est traduit en natif et exécuté à travers le CPU du début à la fin. Cette compilation se fait avant l'exécution et s'appelle AoT (Ahead of Time).
Ainsi, (1) est le travail de l'interpréteur, (2) est le résultat de la compilation JIT et (3) est le résultat de la compilation AOT.
Par souci d'exhaustivité, je mentionnerai qu'il existe une quatrième approche - pour interpréter directement le code source, mais en Java, cela n'est pas accepté. Cela se fait, par exemple, en Python.
Voyons maintenant comment "java" fonctionne comme (1) l'interpréteur de (2) le compilateur JIT et / ou (3) le compilateur AOT - et quand.
En bref - en règle générale, "java" fait à la fois (1) et (2). À partir de Java 9, une troisième option est également possible.
Voici notre classe
Test
, qui sera utilisée dans de futurs exemples.
public class Test { public int f() throws Exception { int a = 5; return a; } public static void main(String[] args) throws Exception { for (int i = 1; i <= 10; i++) { System.out.println("call " + Integer.valueOf(i)); long a = System.nanoTime(); new Test().f(); long b = System.nanoTime(); System.out.println("elapsed= " + (ba)); } } }
Comme vous pouvez le voir, il existe une méthode
main
qui instancie l'objet
Test
et appelle cycliquement la fonction
f
10 fois de suite. La fonction
f
ne fait presque rien.
Donc, si vous compilez et exécutez le code ci-dessus, la sortie sera tout à fait attendue (bien sûr, les valeurs du temps écoulé seront différentes pour vous):
call 1 elapsed= 5373 call 2 elapsed= 913 call 3 elapsed= 654 call 4 elapsed= 623 call 5 elapsed= 680 call 6 elapsed= 710 call 7 elapsed= 728 call 8 elapsed= 699 call 9 elapsed= 853 call 10 elapsed= 645
Et maintenant, la question est: cette conclusion est-elle le résultat du travail de "java" en tant qu'interprète, c'est-à-dire l'option (1), "java" en tant que compilateur JIT, c'est-à-dire l'option (2) ou est-elle en quelque sorte liée à la compilation AOT , c'est-à-dire l'option (3)? Dans cet article, je vais trouver les bonnes réponses à toutes ces questions.
La première réponse que je veux donner est très probablement que seul (1) a lieu ici. Je dis "très probablement", car je ne sais pas si une variable d'environnement est définie ici qui modifierait les options JVM par défaut. Si rien de superflu n'est installé, et c'est ainsi que «java» fonctionne par défaut, alors nous observons à 100% seulement l'option (1), c'est-à-dire que le code est entièrement interprété. J'en suis sûr, car:
- Selon la documentation java, l'option
-XX:CompileThreshold=invocations
s'exécute avec les invocations=1500
par défaut invocations=1500
sur la machine -XX:CompileThreshold=invocations
client (plus d'informations sur la machine -XX:CompileThreshold=invocations
cliente sont décrites ci-dessous). Comme je ne l'exécute que 10 fois et 10 <1500, nous ne parlons pas ici de compilation dynamique. En règle générale, cette option de ligne de commande spécifie combien de fois (au maximum) la fonction doit être interprétée avant le début de l'étape de compilation dynamique. Je m'attarderai sur cela ci-dessous. - En fait, j'ai exécuté ce code avec des indicateurs de diagnostic, donc je sais s'il a été compilé dynamiquement. J'expliquerai également ce point ci-dessous.
Remarque: la JVM peut fonctionner en mode client ou serveur, et les options définies par défaut dans les premier et deuxième cas seront différentes. En règle générale, la décision concernant le mode de démarrage est prise automatiquement, en fonction de l'environnement ou de l'ordinateur sur lequel la JVM a été lancée. Ci-après, je spécifierai l'option
–client
lors de tous les démarrages, afin de ne pas douter que le programme s'exécute en mode client. Cette option n'affectera pas les aspects que je veux démontrer dans ce post.
Si vous exécutez «java» avec l'
-XX:PrintCompilation
, le programme imprime une ligne lorsque la fonction est compilée dynamiquement. N'oubliez pas que la compilation JIT est effectuée pour chaque fonction séparément, certaines fonctions de la classe peuvent rester en bytecode (c'est-à-dire non compilées), tandis que d'autres peuvent déjà avoir passé la compilation JIT, c'est-à-dire, prêtes à être exécutées directement dans le processeur .
Ci-dessous, j'ajoute également l'option
-Xbatch
. L'option
-Xbatch
nécessaire que pour rendre la sortie plus présentable; sinon, la compilation JIT se déroule de manière compétitive (avec l'interprétation), et la sortie après la compilation peut parfois sembler étrange au moment de l'exécution (en raison de
-XX:PrintCompilation
). Cependant, l'option
–Xbatch
désactive la compilation en arrière-plan, par conséquent, avant d'exécuter la compilation JIT, l'exécution de notre programme sera arrêtée.
(Par souci de lisibilité, j'écrirai chaque option à partir d'une nouvelle ligne)
$ java -client -Xbatch -XX:+PrintCompilation Test
Je n'insérerai pas la sortie de cette commande ici, car par défaut, la JVM compile beaucoup de fonctions internes (concernant, par exemple, les packages java, sun, jdk), donc la sortie sera très longue - donc, sur mon écran, il y a 274 lignes sur les fonctions internes , et quelques autres - jusqu'à la fin du programme). Pour faciliter cette recherche, je vais annuler la compilation JIT pour les classes internes ou l'activer sélectivement uniquement pour ma méthode (
Test.f
). Pour ce faire, spécifiez une autre option,
-XX:CompileCommand
. Vous pouvez spécifier de nombreuses commandes (compilation), il serait donc plus facile de les placer dans un fichier séparé. Heureusement, nous avons l'option
-XX:CompileCommandFile
. Passez donc à la création du fichier. Je l'appellerai
hotspot_compiler
pour une raison que je vais expliquer brièvement et écrire ce qui suit:
quiet exclude java/* * exclude jdk/* * exclude sun/* *
Dans ce cas, il devrait être tout à fait clair que nous excluons toutes les fonctions (la dernière *) de toutes les classes de tous les packages commençant par java, jdk et sun (les noms des packages sont séparés par / et vous pouvez utiliser *). La commande
quiet
indique à la JVM de ne rien écrire sur les classes exclues, donc seules celles qui sont maintenant compilées seront sorties sur la console. Alors je lance:
java -client -Xbatch -XX:+PrintCompilation -XX:CompileCommandFile=hotspot_compiler Test
Avant de vous parler de la sortie de cette commande, je vous rappelle que j'ai nommé ce fichier
hotspot_compiler
, car il semble (je n'ai pas vérifié) que dans Oracle JDK le nom
.hotspot_compiler
est défini par défaut pour le fichier avec les commandes du compilateur.
La conclusion est donc:
many lines like this 111 1 n 0 java.lang.invoke.MethodHandle::linkToStatic(LLLLLL)L (native) (static) call 1 some more lines like this 161 48 n 0 java.lang.invoke.MethodHandle::linkToStatic(ILIJL)I (native) (static) elapsed= 7558 call 2 elapsed= 1532 call 3 elapsed= 920 call 4 elapsed= 732 call 5 elapsed= 774 call 6 elapsed= 815 call 7 elapsed= 767 call 8 elapsed= 765 call 9 elapsed= 757 call 10 elapsed= 868
Tout d'abord, je ne sais pas pourquoi certaines méthodes
java.lang.invoke.MethodHandler.
sont toujours en cours de compilation
java.lang.invoke.MethodHandler.
Probablement, certaines choses ne peuvent tout simplement pas être désactivées. Si je comprends bien le problème, je mettrai à jour ce message. Cependant, comme vous pouvez le voir, toutes les autres étapes de compilation (il y avait auparavant 274 lignes) ont maintenant disparu. Dans d'autres exemples, je supprimerai également
java.lang.invoke.MethodHandler
de la sortie du journal de compilation.
Voyons voir où nous en sommes. Maintenant, nous avons un code simple où nous exécutons notre fonction 10 fois. J'ai mentionné plus tôt que cette fonction est interprétée, non compilée, comme cela est indiqué dans la documentation, et maintenant nous la voyons dans les journaux (en même temps, nous ne la voyons pas dans les journaux de compilation, ce qui signifie qu'elle n'est pas soumise à la compilation JIT). Eh bien, vous venez de voir l'outil «java» en action, interprétant et n'interprétant notre fonction que dans 100% des cas. Donc, nous pouvons cocher la case qui a figuré avec l'option (1). On passe à (2), compilation dynamique.
Selon la documentation, vous pouvez exécuter la fonction 1 500 fois et vous assurer que la compilation JIT se déroule réellement. Cependant, vous pouvez également utiliser l'
-XX:CompileThreshold=invocations
appel
-XX:CompileThreshold=invocations
, définissant la valeur souhaitée au lieu de 1500. Pointons ici 5. Cela signifie que nous nous attendons à ce qui suit: après 5 «interprétations» de notre fonction f, la JVM doit compiler la méthode, puis exécuter la version compilée.
java -client -Xbatch
-XX:+PrintCompilation -XX:CompileCommandFile=hotspot_compiler -XX:CompileThreshold=5 Test
Si vous avez exécuté cette commande, vous avez peut-être remarqué que rien n'a changé par rapport à l'exemple ci-dessus. Autrement dit, la compilation ne se produit toujours pas. Il s'avère que, selon la documentation,
-XX:CompileThreshold
ne fonctionne que lorsque
TieredCompilation
désactivé, ce qui est la valeur par défaut. Il
-XX:-TieredCompilation
comme ceci:
-XX:-TieredCompilation
. La compilation à plusieurs niveaux est une fonctionnalité introduite dans Java 7 pour améliorer à la fois le lancement et la vitesse de croisière de la JVM. Dans le contexte de ce post, ce n'est pas important, alors n'hésitez pas à le désactiver. Exécutons à nouveau cette commande:
java -client -Xbatch -XX:+PrintCompilation -XX:CompileCommandFile=hotspot_compiler -XX:CompileThreshold=5 -XX:-TieredCompilation Test
Voici la sortie (je me souviens, j'ai raté les lignes concernant
java.lang.invoke.MethodHandle
):
call 1 elapsed= 9411 call 2 elapsed= 1291 call 3 elapsed= 862 call 4 elapsed= 1023 call 5 227 56 b Test::<init> (5 bytes) 228 57 b Test::f (4 bytes) elapsed= 1051739 call 6 elapsed= 18516 call 7 elapsed= 940 call 8 elapsed= 769 call 9 elapsed= 855 call 10 elapsed= 838
Nous accueillons (bonjour!) La fonction compilée dynamiquement Test.f ou
Test::<init>
immédiatement après avoir appelé le numéro 5, car j'ai défini CompileThreshold sur 5. La JVM interprète la fonction 5 fois, puis la compile et exécute enfin la version compilée. Étant donné que la fonction est compilée, elle devrait s'exécuter plus rapidement, mais nous ne pouvons pas le vérifier ici, car cette fonction ne fait rien. Je pense que c'est un bon sujet pour un article séparé.
Comme vous l'avez probablement déjà deviné, une autre fonction est compilée ici, à savoir
Test::<init>
, qui est un constructeur de la classe
Test
. Puisque le code appelle le constructeur (nouveau
Test()
), chaque fois que
f
appelé, il compile simultanément avec la fonction
f
, exactement après 5 appels.
En principe, cela peut mettre fin à la discussion de l'option (2), la compilation JIT. Comme vous pouvez le voir, dans ce cas, la fonction est d'abord interprétée par la JVM, puis compilée dynamiquement après une interprétation quintuple. Je voudrais ajouter le dernier détail concernant la compilation JIT, à savoir mentionner l'option
-XX:+PrintAssembly
. Comme son nom l'indique, il fournit à la console une version compilée de la fonction (version compilée = code machine natif = code assembleur). Cependant, cela ne fonctionnera que s'il y a un désassembleur dans le chemin de la bibliothèque. Je suppose que le désassembleur peut différer dans différentes machines virtuelles Java, mais dans ce cas, nous avons affaire à hsdis - un désassembleur pour openjdk. Le code source de la bibliothèque hsdis ou son fichier binaire peut être pris à différents endroits. Dans ce cas, j'ai compilé ce fichier et mis
hsdis-amd64.so
dans
JAVA_HOME/lib/server
.
Alors maintenant, nous pouvons exécuter cette commande. Mais je dois d'abord ajouter cela pour exécuter
-XX:+PrintAssembly
devez également ajouter l'
-XX:+UnlockDiagnosticVMOptions
, et elle doit suivre avant l'option
PrintAssembly
. Si cela n'est pas fait, la JVM vous
PrintAssembly
une utilisation incorrecte de l'option
PrintAssembly
. Exécutons ce code:
java -client -Xbatch -XX:+PrintCompilation -XX:CompileCommandFile=hotspot_compiler -XX:CompileThreshold=5 -XX:-TieredCompilation -XX:+UnlockDiagnosticVMOptions -XX:+PrintAssembly Test
La sortie sera longue et il y aura des lignes comme:
0x00007f4b7cab1120: mov 0x8(%rsi),%r10d 0x00007f4b7cab1124: shl $0x3,%r10 0x00007f4b7cab1128: cmp %r10,%rax
Comme vous pouvez le voir, les fonctions correspondantes sont compilées en code machine natif.
Enfin, discutez de l'option 3, AOT. La compilation avant exécution, AOT, n'était pas disponible en Java avant la version 9.
Un nouvel outil est apparu dans JDK 9, jaotc - comme son nom l'indique, il s'agit d'un compilateur AOT pour Java. L'idée est la suivante: exécutez le compilateur Java "javac", puis le compilateur AOT pour Java "jaotc", puis exécutez la JVM "java" comme d'habitude. La JVM effectue normalement l'interprétation et la compilation JIT. Cependant, si la fonction a du code compilé AOT, elle l'utilise directement et n'a pas recours à l'interprétation ou à la compilation JIT. Laissez-moi vous expliquer: vous n'avez pas besoin d'exécuter le compilateur AOT, il est facultatif, et si vous l'utilisez, vous ne pouvez compiler que les classes que vous souhaitez avant de l'exécuter.
Construisons une bibliothèque composée d'une version compilée AOT de
Test::f
. N'oubliez pas: pour le faire vous-même, vous aurez besoin de JDK 9 dans la version 150+.
jaotc --output=libTest.so Test.class
En conséquence,
libTest.so
généré, une bibliothèque contenant le code natif de fonctions compilé par AOT inclus dans la classe
Test
. Vous pouvez visualiser les caractères définis dans cette bibliothèque:
nm libTest.so
Dans notre conclusion, entre autres, il y aura:
0000000000002120 t Test.f()I 00000000000021a0 t Test.<init>()V 00000000000020a0 t Test.main([Ljava/lang/String;)V
Ainsi, toutes nos fonctions, constructeur,
f
, méthode statique
main
sont présentes dans la bibliothèque
libTest.so
.
Comme dans le cas de l'option «java» correspondante, dans ce cas, l'option peut être accompagnée d'un fichier, pour cela il y a l'option –compile-commandes à jaotc. JEP 295 fournit des exemples pertinents que je ne montrerai pas ici.
Lançons maintenant «java» et voyons si des méthodes compilées AOT sont utilisées. Si vous exécutez «java» comme auparavant, la bibliothèque AOT ne sera pas utilisée, ce qui n'est pas surprenant. Pour utiliser cette nouvelle fonctionnalité, l'option
-XX:AOTLibrary
est fournie, que vous devez spécifier:
java -XX:AOTLibrary=./libTest.so Test
Vous pouvez spécifier plusieurs bibliothèques AOT, séparées par des virgules.
La sortie de cette commande est exactement la même que lors du démarrage de «java» sans
AOTLibrary
, car le comportement du programme Test n'a pas changé du tout. Pour vérifier si les fonctions compilées AOT sont utilisées, vous pouvez ajouter une autre nouvelle option,
-XX:+PrintAOT
.
java -XX:AOTLibrary=./libTest.so -XX:+PrintAOT Test
Avant la sortie du programme de
Test
, cette commande affiche les éléments suivants:
9 1 loaded ./libTest.so aot library 99 1 aot[ 1] Test.main([Ljava/lang/String;)V 99 2 aot[ 1] Test.f()I 99 3 aot[ 1] Test.<init>()V
Comme prévu, la bibliothèque AOT est chargée et les fonctions compilées AOT sont utilisées.
Si vous êtes intéressé, vous pouvez exécuter la commande suivante et vérifier si la compilation JIT se produit.
java -client -Xbatch -XX:+PrintCompilation -XX:CompileCommandFile=hotspot_compiler -XX:CompileThreshold=5 -XX:-TieredCompilation -XX:AOTLibrary=./libTest.so -XX:+PrintAOT Test
Comme prévu, la compilation JIT ne se produit pas, car les méthodes de la classe Test sont compilées avant l'exécution et fournies sous forme de bibliothèque.
Une question possible est: si nous fournissons un code de fonction natif, alors comment la JVM détermine-t-elle si le code natif est obsolète / périmé? Comme dernier exemple, modifions la fonction
f
et fixons a à 6.
public int f() throws Exception { int a = 6; return a; }
Je l'ai fait juste pour modifier le fichier de classe. Maintenant, nous faisons compiler javac et exécutons la même commande que ci-dessus.
javac Test.java java -client -Xbatch -XX:+PrintCompilation -XX:CompileCommandFile=hotspot_compiler -XX:CompileThreshold=5 -XX:-TieredCompilation -XX:AOTLibrary=./libTest.so -XX:+PrintAOT Test
Comme vous pouvez le voir, je n'ai pas exécuté «jaotc» après «javac», donc le code de la bibliothèque AOT est maintenant ancien et incorrect, et la fonction
f
a a = 5.
La sortie de la commande «java» ci-dessus montre:
228 56 b Test::<init> (5 bytes) 229 57 b Test::f (5 bytes)
Cela signifie que les fonctions dans ce cas ont été compilées dynamiquement, donc le code résultant de la compilation AOT n'a pas été utilisé. Un changement a donc été détecté dans le fichier de classe. Lorsque la compilation est effectuée à l'aide de javac, son empreinte digitale est entrée dans la classe et l'empreinte digitale de la classe est également stockée dans la bibliothèque AOT. Étant donné que la nouvelle empreinte digitale de la classe diffère de celle stockée dans la bibliothèque AOT, le code natif compilé à l'avance (AOT) n'a pas été utilisé. C'est tout ce que je voulais vous dire sur la dernière option de compilation, avant exécution.
Dans cet article, j'ai essayé d'expliquer et d'illustrer avec des exemples simples et réalistes comment la JVM exécute le code Java: l'interpréter, compiler dynamiquement (JIT) ou à l'avance (AOT) - de plus, la dernière opportunité n'est apparue que dans JDK 9. J'espère que vous avez appris quelque chose nouveau.