Un guide pour l’interprétation et la compilation JVM

Java est un langage indépendant de la plate-forme. Les programmes sont convertis en bytecode après compilation. Ce bytecode est converti en langage machine lors de l’exécution. Un interpréteur émule l’exécution d’instructions de bytecode pour la machine abstraite sur une machine physique spécifique. La compilation juste-à-temps (JIT) se produit à un moment donné pendant l’exécution, et la compilation en avance (AOT) se produit pendant le temps de construction.
Cet article explique quand un interprète entre en jeu et quand JIT et AOT se produiront. Je discute également des compromis entre JIT et AOT.
Contents
Code source, bytecode, code machine
Les applications sont généralement écrites à l’aide d’un langage de programmation tel que C, C++ ou Java. L’ensemble d’instructions écrites à l’aide de langages de programmation de haut niveau est appelé code source. Le code source est lisible par l’homme. Pour l’exécuter sur la machine cible, le code source doit être converti en code machine, qui est lisible par machine. Le code source est généralement converti en code machine par un compilateur.
En Java, cependant, le code source est d’abord converti en une forme intermédiaire appelée bytecode. Ce bytecode est indépendant de la plate-forme, c’est pourquoi Java est bien connu comme un langage de programmation indépendant de la plate-forme. Le compilateur Java principal javac
convertit le code source Java en bytecode. Ensuite, le bytecode est interprété par l’interpréteur.
Voici un petit Hello.java
programme:
Compilez-le en utilisant javac
pour générer un Hello.class
fichier contenant le bytecode.
$ javac Hello.java
$ ls
Hello.class Hello.java
Maintenant, utilisez javap
démonter le contenu de la Hello.class
dossier. La sortie de javap
dépend des options utilisées. Si vous ne choisissez aucune option, il imprime des informations de base, notamment le fichier source à partir duquel ce fichier de classe est compilé, le nom du package, les champs publics et protégés et les méthodes de la classe.
$ javap Hello.class
Compiled from "Hello.java"
public class Hello {
public Hello();
public static void main(java.lang.String[]);
}
Pour voir le contenu du bytecode dans le .class
fichier, utilisez le -c
option:
$ javap -c Hello.class
Compiled from "Hello.java"
public class Hello {
public Hello();
Code:
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: returnpublic static void main(java.lang.String[]);
Code:
0: getstatic #2 // Field java/lang/System.out:Ljava/io/PrintStream;
3: ldc #3 // String Inside Hello World!
5: invokevirtual #4 // Method
java/io/PrintStream.println:(Ljava/lang/String;)V
8: return
}
Pour obtenir des informations plus détaillées, utilisez le -v
option:
$ javap -v Hello.class
Interprète, JIT, AOT
L’interpréteur est chargé d’émuler l’exécution des instructions de bytecode pour la machine abstraite sur une machine physique spécifique. Lors de la compilation du code source à l’aide de javac
et l’exécution à l’aide de java
commande, l’interpréteur fonctionne pendant l’exécution et sert son objectif.
$ javac Hello.java
$ java Hello
Inside Hello World!
Le compilateur JIT fonctionne également au moment de l’exécution. Lorsque l’interpréteur interprète un programme Java, un autre composant, appelé un profileur d’exécution, surveille silencieusement l’exécution du programme pour observer quelle partie du code est interprétée et combien de fois. Ces statistiques permettent de détecter points chauds du programme, c’est-à-dire les portions de code fréquemment interprétées. Une fois qu’ils sont interprétés au-dessus d’un seuil défini, ils peuvent être convertis en code machine directement par le compilateur JIT. Le compilateur JIT est également appelé compilateur guidé par profil. La conversion du bytecode en code natif se produit à la volée, d’où le nom juste-à-temps. JIT réduit la surcharge de l’interpréteur en émulant le même ensemble d’instructions au code machine.
Le compilateur AOT compile le code pendant la construction. La génération de code fréquemment interprété et compilé JIT au moment de la génération améliore le temps de préchauffage de la machine virtuelle Java (JVM). Ce compilateur a été introduit dans Java 9 en tant que fonctionnalité expérimentale. La jaotc
L’outil utilise le compilateur Graal, lui-même écrit en Java, pour la compilation AOT.
Voici un exemple de cas d’utilisation pour un programme Hello :
Quand l’interprétation et la compilation entrent en jeu : un exemple
Cet exemple illustre quand Java utilise un interpréteur et quand JIT et AOT interviennent. Considérez un programme Java simple, Demo.java
:
//Demo.java
public class Demo {
public int square(int i) throws Exception {
return(i*i);
}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();
Int r = new Demo().square(i);
System.out.println("Square(i) = " + r);
long b = System.nanoTime();
System.out.println("elapsed= " + (b-a));
System.out.println("--------------------------------");
}
}
}
Ce programme simple a un main
méthode qui crée un Demo
instance d’objet et appelle la méthode square
qui affiche la racine carrée de for
valeur d’itération de la boucle. Maintenant, compilez et exécutez le code :
$ javac Demo.java
$ java Demo
1 iteration
Square(i) = 1
Time taken= 8432439
--------------------------------
2 iteration
Square(i) = 4
Time taken= 54631
--------------------------------
.
.
.
--------------------------------
10 iteration
Square(i) = 100
Time taken= 66498
--------------------------------
La question est maintenant de savoir si la sortie est le résultat de l’interpréteur, JIT ou AOT. Dans ce cas, il est entièrement interprété. Comment ai-je conclu cela? Eh bien, pour que JIT contribue à la compilation, les points chauds du code doivent être interprétés au-dessus d’un seuil défini. Alors et seulement alors ces morceaux de code sont mis en file d’attente pour la compilation JIT. Pour trouver le seuil pour JDK 11 :
$ java -XX:+PrintFlagsFinal -version | grep CompileThreshold
intx CompileThreshold = 10000 {pd product} {default}
[...]
openjdk version "11.0.13" 2021-10-19
OpenJDK Runtime Environment 18.9 (build 11.0.13+8)
OpenJDK 64-Bit Server VM 18.9 (build 11.0.13+8, mixed mode, sharing)
La sortie ci-dessus montre qu’un morceau de code particulier doit être interprété 10 000 fois pour être éligible à la compilation JIT. Ce seuil peut-il être réglé manuellement et existe-t-il un indicateur JVM indiquant si une méthode est compilée JIT ? Oui, il existe plusieurs options pour atteindre cet objectif.
Une option pour savoir si une méthode est compilée JIT est -XX:+PrintCompilation
. Parallèlement à cette option, le drapeau -Xbatch
fournit la sortie d’une manière plus lisible. Si l’interprétation et le JAT se déroulent en parallèle, le -Xbatch
flag aide à distinguer la sortie des deux. Utilisez ces drapeaux comme suit :
$ java -Xbatch -XX:+PrintCompilation Demo
34 1 b 3 java.util.concurrent.ConcurrentHashMap::tabAt (22 bytes)
35 2 n 0 jdk.internal.misc.Unsafe::getObjectVolatile (native)
35 3 b 3 java.lang.Object::<init> (1 bytes)
[...]
210 269 n 0 java.lang.reflect.Array::newArray (native) (static)
211 270 b 3 java.lang.String::substring (58 bytes)
[...]
--------------------------------
10 iteration
Square(i) = 100
Time taken= 50150
--------------------------------
La sortie de la commande ci-dessus est trop longue, j’ai donc tronqué la partie centrale. Notez qu’avec le code du programme de démonstration, les fonctions de classe internes du JDK sont également compilées. C’est pourquoi la sortie est si longue. Parce que mon objectif est Demo.java
code, j’utiliserai une option qui peut minimiser la sortie en excluant les fonctions internes du package. La commande –XX:CompileCommandFile
désactive JIT pour les classes internes :
$ java -Xbatch -XX:+PrintCompilation -XX:CompileCommandFile=hotspot_compiler Demo
Le fichier hotspot_compiler
référencé par -XX:CompileCommandFile
contient ce code pour exclure des packages spécifiques :
$ cat hotspot_compiler
quiet
exclude java/* *
exclude jdk/* *
exclude sun/* *
En première ligne, quiet
indique à la JVM de ne rien écrire sur les classes exclues. Pour régler le seuil JIT, utilisez -XX:CompileThreshold
avec la valeur définie sur 5, ce qui signifie qu’après avoir interprété cinq fois, il est temps pour JIT :
$ java -Xbatch -XX:+PrintCompilation -XX:CompileCommandFile=hotspot_compiler \
-XX:CompileThreshold=5 Demo
47 1 n 0 java.lang.invoke.MethodHandle::linkToStatic(LLLLLL)L (native)
(static)
47 2 n 0 java.lang.invoke.MethodHandle::invokeBasic(LLLLL)L (native)
47 3 n 0 java.lang.invoke.MethodHandle::linkToSpecial(LLLLLLL)L (native)
(static)
48 4 n 0 java.lang.invoke.MethodHandle::linkToStatic(L)I (native) (static)
48 5 n 0 java.lang.invoke.MethodHandle::invokeBasic()I (native)
48 6 n 0 java.lang.invoke.MethodHandle::linkToSpecial(LL)I (native)
(static)
[...]
1 iteration
69 40 n 0 java.lang.invoke.MethodHandle::linkToStatic(ILIIL)I (native)
(static)
[...]
Square(i) = 1
78 48 n 0 java.lang.invoke.MethodHandle::linkToStatic(ILIJL)I (native)
(static)
79 49 n 0 java.lang.invoke.MethodHandle::invokeBasic(ILIJ)I (native)
[...]
86 54 n 0 java.lang.invoke.MethodHandle::invokeBasic(J)L (native)
87 55 n 0 java.lang.invoke.MethodHandle::linkToSpecial(LJL)L (native)
(static)
Time taken= 8962738
--------------------------------
2 iteration
Square(i) = 4
Time taken= 26759
--------------------------------10 iteration
Square(i) = 100
Time taken= 26492
--------------------------------
La sortie n’est toujours pas différente de la sortie interprétée ! C’est parce que, selon la documentation d’Oracle, le -XX:CompileThreshold
flag n’est effectif que lorsque TieredCompilation
est désactivé :
$ java -Xbatch -XX:+PrintCompilation -XX:CompileCommandFile=hotspot_compiler \
-XX:-TieredCompilation -XX:CompileThreshold=5 Demo
124 1 n java.lang.invoke.MethodHandle::linkToStatic(LLLLLL)L (native) (static)
127 2 n java.lang.invoke.MethodHandle::invokeBasic(LLLLL)L (native)
[...]
1 iteration
187 40 n java.lang.invoke.MethodHandle::linkToStatic(ILIIL)I (native) (static)
[...]
(native) (static)
212 54 n java.lang.invoke.MethodHandle::invokeBasic(J)L (native)
212 55 n java.lang.invoke.MethodHandle::linkToSpecial(LJL)L (native) (static)
Time taken= 12337415
[...]
--------------------------------
4 iteration
Square(i) = 16
Time taken= 37183
--------------------------------
5 iteration
214 56 b Demo::<init> (5 bytes)
215 57 b Demo::square (16 bytes)
Square(i) = 25
Time taken= 983002
--------------------------------
6 iteration
Square(i) = 36
Time taken= 81589
[...]
10 iteration
Square(i) = 100
Time taken= 52393
Cette section de code est maintenant compilée JIT après la cinquième interprétation :
--------------------------------
5 iteration
214 56 b Demo::<init> (5 bytes)
215 57 b Demo::square (16 bytes)
Square(i) = 25
Time taken= 983002
--------------------------------
Avec le square()
méthode, le constructeur obtient également JIT compilé car il y a une instance Demo à l’intérieur de la for
boucle avant d’appeler square()
. Par conséquent, il atteindra également le seuil et sera compilé JIT. Cet exemple illustre le moment où le JIT entre en jeu après l’interprétation.
Pour voir la version compilée du code, utilisez le -XX:+PrintAssembly flag
, qui ne fonctionne que s’il y a un désassembleur dans le chemin de la bibliothèque. Pour OpenJDK, utilisez le hsdis
désassembleur. Téléchargez une bibliothèque de désassembleur appropriée – dans ce cas, hsdis-amd64.so
— et placez-le sous Java_HOME/lib/server
. Assurez-vous d’utiliser -XX:+UnlockDiagnosticVMOptions
avant de -XX:+PrintAssembly
. Sinon, JVM vous donnera un avertissement.
La commande entière est la suivante :
$ java -Xbatch -XX:+PrintCompilation -XX:CompileCommandFile=hotspot_compiler \ -XX:-TieredCompilation -XX:CompileThreshold=5 -XX:+UnlockDiagnosticVMOptions \ -XX:+PrintAssembly Demo
[...]
5 iteration
178 56 b Demo::<init> (5 bytes)
Compiled method (c2) 178 56 Demo::<init> (5 bytes)
total in heap [0x00007fd4d08dad10,0x00007fd4d08dafe0] = 720
relocation [0x00007fd4d08dae88,0x00007fd4d08daea0] = 24
[...]
handler table [0x00007fd4d08dafc8,0x00007fd4d08dafe0] = 24
[...]
dependencies [0x00007fd4d08db3c0,0x00007fd4d08db3c8] = 8
handler table [0x00007fd4d08db3c8,0x00007fd4d08db3f8] = 48
----------------------------------------------------------------------
Demo.square(I)I [0x00007fd4d08db1c0, 0x00007fd4d08db2b8] 248 bytes
[Entry Point]
[Constants]
# {method} {0x00007fd4b841f4b0} 'square' '(I)I' in 'Demo'
# this: rsi:rsi = 'Demo'
# parm0: rdx = int
# [sp+0x20] (sp of caller)
[...]
[Stub Code]
0x00007fd4d08db280: movabs $0x0,%rbx ; {no_reloc}
0x00007fd4d08db28a: jmpq 0x00007fd4d08db28a ; {runtime_call}
0x00007fd4d08db28f: movabs $0x0,%rbx ; {static_stub}
0x00007fd4d08db299: jmpq 0x00007fd4d08db299 ; {runtime_call}
[Exception Handler]
0x00007fd4d08db29e: jmpq 0x00007fd4d08bb880 ; {runtime_call ExceptionBlob}
[Deopt Handler Code]
0x00007fd4d08db2a3: callq 0x00007fd4d08db2a8
0x00007fd4d08db2a8: subq $0x5,(%rsp)
0x00007fd4d08db2ad: jmpq 0x00007fd4d08a01a0 ; {runtime_call DeoptimizationBlob}
0x00007fd4d08db2b2: hlt
0x00007fd4d08db2b3: hlt
0x00007fd4d08db2b4: hlt
0x00007fd4d08db2b5: hlt
0x00007fd4d08db2b6: hlt
0x00007fd4d08db2b7: hlt
ImmutableOopMap{rbp=NarrowOop }pc offsets: 96
ImmutableOopMap{}pc offsets: 112
ImmutableOopMap{rbp=Oop }pc offsets: 148 Square(i) = 25
Time taken= 2567698
--------------------------------
6 iteration
Square(i) = 36
Time taken= 76752
[...]
--------------------------------
10 iteration
Square(i) = 100
Time taken= 52888
La sortie est longue, j’ai donc inclus uniquement la sortie liée à Demo.java
.
Il est maintenant temps pour la compilation AOT. Cette option a été introduite dans JDK9. AOT est un compilateur statique pour générer le .so
bibliothèque. Avec AOT, les classes intéressées peuvent être compilées pour créer un .so
bibliothèque qui peut être directement exécutée au lieu d’interpréter ou de compiler JIT. Si JVM ne trouve aucun code compilé par AOT, l’interprétation habituelle et la compilation JIT ont lieu.
La commande utilisée pour la compilation AOT est la suivante :
$ jaotc --output=libDemo.so Demo.class
Pour voir les symboles dans la bibliothèque partagée, utilisez ce qui suit :
$ nm libDemo.so
Pour utiliser le produit généré .so
bibliothèque, utiliser -XX:AOTLibrary
de même que -XX:+UnlockExperimentalVMOptions
comme suit:
Cette sortie ressemble à une version interprétée elle-même. Pour vous assurer que le code compilé AOT est utilisé, utilisez -XX:+PrintAOT
:
$ java -XX:+UnlockExperimentalVMOptions -XX:AOTLibrary=./libDemo.so -XX:+PrintAOT Demo
28 1 loaded ./libDemo.so aot library
80 1 aot[ 1] Demo.main([Ljava/lang/String;)V
80 2 aot[ 1] Demo.square(I)I
80 3 aot[ 1] Demo.<init>()V
1 iteration
Square(i) = 1
Time taken= 7252921
--------------------------------
2 iteration
Square(i) = 4
Time taken= 57443
[...]
10 iteration
Square(i) = 100
Time taken= 53586
Juste pour vous assurer que la compilation JIT ne s’est pas produite, utilisez ce qui suit :
$ java -XX:+UnlockExperimentalVMOptions -Xbatch -XX:+PrintCompilation \ -XX:CompileCommandFile=hotspot_compiler -XX:-TieredCompilation \ -XX:CompileThreshold=3 -XX:AOTLibrary=./libDemo.so -XX:+PrintAOT Demo
19 1 loaded ./libDemo.so aot library
77 1 aot[ 1] Demo.square(I)I
77 2 aot[ 1] Demo.main([Ljava/lang/String;)V
77 3 aot[ 1] Demo.<init>()V
77 2 aot[ 1] Demo.main([Ljava/lang/String;)V made not entrant
[...]
4 iteration
Square(i) = 16
Time taken= 43366
[...]
10 iteration
Square(i) = 100
Time taken= 59554
Si une petite modification est apportée au code source soumis à AOT, il est important de s’assurer que le .so
est à nouveau créé. Sinon, l’AOT compilé obsolète .so
n’aura aucun effet. Par exemple, apportez une petite modification à la fonction square de sorte qu’elle calcule maintenant le cube :
//Demo.java
public class Demo {public int square(int i) throws Exception {
return(i*i*i);
}public static void main(String[] args) throws Exception {
for (int i = 1; i <= 10; i++) {
System.out.println("" + Integer.valueOf(i)+" iteration");
long start = System.nanoTime();
int r= new Demo().square(i);
System.out.println("Square(i) = " + r);
long end = System.nanoTime();
System.out.println("Time taken= " + (end-start));
System.out.println("--------------------------------");
}
}
}
Maintenant, compilez Demo.java
encore:
$ java Demo.java
Mais ne créez pas libDemo.so
utilisant jaotc
. Utilisez plutôt cette commande :
$ java -XX:+UnlockExperimentalVMOptions -Xbatch -XX:+PrintCompilation -XX:CompileCommandFile=hotspot_compiler -XX:-TieredCompilation -XX:CompileThreshold=3 -XX:AOTLibrary=./libDemo.so -XX:+PrintAOT Demo
20 1 loaded ./libDemo.so aot library
74 1 n java.lang.invoke.MethodHandle::linkToStatic(LLLLLL)L (native) (static)
2 iteration
sqrt(i) = 8
Time taken= 43838
--------------------------------
3 iteration
137 56 b Demo::<init> (5 bytes)
138 57 b Demo::square (6 bytes)
sqrt(i) = 27
Time taken= 534649
--------------------------------
4 iteration
sqrt(i) = 64
Time taken= 51916
[...]
10 iteration
sqrt(i) = 1000
Time taken= 47132
Bien que l’ancienne version de libDemo.so
est chargé, JVM l’a détecté comme obsolète. Chaque fois qu’un .class
est créé, une empreinte digitale va dans le fichier de classe et une empreinte digitale de classe est conservée dans la bibliothèque AOT. Étant donné que l’empreinte de classe est différente de celle de la bibliothèque AOT, le code natif compilé par AOT n’est pas utilisé. Au lieu de cela, la méthode est maintenant compilée JIT, car le -XX:CompileThreshold
est fixé à 3.
AOT ou JIT ?
Si vous souhaitez réduire le temps de préchauffage de la JVM, utilisez AOT, ce qui réduit la charge pendant l’exécution. Le hic, c’est qu’AOT n’aura pas assez de données pour décider quel morceau de code doit être précompilé en code natif. En revanche, JIT intervient pendant l’exécution et a un impact sur le temps de préchauffage. Cependant, il disposera de suffisamment de données de profilage pour compiler et décompiler le code plus efficacement.