Una guía para la interpretación y compilación de JVM.
Utilice la interpretación, la compilación justo a tiempo y la compilación anticipada de manera eficiente comprendiendo las diferencias entre ellas.
Java es un lenguaje independiente de la plataforma. Los programas se convierten a código de bytes después de la compilación. Este código de bytes se convierte a código de máquina en tiempo de ejecución. Un intérprete emula la ejecución de instrucciones de código de bytes para la máquina abstracta en una máquina física específica. La compilación justo a tiempo (JIT) ocurre en algún momento durante la ejecución y la compilación anticipada (AOT) ocurre durante el tiempo de compilación.
Este artículo explica cuándo entra en juego un intérprete y cuándo ocurrirán JIT y AOT. También analizo las compensaciones entre JIT y AOT.
Código fuente, código de bytes, código de máquina
Las aplicaciones generalmente se escriben utilizando un lenguaje de programación como C, C++ o Java. El conjunto de instrucciones escritas utilizando lenguajes de programación de alto nivel se denomina código fuente. El código fuente es legible por humanos. Para ejecutarlo en la máquina de destino, el código fuente debe convertirse a código de máquina, que sea legible por máquina. El código fuente normalmente lo convierte un compilador en código de máquina.
Sin embargo, en Java, el código fuente primero se convierte a una forma intermedia llamada código de bytes. Este código de bytes es independiente de la plataforma, razón por la cual Java es bien conocido como un lenguaje de programación independiente de la plataforma. El compilador principal de Java javac
convierte el código fuente de Java en código de bytes. Luego, el intérprete interpreta el código de bytes.
Aquí hay un pequeño programa Hello.java
:
//Hello.java
public class Hello {
public static void main(String[] args) {
System.out.println("Inside Hello World!");
}
}
Compílelo usando javac
para generar un archivo Hello.class
que contenga el código de bytes.
$ javac Hello.java
$ ls
Hello.class Hello.java
Ahora, use javap
para desmontar el contenido del archivo Hello.class
. La salida de javap
depende de las opciones utilizadas. Si no elige ninguna opción, imprime información básica, incluido desde qué archivo fuente se compila este archivo de clase, el nombre del paquete, los campos públicos y protegidos, y los métodos de la clase.
$ javap Hello.class
Compiled from "Hello.java"
public class Hello {
public Hello();
public static void main(java.lang.String[]);
}
Para ver el contenido del código de bytes en el archivo .class
, use la opción -c
:
$ 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: return
public 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
}
Para obtener información más detallada, utilice la opción -v
:
$ javap -v Hello.class
Intérprete, JIT, AOT
El intérprete es responsable de emular la ejecución de instrucciones de código de bytes para la máquina abstracta en una máquina física específica. Al compilar el código fuente usando javac
y ejecutarlo usando el comando java
, el intérprete opera durante el tiempo de ejecución y cumple su propósito.
$ javac Hello.java
$ java Hello
Inside Hello World!
El compilador JIT también funciona en tiempo de ejecución. Cuando el intérprete interpreta un programa Java, otro componente, llamado perfilador de tiempo de ejecución, monitorea silenciosamente la ejecución del programa para observar qué parte del código se interpreta y cuántas veces. Estas estadísticas ayudan a detectar los puntos de acceso del programa, es decir, aquellas partes del código que se interpretan con frecuencia. Una vez que se interpretan por encima de un umbral establecido, son elegibles para ser convertidos en código de máquina directamente por el compilador JIT. El compilador JIT también se conoce como compilador guiado por perfiles. La conversión de código de bytes a código nativo ocurre sobre la marcha, de ahí el nombre justo a tiempo. JIT reduce la sobrecarga del intérprete que emula el mismo conjunto de instrucciones en código de máquina.
El compilador AOT compila código durante el tiempo de compilación. La generación de código compilado JIT y interpretado con frecuencia en el momento de la compilación mejora el tiempo de calentamiento de la máquina virtual Java (JVM). Este compilador se introdujo en Java 9 como una característica experimental. La herramienta jaotc
utiliza el compilador Graal, que a su vez está escrito en Java, para la compilación AOT.
A continuación se muestra un caso de uso de muestra para un programa Hello:
//Hello.java
public class Hello {
public static void main(String[] args) {
System.out.println("Inside Hello World!");
}
}
$ javac Hello.java
$ jaotc --output libHello.so Hello.class
$ java -XX:+UnlockExperimentalVMOptions -XX:AOTLibrary=./libHello.so Hello
Inside Hello World!
Cuándo entran en juego la interpretación y la compilación: un ejemplo
Este ejemplo ilustra cuándo Java usa un intérprete y cuándo intervienen JIT y AOT. Considere un programa 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("--------------------------------");
}
}
}
Este sencillo programa tiene un método main
que crea una instancia de objeto Demo
y llama al método square
, que muestra la raíz cuadrada del . >for
valor de iteración del bucle. Ahora, compila y ejecuta el código:
$ 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 pregunta ahora es si el resultado es el resultado del intérprete, JIT o AOT. En este caso, se interpreta íntegramente. ¿Cómo llegué a esa conclusión? Bueno, para que JIT contribuya a la compilación, los puntos críticos del código deben interpretarse por encima de un umbral definido. Entonces, y sólo entonces, esos fragmentos de código se ponen en cola para la compilación JIT. Para encontrar el umbral para 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)
El resultado anterior demuestra que un fragmento de código en particular debe interpretarse 10.000 veces para ser elegible para la compilación JIT. ¿Se puede ajustar este umbral manualmente? ¿Existe algún indicador de JVM que indique si un método está compilado JIT? Sí, existen múltiples opciones para cumplir este propósito.
Una opción para saber si un método está compilado JIT es -XX:+PrintCompilation
. Junto con esta opción, la bandera -Xbatch
proporciona el resultado de una manera más legible. Si tanto la interpretación como el JIT se realizan en paralelo, el indicador -Xbatch
ayuda a distinguir la salida de ambos. Utilice estas banderas de la siguiente manera:
$ 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
--------------------------------
El resultado del comando anterior es demasiado largo, por lo que trunqué la parte central. Tenga en cuenta que junto con el código del programa de demostración, también se compilan las funciones de clase internas del JDK. Por eso el resultado es tan largo. Debido a que mi enfoque es el código Demo.java
, usaré una opción que puede minimizar la salida excluyendo las funciones internas del paquete. El comando -XX:CompileCommandFile
deshabilita JIT para clases internas:
$ java -Xbatch -XX:+PrintCompilation -XX:CompileCommandFile=hotspot_compiler Demo
El archivo hotspot_compiler
al que hace referencia -XX:CompileCommandFile
contiene este código para excluir paquetes específicos:
$ cat hotspot_compiler
quiet
exclude java/* *
exclude jdk/* *
exclude sun/* *
En la primera línea, quiet
le indica a la JVM que no escriba nada sobre las clases excluidas. Para ajustar el umbral JIT, use -XX:CompileThreshold
con el valor establecido en 5, lo que significa que después de interpretar cinco veces, es hora de 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
--------------------------------
¡El resultado todavía no es diferente del resultado interpretado! Esto se debe a que, según la documentación de Oracle, el indicador -XX:CompileThreshold
es efectivo solo cuando TieredCompilation
está deshabilitado:
$ 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
Esta sección de código ahora está compilada JIT después de la quinta interpretación:
--------------------------------
5 iteration
214 56 b Demo::<init> (5 bytes)
215 57 b Demo::square (16 bytes)
Square(i) = 25
Time taken= 983002
--------------------------------
Junto con el método square()
, el constructor también compila JIT porque hay una instancia de demostración dentro del bucle for
antes de llamar a square()
. >. Por lo tanto, también alcanzará el umbral y se compilará JIT. Este ejemplo ilustra cuándo entra en juego JIT después de la interpretación.
Para ver la versión compilada del código, use el -XX:+PrintAssembly flag
, que funciona solo si hay un desensamblador en la ruta de la biblioteca. Para OpenJDK, utilice el desensamblador hsdis
. Descargue una biblioteca desensambladora adecuada (en este caso, hsdis-amd64.so
) y colóquela en Java_HOME/lib/server
. Asegúrese de utilizar -XX:+UnlockDiagnosticVMOptions
antes de -XX:+PrintAssembly
. De lo contrario, JVM le dará una advertencia.
El comando completo es el siguiente:
$ 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
El resultado es largo, por lo que he incluido solo el resultado relacionado con Demo.java
.
Ahora es el momento de la compilación de AOT. Esta opción se introdujo en JDK9. AOT es un compilador estático para generar la biblioteca .so
. Con AOT, las clases interesadas se pueden compilar para crear una biblioteca .so
que se puede ejecutar directamente en lugar de interpretar o compilar JIT. Si JVM no encuentra ningún código compilado con AOT, se lleva a cabo la interpretación habitual y la compilación JIT.
El comando utilizado para la compilación AOT es el siguiente:
$ jaotc --output=libDemo.so Demo.class
Para ver los símbolos en la biblioteca compartida, utilice lo siguiente:
$ nm libDemo.so
Para usar la biblioteca .so
generada, use -XX:AOTLibrary
junto con -XX:+UnlockExperimentalVMOptions
de la siguiente manera:
$ java -XX:+UnlockExperimentalVMOptions -XX:AOTLibrary=./libDemo.so Demo
1 iteration
Square(i) = 1
Time taken= 7831139
--------------------------------
2 iteration
Square(i) = 4
Time taken= 36619
[...]
10 iteration
Square(i) = 100
Time taken= 42085
Este resultado parece ser una versión interpretada en sí misma. Para asegurarse de que se utilice el código compilado AOT, utilice -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
Sólo para asegurarse de que no se haya realizado la compilación JIT, utilice lo siguiente:
$ 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 se realiza algún pequeño cambio en el código fuente sujeto a AOT, es importante asegurarse de que se vuelva a crear el .so
correspondiente. De lo contrario, el .so
obsoleto compilado por AOT no tendrá ningún efecto. Por ejemplo, haga un pequeño cambio en la función cuadrado de modo que ahora calcule el cubo:
//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("--------------------------------");
}
}
}
Ahora, compila Demo.java
nuevamente:
$ java Demo.java
Pero no cree libDemo.so
usando jaotc
. En su lugar, utilice este comando:
$ 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
Aunque la versión anterior de libDemo.so
está cargada, JVM la detectó como obsoleta. Cada vez que se crea un archivo .class
, se ingresa una huella digital en el archivo de clase y se guarda en la biblioteca AOT. Debido a que la huella digital de la clase es diferente de la de la biblioteca AOT, no se utiliza código nativo compilado por AOT. En cambio, el método ahora está compilado JIT, porque -XX:CompileThreshold
está establecido en 3.
¿AOT o JIT?
Si su objetivo es reducir el tiempo de calentamiento de la JVM, utilice AOT, que reduce la carga durante el tiempo de ejecución. El problema es que AOT no tendrá suficientes datos para decidir qué fragmento de código debe precompilarse en código nativo. Por el contrario, JIT colabora durante el tiempo de ejecución e impacta el tiempo de calentamiento. Sin embargo, tendrá suficientes datos de perfil para compilar y descompilar el código de manera más eficiente.