Pular para o conteúdo principal

Uma questão de interpretação: de bytecodes a código de máquina na JVM

 


Uma questão de interpretação: de bytecodes a código de máquina na JVM

Esta é uma tradução/adaptação do artigo original em inglês.

No primeiro artigo desta série, vimos como os aplicativos baseados em JVM usam um conjunto de instruções virtuais na forma de bytecodes para criar um aplicativo de plataforma neutra. A JVM converte esses bytecodes no tempo de execução nas instruções para a plataforma na qual o aplicativo está sendo executado.

Antes de nos aprofundarmos na compilação just-in-time (JIT), vamos ver como a JVM começa a executar bytecodes usando um interpretador.

A ideia básica do interpretador é simples. Cada método, começando com public static void main(), possui um conjunto de bytecodes que definem o que ele fará. A JVM interpretará o primeiro bytecode (assumindo que a instrução não resulta em um salto para um endereço diferente ou chamará outro método) continuará com cada bytecode em sequência. Interpretar é o processo de converter um bytecode em qualquer chamada de sistema operacional ou instruções de código de máquina necessárias para executar a ação do bytecode.

Declarar assim parece simples, mas há uma quantidade considerável de trabalho envolvido.

Vamos voltar ao programa de exemplo do post anterior que soma os números de um a dez (vamos deixar de imprimir o resultado para manter o código o mais simples possível).


public class Sum {
  public static void main(String[] args) {
    int sum = 0;

    for (int i = 1; i <= 10; i++) {
      sum += i;
    }

    System.out.println(sum);
  }
}


O arquivo de classe compilado contém os seguintes bytecodes (produzidos com javap -c).

    Code:

       0: iconst_0

       1: istore_1

       2: iconst_1

       3: istore_2

       4: iload_2

       5: bipush        10

       7: if_icmpgt     20

      10: iload_1

      11: iload_2

      12: iadd

      13: istore_1

      14: iinc          2, 1

      17: goto          4

      20: return


Isso mostra que há 14 bytecodes a serem executados (A contagem de 20 inclui os operandos dos bytecodes). Como temos um loop, as nove instruções das posições 4 a 17 serão executadas dez vezes. Uma vez que incluímos as três instruções extras necessárias para sair do loop (posições 4 a 7), temos um total de 98 bytecodes para interpretar. Aqui, então, é uma questão interessante. Se executarmos este aplicativo, quantos bytecodes serão executados?

Para determinar isso, precisamos de uma compilação especial da JVM que forneça um nível de observabilidade maior do que o padrão. Não vou entrar em todos os detalhes de como fazer isso, mas você precisa configurar um ambiente de compilação OpenJDK usando os sinalizadores -with-debug-level=slowdebug e -with-native-debug-symbols=external. Isso fornece uma variedade de opções -XX adicionais que podem ser usadas ao iniciar a JVM. Também direi que usei o jlink para gerar um runtime que incluía apenas o módulo java.base, pois é tudo o que precisamos para executar a aplicação. Se executarmos nosso aplicativo com java -XX:+CountBytecodes Sum, obteremos o seguinte:

359058 bytecodes executed in 0.4s (0.904MHz)

A sério! Os 98 bytecodes esperados se transformaram em quase 360 mil! O que é ainda mais confuso é que você obtém três resultados diferentes se executar o aplicativo três vezes. Tanto para os autômatos finitos determinísticos que aprendi na universidade. O que está acontecendo? A primeira coisa a ser observada para descobrir isso é executar o aplicativo com a opção -verbose:class. Isso fornecerá informações sobre o carregamento de classe à medida que a JVM é executada. Uma simples contagem da saída revela que 412 classes estão sendo carregadas, 409 das quais são carregadas antes da nossa classe Sum. Embora não seja necessário que uma classe seja inicializada assim que for carregada (conforme definido na Especificação JVM), neste caso, todas são. Como você pode imaginar, muitas classes requerem inicialização complexa, instanciando objetos e chamando métodos. Tudo isso resulta (como podemos ver) na execução de muitos milhares de bytecodes. Demorei um pouco para descobrir por que o número de bytecodes executados varia para diferentes execuções. Embora o código que está sendo executado não esteja mudando (e, portanto, determinístico), o caminho percorrido pelo código pode ser diferente. Por exemplo, mesmo algo tão simples como o valor da hora atual pode resultar em uma ramificação diferente sendo obtida em uma instrução if.


Vamos voltar ao assunto real do post, que é como a JVM interpreta cada um desses muitos bytecodes. Como há muitas maneiras possíveis de fazer isso, discutiremos a implementação da Hotspot JVM no OpenJDK.


No JDK 1.0, o interpretador, como o restante da JVM, foi escrito em C e consistia em um loop while infinito. Dentro do loop havia uma instrução switch gigante, que incluía um case para cada instrução de bytecode. O loop while inicia a execução de bytecodes para um método e, quando o método retorna, sai do loop por meio de uma pausa.

O interpretador C++

O interpretador evoluiu no JDK 1.2 (suspeito, embora não possa confirmar isso) com a mudança para o Hotspot JVM. Às vezes chamado de C++Interpreter, ele ainda usa uma enorme instrução switch, mas faz alterações para melhorar a eficiência. Por exemplo, várias funções foram incorporadas para aproveitar os recursos específicos da plataforma.

Embora o C++Interpreter ainda esteja incluído no código-fonte do OpenJDK, ele não é construído e incluído na JVM por padrão. Nesse caso, o princípio Highlander é válido e pode haver apenas um (intérprete). A única compilação que faz uso disso é a de montagem zero.

O intérprete de modelos

O interpretador usado no OpenJDK por padrão é o interpretador de modelo (este tem sido o padrão desde, acredito, JDK 1.4, embora, novamente, eu não tenha confirmado isso). O uso de instruções case codificadas para implementar a funcionalidade de cada bytecode limita as otimizações que podem ser exploradas para uma determinada plataforma. Veremos isso mais quando analisarmos as vantagens e desvantagens da compilação AOT e JIT posteriormente nesta série.

O interpretador de modelo constrói o código do interpretador para cada bytecode quando a JVM é inicializada. Como o nome sugere, cada bytecode tem um template. A JVM pode determinar os detalhes precisos da microarquitetura em que está sendo executada e usar esse conhecimento para gerar código otimizado para cada bytecode. Esses pedaços de código são chamados de codelets. Em vez de usar uma instrução switch, o interpretador de modelo usa uma tabela de distribuição com pontos de entrada para cada codelet gerado.

Você pode obter mais informações sobre o que o interpretador de modelo faz usando as opções de linha de comando -XX:+UnlockDiagnosticVMOptions -XX:+PrintInterpreter. Executando isso na minha compilação do JDK 17 com nosso aplicativo simples mostrou:

Interpreter

code size        =     66K bytes
total space      =     66K bytes
wasted space     =      0K bytes

# of codelets    =    275
avg codelet size =    246 bytes

Como você pode ver, o interpretador de modelo gera 275 codelets, que é mais do que o número de bytecodes no conjunto de instruções da JVM. Um exame mais atento mostra que, além das instruções de bytecode, há um conjunto de codelets para otimizar determinadas operações. Por exemplo, existem codelets para lidar com coisas como funções matemáticas padrão (sin, cos, tan, etc.)

Há também uma série de codelets de atalho. Quando um campo do objeto atual é carregado por meio da referência this, sempre resulta em dois bytecodes, aload_0 seguido de getfield. Essa combinação de bytecodes resulta no valor de this sendo empurrado para a pilha e imediatamente retirado novamente. Neste caso, a JVM fornece uma pseudo-instrução, fast_agetfield, que elimina o push-pop redundante e reduz os dois bytecodes de uma operação muito comum para um. Outros codelets de atalho fazem coisas semelhantes para melhorar a eficiência de operações comuns de vários bytecodes. Há também, um tanto bizarramente, quatro codelets nofast que estão relacionados ao uso do recurso de compartilhamento de dados de classe (CDS). O tamanho médio dos codelets é de 246 bytes. Todos os codelets têm um tamanho múltiplo de 32 bytes, preenchendo com zeros quando necessário; muitos são apenas 96 bytes. Alguns, no entanto, são surpreendentemente grandes. Todo o branch se compara com zero bytecodes (ifeq, ifne, etc.), gera codelets (na minha máquina Windows com Core i7 de 9a geração) de 1376 bytes. (Eu não desmontei o código para tentar descobrir por que isso seria tão grande). Outra coisa curiosa é que o codelet para no-op é de 96 bytes! O impacto deste modelo, abordagem de geração de código em tempo real, é substancial. Volker Simonis fez uma comparação de referência de ambos, e seus resultados mostraram que o interpretador de modelo era (em termos gerais) duas vezes mais rápido que o interpretador C++ mais simples. Certamente, faria sentido armazenar em cache o código gerado pelo modelo para reduzir o tempo de inicialização da JVM? De maneira útil, podemos ver quanto tempo leva para gerar todos os codelets usando o sinalizador -Xlog:startuptime. Para nossa aplicação, os resultados na minha máquina são:

java -Xlog:startuptime Sum
[0.007s][info][startuptime] StubRoutines generation 1, 0.0002250 secs
[0.014s][info][startuptime] Genesis, 0.0069230 secs
[0.015s][info][startuptime] Interpreter generation, 0.0005353 secs
[0.015s][info][startuptime] StubRoutines generation 2, 0.0001260 secs
[0.015s][info][startuptime] MethodHandles adapters generation, 0.0000212 secs
[0.015s][info][startuptime] Start VMThread, 0.0002540 secs
[0.024s][info][startuptime] Initialize java.lang classes, 0.0082586 secs
[0.024s][info][startuptime] Initialize java.lang.invoke classes, 0.0001236 secs
[0.031s][info][startuptime] Initialize module system, 0.0069481 secs
[0.032s][info][startuptime] Create VM, 0.0282268 secs

A geração do interpretador levou apenas 0,5 ms, portanto, mesmo reduzindo isso para zero, não teria impacto perceptível no tempo de inicialização. Lembro-me de quando o Java foi lançado e começou a se tornar popular. Um dos argumentos que as pessoas tinham contra a plataforma era que ela era muito lenta em comparação com as linguagens tradicionais compiladas estaticamente como C e C++. O que, naquela época, era. Como vimos, o interpretador só se preocupa com um único bytecode (ou possivelmente dois usando codelets de caminho rápido) por vez. Mesmo o uso de modelos não mudará esse fato. Um compilador estático analisa blocos maiores de código, como loops e métodos. Ele pode então otimizar o código gerado usando técnicas como desdobramento de loop e métodos incorporados. O intérprete nunca será capaz de igualar este nível de desempenho. Para resolver isso sem sacrificar o “Write once, run anywhere”, Java adotou o uso de compilação adaptável just-in-time (JIT). No próximo post desta série, veremos as ideias básicas da compilação JIT e examinaremos como a JVM decide quais métodos compilar e quando fazê-lo.

Referências

Artigo original em inglês.


Comentários

Postagens mais visitadas deste blog

Java Records

  Java Records Imutável, Simples e limpa Esta funcionalidade da linguagem apareceu pela primeira vez na versão 14 como experimental e assim continuou até a versão 15 . Agora liberada de forma definitiva no Java 16 . O objetivo é ser possível ter classes que atuam como portadores transparentes de dados imutáveis. Os registros podem ser considerados tuplas nominais. Ou seja, após criado, um record não pode mais ser alterado. Records oferece uma uma sintaxe compacta para declarar classes que são portadores transparentes para dados imutáveis superficiais visando reduzir significamente o detalhamento dessas classes e irá melhorar a capacidade de leitura e manutenção do código. Vamos seguir um exemplo de uma classe chamada Pessoa . O primeiro exemplo vamos utilizar o modo tradicional. public class Pessoa { private String nome; private int idade; public Pessoa (String nome, int idade) { super (); this .nome = nome; this .idade = idade; } public String getNo

Java 8 ao 18: Mudanças mais importantes na plataforma Java

    Vamos rever muitas das mudanças mais importantes na plataforma Java que aconteceram entre a versão 8 (2014) e 18 (2022)   O Java 8 foi lançado em março de 2014 e o Java 18 em março de 2022. São 8 anos de progresso, 203 JEPs (JDK Enhancement Proposals ), entre essas duas versões. Neste post, revisaremos as mudanças mais importantes e discutiremos os benefícios e desafios da adoção de versões mais recentes do JDK para novos aplicativos e para os mais antigos compilados com versões mais antigas. Desde a versão 9, o Java tem novos recursos a cada 6 meses e é muito difícil acompanhar essas novas mudanças. A maioria das informações na internet descreve as mudanças entre as duas últimas versões do Java. No entanto, se você estiver em uma situação semelhante à minha, não está usando uma das versões mais recentes do Java, mas uma das várias versões anteriores (Geralmente 8 ou 11 que são as versões de suporte estendido). Então é útil saber quais novos recursos foram adicionados d

O suporte de longo prazo e o que o LTS significa para o ecossistema Java

A arte do suporte de longo prazo e o que o LTS significa para o ecossistema Java Aqui está o que o Java 17 tem em comum com o Java 11 e o Java 8. Em junho de 2018, há pouco mais de três anos, a Oracle e outros participantes do ecossistema Java anunciaram uma mudança no modelo de cadência de lançamento para Java SE. Em vez de ter um lançamento principal planejado a cada dois ou quatro anos (que geralmente se torna de três a quatro anos), um novo modelo de lançamento de recursos de seis meses seria usado: a cada três anos, um lançamento seria designado como Long-Term Support (LTS) e receba apenas atualizações trimestrais de segurança, estabilidade e desempenho. Esse padrão foi emprestado descaradamente do modelo de lançamento do Mozilla Firefox, mas o ajustou para ficar mais alinhado com os requisitos de uma plataforma de desenvolvimento. A primeira versão do Java lançada sob esse modelo foi o Java SE 11. O lançamento do Java SE 17, o segundo lançamento do LTS sob o novo