Hola a todos!
Hoy, su atención está invitada a una traducción del artículo, que muestra ejemplos de opciones de compilación en la JVM. Se presta especial atención a la compilación AOT compatible con Java 9 y superior.
Que tengas una buena lectura!
Supongo que cualquiera que haya programado en Java ha oído hablar de la compilación instantánea (JIT), y posiblemente de la compilación antes de la ejecución (AOT). Además, no es necesario explicar qué son los idiomas "interpretados". Este artículo explicará cómo se implementan todas estas características en la máquina virtual Java, JVM.
Probablemente sepa que al programar en Java, necesita ejecutar un compilador (usando el programa "javac") que recopila el código fuente de Java (archivos .java) en el código de bytes de Java (archivos .class). Java bytecode es un lenguaje intermedio. Se llama "intermedio" porque no es entendido por un dispositivo informático real (CPU) y no puede ser ejecutado por una computadora y, por lo tanto, representa una forma de transición entre el código fuente y el código máquina "nativo" ejecutado en el procesador.
Para que el bytecode de Java haga un trabajo específico, hay 3 formas de hacerlo:
- Ejecute directamente el código intermedio. Es mejor y más correcto decir que necesita ser "interpretado". La JVM tiene un intérprete de Java. Como sabe, para que la JVM funcione, debe ejecutar el programa "java".
- Justo antes de ejecutar el código intermedio, compílelo en código nativo y fuerce a la CPU a ejecutar este código nativo recién horneado. Por lo tanto, la compilación se lleva a cabo justo antes de la ejecución (Just in Time) y se llama "dinámica".
- 3Lo primero, incluso antes de que se inicie el programa, el código intermedio se traduce al nativo y lo ejecuta a través de la CPU de principio a fin. Esta compilación se realiza antes de la ejecución y se llama AoT (Ahead of Time).
Entonces, (1) es el trabajo del intérprete, (2) es el resultado de la compilación JIT y (3) es el resultado de la compilación AOT.
En aras de la exhaustividad, mencionaré que hay un cuarto enfoque: interpretar directamente el código fuente, pero en Java esto no se acepta. Esto se hace, por ejemplo, en Python.
Ahora veamos cómo funciona "java" como (1) el intérprete de (2) el compilador JIT y / o (3) el compilador AOT, y cuándo.
En resumen, como regla, "java" hace tanto (1) como (2). A partir de Java 9, también es posible una tercera opción.
Aquí está nuestra clase de
Test
, que se utilizará en futuros ejemplos.
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)); } } }
Como puede ver, hay un método
main
que crea instancias del objeto
Test
y llama cíclicamente a la función
f
10 veces seguidas. La función
f
no hace casi nada.
Entonces, si compila y ejecuta el código anterior, la salida será bastante esperada (por supuesto, los valores del tiempo transcurrido serán diferentes para usted):
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
Y ahora la pregunta es: ¿es esta conclusión el resultado del trabajo de "java" como intérprete, es decir, la opción (1), "java" como un compilador JIT, es decir, la opción (2) o está relacionado de alguna manera con la compilación AOT , es decir, la opción (3)? En este artículo voy a encontrar las respuestas correctas a todas estas preguntas.
La primera respuesta que quiero dar es muy probable que solo (1) tenga lugar aquí. Digo "muy probablemente", porque no sé si alguna variable de entorno está configurada aquí que cambiaría las opciones predeterminadas de JVM. Si no se instala nada superfluo, y así es como funciona "java" por defecto, entonces aquí estamos observando al 100% solo la opción (1), es decir, el código se interpreta completamente. Estoy seguro de esto, ya que:
- De acuerdo con la documentación de Java, la
-XX:CompileThreshold=invocations
se inicia con las invocations=1500
predeterminadas invocations=1500
en la JVM del cliente (a continuación se describe más sobre la JVM del cliente). Como lo ejecuto solo 10 veces y 10 <1500, no estamos hablando de compilación dinámica aquí. Por lo general, esta opción de línea de comando especifica cuántas veces (máximo) debe interpretarse la función antes de que comience el paso de compilación dinámica. Me detendré en esto a continuación. - De hecho, ejecuté este código con marcas de diagnóstico, así que sé si se compiló dinámicamente. También explicaré este punto a continuación.
Tenga en cuenta: JVM puede funcionar en modo cliente o servidor, y las opciones establecidas de forma predeterminada en el primer y segundo caso serán diferentes. Como regla general, la decisión sobre el modo de inicio se toma automáticamente, dependiendo del entorno o la computadora donde se lanzó la JVM. En lo sucesivo, especificaré la opción
–client
durante todos los inicios, para no dudar de que el programa se está ejecutando en modo cliente. Esta opción no afectará los aspectos que quiero demostrar en esta publicación.
Si ejecuta "java" con la
-XX:PrintCompilation
, el programa imprimirá una línea cuando la función se compila dinámicamente. No olvide que la compilación JIT se realiza para cada función por separado, algunas funciones de la clase pueden permanecer en bytecode (es decir, no compiladas), mientras que otras pueden haber pasado la compilación JIT, es decir, listas para la ejecución directa en el procesador .
A continuación también agrego la opción
-Xbatch
. La opción
-Xbatch
solo
-Xbatch
necesaria para que la salida se vea más presentable; de lo contrario, la compilación JIT procede de manera competitiva (junto con la interpretación), y el resultado después de la compilación a veces puede parecer extraño en tiempo de ejecución (debido a
-XX:PrintCompilation
). Sin embargo, la opción
–Xbatch
deshabilita la compilación en segundo plano, por lo tanto, antes de ejecutar la compilación JIT, la ejecución de nuestro programa se detendrá.
(En aras de la legibilidad, escribiré cada opción desde una nueva línea)
$ java -client -Xbatch -XX:+PrintCompilation Test
No insertaré la salida de este comando aquí, porque de forma predeterminada, la JVM compila muchas funciones internas (relacionadas, por ejemplo, con los paquetes java, sun, jdk), por lo que la salida será muy larga, por lo que, en mi pantalla, hay 274 líneas en las funciones internas , y algunos más hasta la conclusión del programa en sí). Para facilitar esta investigación, cancelaré la compilación JIT para clases internas o la habilitaré selectivamente solo para mi método (
Test.f
). Para hacer esto, especifique una opción más,
-XX:CompileCommand
. Puede especificar muchos comandos (compilación), por lo que sería más fácil colocarlos en un archivo separado. Afortunadamente, tenemos la opción
-XX:CompileCommandFile
. Entonces, pasa a crear el archivo. Lo llamaré
hotspot_compiler
por una razón que explicaré en breve y escribiré lo siguiente:
quiet exclude java/* * exclude jdk/* * exclude sun/* *
En este caso, debe quedar completamente claro que excluimos todas las funciones (la última *) en todas las clases de todos los paquetes que comienzan con java, jdk y sun (los nombres de los paquetes están separados por /, y puede usar *). El comando
quiet
le dice a la JVM que no escriba nada sobre las clases excluidas, por lo que solo las que se compilan ahora se enviarán a la consola. Entonces, corro:
java -client -Xbatch -XX:+PrintCompilation -XX:CompileCommandFile=hotspot_compiler Test
Antes de informarle sobre la salida de este comando, le recuerdo que llamé a este archivo
hotspot_compiler
, porque parece (no verifiqué) que en Oracle JDK el nombre
.hotspot_compiler
está configurado de manera predeterminada para el archivo con los comandos del compilador.
Entonces la conclusión es:
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
Primero, no sé por qué algunos métodos
java.lang.invoke.MethodHandler.
todavía se están compilando
java.lang.invoke.MethodHandler.
Probablemente, algunas cosas simplemente no se pueden apagar. Como entiendo cuál es el problema, actualizaré esta publicación. Sin embargo, como puede ver, todos los demás pasos de compilación (anteriormente había 274 líneas) ahora han desaparecido. En otros ejemplos, también eliminaré
java.lang.invoke.MethodHandler
de la salida del registro de compilación.
Veamos a qué hemos llegado. Ahora tenemos un código simple donde ejecutamos nuestra función 10 veces. Mencioné anteriormente que esta función se interpreta, no se compila, como se indica en la documentación, y ahora la vemos en los registros (al mismo tiempo, no la vemos en los registros de compilación, y esto significa que no está sujeta a la compilación JIT). Bueno, acabas de ver la herramienta "java" en acción, interpretando y solo interpretando nuestra función en el 100% de los casos. Entonces, podemos marcar la casilla que descubrió con la opción (1). Pasamos a (2), compilación dinámica.
De acuerdo con la documentación, puede ejecutar la función 1.500 veces y asegurarse de que la compilación JIT realmente esté sucediendo. Sin embargo, también puede usar la
-XX:CompileThreshold=invocations
llamada
-XX:CompileThreshold=invocations
, estableciendo el valor deseado en lugar de 1500. Señalemos aquí 5. Esto significa que esperamos lo siguiente: después de 5 "interpretaciones" de nuestra función f, la JVM debe compilar el método y luego ejecutar la versión compilada.
java -client -Xbatch
-XX:+PrintCompilation -XX:CompileCommandFile=hotspot_compiler -XX:CompileThreshold=5 Test
Si ejecutó este comando, es posible que haya notado que nada ha cambiado en comparación con el ejemplo anterior. Es decir, la compilación aún no ocurre. Resulta que, de acuerdo con la documentación,
-XX:CompileThreshold
solo funciona cuando
TieredCompilation
deshabilitado, que es el valor predeterminado. Se
-XX:-TieredCompilation
así:
-XX:-TieredCompilation
. La compilación escalonada es una característica introducida en Java 7 para mejorar tanto el lanzamiento como la velocidad de crucero de la JVM. En el contexto de esta publicación, no es importante, así que siéntete libre de deshabilitarla. Ahora ejecutemos este comando nuevamente:
java -client -Xbatch -XX:+PrintCompilation -XX:CompileCommandFile=hotspot_compiler -XX:CompileThreshold=5 -XX:-TieredCompilation Test
Aquí está la salida (recuerdo, he perdido las líneas con respecto a
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
Damos la bienvenida (¡hola!) A la función compilada dinámicamente Test.f o
Test::<init>
inmediatamente después de llamar al número 5, porque configuré CompileThreshold en 5. La JVM interpreta la función 5 veces, luego la compila y finalmente ejecuta la versión compilada. Como la función está compilada, debería ejecutarse más rápido, pero no podemos verificar esto aquí, ya que esta función no hace nada. Creo que este es un buen tema para una publicación separada.
Como probablemente ya haya adivinado, aquí se compila otra función, a saber,
Test::<init>
, que es un constructor de la clase
Test
. Como el código llama al constructor (nueva
Test()
), cada vez que
f
llama
f
, se compila simultáneamente con la función
f
, exactamente después de 5 llamadas.
En principio, esto puede terminar la discusión de la opción (2), compilación JIT. Como puede ver, en este caso, la función es interpretada primero por la JVM, luego compilada dinámicamente después de una interpretación quíntuple. Me gustaría agregar el último detalle con respecto a la compilación JIT, es decir, mencionar la opción
-XX:+PrintAssembly
. Como su nombre lo indica, envía a la consola una versión compilada de la función (versión compilada = código máquina nativo = código ensamblador). Sin embargo, esto solo funcionará si hay un desensamblador en la ruta de la biblioteca. Supongo que el desensamblador puede diferir en diferentes JVM, pero en este caso estamos tratando con hsdis, un desensamblador para openjdk. El código fuente de la biblioteca hsdis o su archivo binario se puede tomar en diferentes lugares. En este caso, compilé este archivo y puse
hsdis-amd64.so
en
JAVA_HOME/lib/server
.
Entonces ahora podemos ejecutar este comando. Pero primero debo agregar eso para ejecutar
-XX:+PrintAssembly
también debe agregar la
-XX:+UnlockDiagnosticVMOptions
, y debe seguir antes de la opción
PrintAssembly
. Si esto no se hace, la JVM le dará una advertencia sobre el uso incorrecto de la opción
PrintAssembly
. Ejecutemos este código:
java -client -Xbatch -XX:+PrintCompilation -XX:CompileCommandFile=hotspot_compiler -XX:CompileThreshold=5 -XX:-TieredCompilation -XX:+UnlockDiagnosticVMOptions -XX:+PrintAssembly Test
La salida será larga y habrá líneas como:
0x00007f4b7cab1120: mov 0x8(%rsi),%r10d 0x00007f4b7cab1124: shl $0x3,%r10 0x00007f4b7cab1128: cmp %r10,%rax
Como puede ver, las funciones correspondientes se compilan en código máquina nativo.
Finalmente, discuta la opción 3, AOT. La compilación antes de la ejecución, AOT, no estaba disponible en Java antes de la versión 9.
Ha aparecido una nueva herramienta en JDK 9, jaotc, como su nombre lo indica, es un compilador AOT para Java. La idea es esta: ejecute el compilador Java "javac", luego el compilador AOT para Java "jaotc", y luego ejecute el JVM "java" como de costumbre. La JVM normalmente realiza interpretación y compilación JIT. Sin embargo, si la función tiene código compilado AOT, lo usa directamente y no recurre a la interpretación o la compilación JIT. Déjame explicarte: no tienes que ejecutar el compilador AOT, es opcional, y si lo usas, solo puedes compilar las clases que deseas antes de que se ejecute.
Construyamos una biblioteca que consista en una versión compilada AOT de
Test::f
. No olvide: para hacerlo usted mismo, necesitará JDK 9 en la compilación 150+.
jaotc --output=libTest.so Test.class
Como resultado,
libTest.so
genera
libTest.so
, una biblioteca que contiene un código nativo de funciones compilado por AOT incluido en la clase
Test
. Puede ver los caracteres definidos en esta biblioteca:
nm libTest.so
En nuestra conclusión, entre otras cosas, habrá:
0000000000002120 t Test.f()I 00000000000021a0 t Test.<init>()V 00000000000020a0 t Test.main([Ljava/lang/String;)V
Entonces, todas nuestras funciones, constructor,
f
, método estático
main
están presentes en la biblioteca
libTest.so
.
Como en el caso de la opción "java" correspondiente, en este caso la opción puede ir acompañada de un archivo, para esto está la opción –compile-command de jaotc. JEP 295 proporciona ejemplos relevantes que no mostraré aquí.
Ejecutemos ahora "java" y veamos si se utilizan métodos compilados por AOT. Si ejecuta "java" como antes, entonces la biblioteca AOT no se usará, y esto no es sorprendente. Para usar esta nueva función, se proporciona la opción
-XX:AOTLibrary
, que debe especificar:
java -XX:AOTLibrary=./libTest.so Test
Puede especificar varias bibliotecas AOT, separadas por comas.
El resultado de este comando es exactamente el mismo que cuando se inicia "java" sin
AOTLibrary
, ya que el comportamiento del programa Test no ha cambiado en absoluto. Para verificar si se utilizan funciones compiladas por AOT, puede agregar otra nueva opción,
-XX:+PrintAOT
.
java -XX:AOTLibrary=./libTest.so -XX:+PrintAOT Test
Antes de la salida del programa de
Test
, este comando muestra lo siguiente:
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
Según lo planeado, se carga la biblioteca AOT y se utilizan funciones compiladas por AOT.
Si está interesado, puede ejecutar el siguiente comando y verificar si está ocurriendo la compilación JIT.
java -client -Xbatch -XX:+PrintCompilation -XX:CompileCommandFile=hotspot_compiler -XX:CompileThreshold=5 -XX:-TieredCompilation -XX:AOTLibrary=./libTest.so -XX:+PrintAOT Test
Como se esperaba, la compilación JIT no ocurre, ya que los métodos en la clase Test se compilan antes de la ejecución y se proporcionan como una biblioteca.
Una posible pregunta es: si proporcionamos un código de función nativo, entonces, ¿cómo determina la JVM si el código nativo es obsoleto / obsoleto? Como último ejemplo, modifiquemos la función
f
y establezcamos a a 6.
public int f() throws Exception { int a = 6; return a; }
Hice esto solo para modificar el archivo de clase. Ahora hacemos compilación de javac y ejecutamos el mismo comando que el anterior.
javac Test.java java -client -Xbatch -XX:+PrintCompilation -XX:CompileCommandFile=hotspot_compiler -XX:CompileThreshold=5 -XX:-TieredCompilation -XX:AOTLibrary=./libTest.so -XX:+PrintAOT Test
Como puede ver, no ejecuté "jaotc" después de "javac", por lo que el código de la biblioteca AOT ahora es antiguo e incorrecto, y la función
f
tiene a = 5.
La salida del comando "java" anterior demuestra:
228 56 b Test::<init> (5 bytes) 229 57 b Test::f (5 bytes)
Esto significa que las funciones en este caso se compilaron dinámicamente, por lo que no se utilizó el código resultante de la compilación AOT. Entonces, se ha detectado un cambio en el archivo de clase. Cuando la compilación se realiza usando javac, su huella digital se ingresa en la clase, y la huella digital de la clase también se almacena en la biblioteca AOT. Dado que la nueva huella digital de la clase difiere de la almacenada en la biblioteca AOT, no se utilizó el código nativo compilado por adelantado (AOT). Eso es todo lo que quería decirte sobre la última opción de compilación, antes de la ejecución.
En este artículo intenté explicar e ilustrar con ejemplos realistas simples cómo JVM ejecuta código Java: interpretándolo, compilando dinámicamente (JIT) o por adelantado (AOT); además, la última oportunidad apareció solo en JDK 9. Espero que hayas descubierto algo nuevo