Olá pessoal!
Hoje, sua atenção é convidada para uma tradução do artigo, que mostra exemplos de opções de compilação na JVM. É dada atenção especial à compilação AOT suportada no Java 9 e superior.
Boa leitura!
Suponho que qualquer pessoa que já tenha programado em Java tenha ouvido falar em compilação instantânea (JIT) e, possivelmente, compilação antes da execução (AOT). Além disso, não há necessidade de explicar o que são linguagens "interpretadas". Este artigo explicará como todos esses recursos são implementados na máquina virtual Java, JVM.
Você provavelmente sabe que, ao programar em Java, é necessário executar um compilador (usando o programa “javac”) que coleta o código-fonte Java (arquivos .java) no código de bytes Java (arquivos .class). O bytecode Java é uma linguagem intermediária. É chamado de "intermediário" porque não é entendido por um dispositivo de computação real (CPU) e não pode ser executado por um computador e, portanto, representa uma forma de transição entre o código fonte e o código da máquina "nativo" executado no processador.
Para que o bytecode Java faça um trabalho específico, há três maneiras de fazê-lo:
- Execute diretamente o código intermediário. É melhor e mais correto dizer que precisa ser "interpretado". A JVM possui um intérprete Java. Como você sabe, para que a JVM funcione, é necessário executar o programa "java".
- Pouco antes de executar o código intermediário, compile-o no código nativo e force a CPU a executar esse código nativo recém-criado. Assim, a compilação ocorre logo antes da execução (Just in Time) e é chamada de "dinâmica".
- 3A primeira coisa, mesmo antes do lançamento do programa, o código intermediário é traduzido para o nativo e o executa na CPU do começo ao fim. Essa compilação é feita antes da execução e é chamada de AoT (Ahead of Time).
Portanto, (1) é o trabalho do intérprete, (2) é o resultado da compilação do JIT e (3) é o resultado da compilação do AOT.
Por uma questão de exaustividade, mencionarei que existe uma quarta abordagem - interpretar diretamente o código-fonte, mas em Java isso não é aceito. Isso é feito, por exemplo, em Python.
Agora vamos ver como “java” funciona como (1) o intérprete (2) do compilador JIT e / ou (3) o compilador AOT - e quando.
Em resumo - como regra, “java” faz os dois (1) e (2). A partir do Java 9, uma terceira opção também é possível.
Aqui está nossa classe de
Test
, que será usada em exemplos futuros.
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 você pode ver, existe um método
main
que instancia o objeto
Test
e chama ciclicamente a função
f
10 vezes seguidas. A função
f
não faz quase nada.
Portanto, se você compilar e executar o código acima, a saída será bastante esperada (é claro, os valores do tempo decorrido serão diferentes para você):
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
E agora a questão é: esta conclusão é o resultado do trabalho de “java” como intérprete, ou seja, opção (1), “java” como um compilador JIT, ou seja, opção (2) ou está de alguma forma relacionado à compilação AOT , ou seja, opção (3)? Neste artigo, vou encontrar as respostas certas para todas essas perguntas.
A primeira resposta que quero dar é mais provável que apenas (1) ocorra aqui. Digo "provavelmente", porque não sei se alguma variável de ambiente foi definida aqui que alteraria as opções padrão da JVM. Se nada supérfluo estiver instalado, e é assim que "java" funciona por padrão, aqui estamos 100% observando apenas a opção (1), ou seja, o código é totalmente interpretado. Estou certo disso, pois:
- De acordo com a documentação java, a opção
-XX:CompileThreshold=invocations
é executada com as invocations=1500
padrão invocations=1500
na JVM do cliente (mais informações sobre a JVM do cliente são descritas abaixo). Como eu o executo apenas 10 vezes e 10 <1500, não estamos falando de compilação dinâmica aqui. Normalmente, esta opção de linha de comando especifica quantas vezes (máximo) a função deve ser interpretada antes do início da etapa de compilação dinâmica. Vou me debruçar sobre isso abaixo. - Na verdade, eu executei esse código com sinalizadores de diagnóstico, para saber se ele foi compilado dinamicamente. Também vou explicar esse ponto abaixo.
Observe: A JVM pode funcionar no modo cliente ou servidor, e as opções definidas por padrão no primeiro e no segundo casos serão diferentes. Como regra, a decisão sobre o modo de inicialização é tomada automaticamente, dependendo do ambiente ou do computador em que a JVM foi iniciada. A seguir, especificarei a opção
–client
durante todas as inicializações, para não duvidar que o programa esteja sendo executado no modo cliente. Esta opção não afetará os aspectos que quero demonstrar neste post.
Se você executar “java” com a
-XX:PrintCompilation
, o programa imprimirá uma linha quando a função for compilada dinamicamente. Não esqueça que a compilação JIT é executada para cada função separadamente, algumas funções na classe podem permanecer no bytecode (ou seja, não compiladas), enquanto outras já podem ter passado na compilação JIT, ou seja, prontas para execução direta no processador .
Abaixo também adiciono a opção
-Xbatch
. A opção
-Xbatch
necessária apenas para tornar a saída mais apresentável; caso contrário, a compilação JIT continuará competitiva (junto com a interpretação) e a saída após a compilação às vezes pode parecer estranha no tempo de execução (devido a
-XX:PrintCompilation
). No entanto, a opção
–Xbatch
desativa a compilação em segundo plano; portanto, antes de executar a compilação JIT, a execução do nosso programa será interrompida.
(Para facilitar a leitura, escreverei cada opção de uma nova linha)
$ java -client -Xbatch -XX:+PrintCompilation Test
Não inserirei a saída deste comando aqui, porque, por padrão, a JVM compila muitas funções internas (relacionadas, por exemplo, aos pacotes java, sun, jdk), portanto a saída será muito longa - portanto, na minha tela, existem 274 linhas nas funções internas e mais alguns - até a conclusão do programa). Para facilitar essa pesquisa, cancelarei a compilação JIT para classes internas ou
Test.f
seletivamente apenas para o meu método (
Test.f
). Para fazer isso, especifique mais uma opção,
-XX:CompileCommand
. Você pode especificar muitos comandos (compilação), para que seja mais fácil colocá-los em um arquivo separado. Felizmente, temos a opção
-XX:CompileCommandFile
. Então, passe à criação do arquivo. Vou chamá-lo de
hotspot_compiler
por um motivo que explicarei em breve e escreverei o seguinte:
quiet exclude java/* * exclude jdk/* * exclude sun/* *
Nesse caso, deve ficar completamente claro que excluímos todas as funções (a última *) em todas as classes de todos os pacotes que começam com java, jdk e sun (os nomes dos pacotes são separados por / e você pode usar *). O comando
quiet
diz à JVM para não escrever nada sobre as classes excluídas; portanto, apenas as que estão compiladas agora serão exibidas no console. Então, eu corro:
java -client -Xbatch -XX:+PrintCompilation -XX:CompileCommandFile=hotspot_compiler Test
Antes de falar sobre a saída desse comando, lembro que nomeei esse arquivo como
hotspot_compiler
, porque parece (não verifiquei) que no Oracle JDK o nome
.hotspot_compiler
esteja definido por padrão para o arquivo com comandos do compilador.
Portanto, a conclusão é:
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
Primeiro, não sei por que alguns métodos
java.lang.invoke.MethodHandler.
ainda estão compilando
java.lang.invoke.MethodHandler.
Provavelmente, algumas coisas simplesmente não podem ser desativadas. Pelo que entendi, atualizarei esta postagem. No entanto, como você pode ver, todas as outras etapas de compilação (anteriormente havia 274 linhas) desapareceram. Em outros exemplos, também removerei
java.lang.invoke.MethodHandler
da saída do log de compilação.
Vamos ver o que chegamos. Agora temos um código simples onde executamos nossa função 10 vezes. Mencionei anteriormente que essa função é interpretada, não compilada, conforme indicado na documentação, e agora a vemos nos logs (ao mesmo tempo, não a vemos nos logs de compilação, e isso significa que ela não está sujeita à compilação JIT). Bem, você acabou de ver a ferramenta “java” em ação, interpretando e interpretando apenas nossa função em 100% dos casos. Então, podemos marcar a caixa que descobriu com a opção (1). Passamos para (2), compilação dinâmica.
De acordo com a documentação, você pode executar a função 1.500 vezes e verifique se a compilação JIT está realmente acontecendo. No entanto, você também pode usar a
-XX:CompileThreshold=invocations
chamada
-XX:CompileThreshold=invocations
, configurando o valor desejado em vez de 1500. Vamos apontar aqui 5. Isso significa que esperamos o seguinte: após 5 “interpretações” de nossa função f, a JVM deve compilar o método e, em seguida, executar a versão compilada.
java -client -Xbatch
-XX:+PrintCompilation -XX:CompileCommandFile=hotspot_compiler -XX:CompileThreshold=5 Test
Se você executou esse comando, pode ter notado que nada mudou em comparação com o exemplo acima. Ou seja, a compilação ainda não ocorre. Acontece que, de acordo com a documentação,
-XX:CompileThreshold
somente funciona quando
TieredCompilation
desativado, que é o padrão.
-XX:-TieredCompilation
assim:
-XX:-TieredCompilation
. A compilação em camadas é um recurso introduzido no Java 7 para melhorar a velocidade de inicialização e de cruzeiro da JVM. No contexto deste post, isso não é importante, portanto, fique à vontade para desativá-lo. Vamos agora executar este comando novamente:
java -client -Xbatch -XX:+PrintCompilation -XX:CompileCommandFile=hotspot_compiler -XX:CompileThreshold=5 -XX:-TieredCompilation Test
Aqui está a saída (lembro-me, perdi as linhas sobre
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
Congratulamo-nos com (olá!) A função compilada dinamicamente Test.f ou
Test::<init>
imediatamente após chamar o número 5, porque eu defini CompileThreshold como 5. A JVM interpreta a função 5 vezes, depois a compila e finalmente executa a versão compilada. Como a função é compilada, ela deve ser executada mais rapidamente, mas não podemos verificar isso aqui, pois essa função não faz nada. Eu acho que esse é um bom tópico para um post separado.
Como você provavelmente já adivinhou, outra função é compilada aqui, a saber
Test::<init>
, que é um construtor da classe
Test
. Como o código chama o construtor (new
Test()
), sempre que
f
chamado, ele compila simultaneamente com a função
f
, exatamente após 5 chamadas.
Em princípio, isso pode encerrar a discussão da opção (2), compilação JIT. Como você pode ver, nesse caso, a função é primeiro interpretada pela JVM, depois compilada dinamicamente após a interpretação quíntupla. Gostaria de adicionar os últimos detalhes sobre a compilação JIT, ou seja, para mencionar a opção
-XX:+PrintAssembly
. Como o nome indica, ele exibe no console uma versão compilada da função (versão compilada = código de máquina nativo = código do montador). No entanto, isso só funcionará se houver um desmontador no caminho da biblioteca. Eu acho que o desmontador pode diferir em diferentes JVMs, mas neste caso estamos lidando com hsdis - um desmontador para o openjdk. O código fonte da biblioteca hsdis ou seu arquivo binário pode ser obtido em diferentes locais. Nesse caso, compilei esse arquivo e coloquei
hsdis-amd64.so
em
JAVA_HOME/lib/server
.
Então agora podemos executar este comando. Mas primeiro eu tenho que adicionar isso para executar
-XX:+PrintAssembly
também precisa adicionar a opção
-XX:+UnlockDiagnosticVMOptions
, e ela deve seguir antes da opção
PrintAssembly
. Se isso não for feito, a JVM emitirá um aviso sobre o uso incorreto da opção
PrintAssembly
. Vamos executar este código:
java -client -Xbatch -XX:+PrintCompilation -XX:CompileCommandFile=hotspot_compiler -XX:CompileThreshold=5 -XX:-TieredCompilation -XX:+UnlockDiagnosticVMOptions -XX:+PrintAssembly Test
A saída será longa e haverá linhas como:
0x00007f4b7cab1120: mov 0x8(%rsi),%r10d 0x00007f4b7cab1124: shl $0x3,%r10 0x00007f4b7cab1128: cmp %r10,%rax
Como você pode ver, as funções correspondentes são compiladas no código de máquina nativo.
Por fim, discuta a opção 3, AOT. A compilação antes da execução, AOT, não estava disponível em Java anterior à versão 9.
Uma nova ferramenta apareceu no JDK 9, jaotc - como o nome indica, é um compilador AOT para Java. A idéia é a seguinte: execute o compilador Java “javac”, depois o compilador AOT para Java “jaotc” e, em seguida, execute a “java” da JVM como de costume. A JVM normalmente executa interpretação e compilação JIT. No entanto, se a função tiver código compilado pela AOT, ela a usará diretamente e não recorrerá à interpretação ou compilação JIT. Deixe-me explicar: você não precisa executar o compilador AOT, ele é opcional e, se você o usar, poderá compilar apenas as classes que deseja antes que ele seja executado.
Vamos construir uma biblioteca que consiste em uma versão compilada por AOT do
Test::f
. Não se esqueça: para fazer isso sozinho, você precisará do JDK 9 na compilação 150+.
jaotc --output=libTest.so Test.class
Como resultado,
libTest.so
gerado, uma biblioteca que contém código de funções nativo compilado por AOT incluído na classe
Test
. Você pode visualizar os caracteres definidos nesta biblioteca:
nm libTest.so
Em nossa conclusão, entre outras coisas, haverá:
0000000000002120 t Test.f()I 00000000000021a0 t Test.<init>()V 00000000000020a0 t Test.main([Ljava/lang/String;)V
Portanto, todas as nossas funções, construtor,
f
, método estático
main
estão presentes na biblioteca
libTest.so
.
Como no caso da opção “java” correspondente, nesse caso, a opção pode ser acompanhada de um arquivo, para isso existe a opção –compile-command para o jaotc. O JEP 295 fornece exemplos relevantes que não mostrarei aqui.
Vamos agora executar “java” e ver se métodos compilados por AOT são usados. Se você executar “java” como antes, a biblioteca AOT não será usada, e isso não é surpreendente. Para usar esse novo recurso, é fornecida a opção
-XX:AOTLibrary
, que você deve especificar:
java -XX:AOTLibrary=./libTest.so Test
Você pode especificar várias bibliotecas AOT, separadas por vírgulas.
A saída deste comando é exatamente a mesma de quando se inicia o "java" sem o
AOTLibrary
, pois o comportamento do programa Test não foi alterado. Para verificar se as funções compiladas pelo AOT são usadas, você pode adicionar outra nova opção,
-XX:+PrintAOT
.
java -XX:AOTLibrary=./libTest.so -XX:+PrintAOT Test
Antes da saída do programa de
Test
, este comando mostra o seguinte:
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
Conforme planejado, a biblioteca AOT é carregada e funções compiladas por AOT são usadas.
Se estiver interessado, você pode executar o seguinte comando e verificar se a compilação JIT está acontecendo.
java -client -Xbatch -XX:+PrintCompilation -XX:CompileCommandFile=hotspot_compiler -XX:CompileThreshold=5 -XX:-TieredCompilation -XX:AOTLibrary=./libTest.so -XX:+PrintAOT Test
Como esperado, a compilação JIT não ocorre, pois os métodos na classe Test são compilados antes da execução e fornecidos como uma biblioteca.
Uma pergunta possível é: se fornecermos um código de função nativo, como a JVM determina se o código nativo é obsoleto / obsoleto? Como exemplo final, vamos modificar a função
f
definir a como 6.
public int f() throws Exception { int a = 6; return a; }
Eu fiz isso apenas para modificar o arquivo de classe. Agora, compilamos o javac e executamos o mesmo comando acima.
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 você pode ver, eu não executei “jaotc” após “javac”; portanto, o código da biblioteca AOT agora está antigo e incorreto, e a função
f
tem a = 5.
A saída do comando "java" acima demonstra:
228 56 b Test::<init> (5 bytes) 229 57 b Test::f (5 bytes)
Isso significa que as funções nesse caso foram compiladas dinamicamente, portanto, o código resultante da compilação AOT não foi usado. Portanto, uma alteração foi detectada no arquivo de classe. Quando a compilação é executada usando javac, sua impressão digital é inserida na classe e a impressão digital da classe também é armazenada na biblioteca AOT. Como a nova impressão digital da classe difere daquela armazenada na biblioteca AOT, o código nativo compilado antecipadamente (AOT) não foi usado. Era tudo o que eu queria contar sobre a última opção de compilação, antes da execução.
Neste artigo, tentei explicar e ilustrar com exemplos simples e realísticos como a JVM executa o código Java: interpretando-o, compilando dinamicamente (JIT) ou antecipadamente (AOT) - além disso, a última oportunidade apareceu apenas no JDK 9. Espero que você tenha aprendido algo novo.