Pular para o conteúdo principal

Compreendendo a compilação Java

 


Compreendendo a compilação Java: de bytecodes a código de máquina na JVM


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


Para a plataforma Java, a compilação é diferente de muitas outras linguagens por causa da Java Virtual Machine (JVM). Para executar um aplicativo com a JVM, o código Java é compilado em um conjunto de arquivos de classe que contém instruções para a JVM, não o sistema operacional e o hardware em que a JVM está instalada. Isso fornece o recurso Write Once, Run Anywhere, pelo qual o Java é famoso.


Como acontece essa conversão de instruções de máquina virtual para instruções nativas?


Esta não é uma pergunta simples de responder, então decidi escrever uma série de posts explorando os diferentes aspectos de interpretação e compilação adaptativa dentro da JVM.


Vamos começar com alguns conceitos fundamentais que desenvolvemos no restante da série do blog.


Código fonte

O que é Código Fonte?


O código-fonte são declarações e expressões de alto nível que os desenvolvedores escrevem para definir as instruções do aplicativo. Chamamos isso de alto nível porque esses tipos de linguagens de programação fornecem fortes abstrações do sistema operacional e do hardware usado para executar o aplicativo.


Exemplo de código-fonte


Como um exemplo simples, se quisermos somar os números de um a dez, poderíamos escrever isso em Java usando um loop, uma das construções fundamentais em muitas linguagens:


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);
  }
}


Isso oculta a complexidade de como um sistema operacional e um processador funcionam para os desenvolvedores. Por exemplo, podemos declarar uma variável inteira local e dar a ela um nome significativo, sum. Isso é mais simples para nós trabalharmos do que usar um endereço de memória explícito. Da mesma forma, podemos chamar um método na classe da biblioteca principal PrintStream por meio de uma referência por meio da classe System que imprimirá uma string em qualquer que seja a saída padrão para nosso aplicativo. Como isso aparece mágicamente como caracteres em um terminal, que é controlado por um gerenciador de janelas e é desenhado na tela por meio de uma placa gráfica, não é nossa preocupação.


No entanto, nosso código de alto nível precisa ser convertido em um conjunto de instruções numéricas e operandos que possam ser entendidos pela máquina na qual executamos a aplicação.


Para entender melhor o que está envolvido nessa conversão, podemos reescrever nosso exemplo Sum.java em uma linguagem de baixo nível. Ao contrário de uma linguagem de alto nível, isso não fornece abstrações, mas nos permite controlar o sistema operacional e o processador diretamente usando instruções que eles entendem.


Para este exemplo, vamos supor que vamos executar nosso aplicativo em uma máquina Linux com um processador x64.


Uma maneira de escrever a parte de loop do nosso aplicativo em linguagem assembly é mostrada abaixo. (Como veremos mais tarde, assim como em Java, existem várias maneiras de escrever esse código para fazer a mesma coisa).


section .text
global _start
_start:
  mov eax, 1
  mov ecx, 10
  xor edx, edx
L:
  add edx, eax
  inc eax
  dec ecx
  jnz L
EL:
  mov eax, 1
  mov ebx, 0
  int 0x80


Neste código, deixei de fora a parte que imprime o resultado no final; fazer isso em assembler requer muito mais código do que para o loop.


Como você pode ver, isso é consideravelmente menos legível do que em Java. Mas mesmo isso ainda é um pouco legível para os humanos. Se você entende a arquitetura básica do computador e o conjunto de instruções que está sendo usado, pode ver que a maior parte do trabalho envolve a manipulação de registradores e a execução de cálculos básicos. Tarefas mais complexas podem ser alcançadas por meio de chamadas de interrupção, como aquela no final em que usamos a interrupção 80H do Linux para invocar uma chamada de sistema para encerrar o aplicativo (sem a qual, como aprendi ao escrever este artigo, você obtém uma falha de segmentação) .


Mesmo isso é muito alto nível para o hardware do computador. O computador precisa apenas de um fluxo de palavras de vários bytes para entender qual instrução executar com quais operandos.


Usando um montador e um linker, podemos converter o código assembly em código objeto e um executável. Isso é gerado principalmente pelo mapeamento de instruções textuais como JNZ para o valor apropriado (neste caso, 0x75). Finalmente, acabamos com um arquivo que o sistema operacional pode executar.


Nosso arquivo executável fica assim quando despejado como uma série de valores hexadecimais:


...
0000160 0000 0020 0000 0000 0000 0000 0000 0000
0000200 01b8 0000 b900 000a 0000 d231 c201 c0ff
0000220 c9ff f875 01b8 0000 bb00 0000 0000 80cd
0000240 2e00 6873 7473 7472 6261 2e00 6574 7478
0000260 0000 0000 0000 0000 0000 0000 0000 0000
...


Nota: este não é o arquivo completo, apenas a parte de execução do loop.


No entanto, para nosso código Java de alto nível, não podemos mapear diretamente das instruções e expressões que usamos para instruções de máquina.


Para isso, devemos usar compilação.


Compilação Java

Genericamente, a compilação é o processo de traduzir o código-fonte em código de destino usando um compilador.


Como sabemos, a plataforma Java usa a JVM para executar aplicativos Java. No entanto, a JVM é um computador abstrato. A especificação JVM, que faz parte da especificação Java SE, define os recursos que toda JVM deve ter (o que a JVM deve fazer). No entanto, ele não especifica detalhes da implementação desses recursos (como a JVM faz essas coisas). Esta é a razão, por exemplo, pela qual há uma variedade de algoritmos de coleta de lixo disponíveis em diferentes implementações da JVM.


Parte da especificação da JVM é uma lista de bytecodes que definem o conjunto de instruções de nossa máquina virtual (abstrata). O nome bytecode vem do fato de que cada operando tem apenas um byte de tamanho. Dos 256 bytecodes possíveis, apenas 202 são usados (com mais três reservados para uso de implementação de JVM). Isso é incrível quando você pensa que o conjunto de instruções x86-64, que parece ser muito difícil de fornecer uma contagem precisa, é de aproximadamente mil.


Uma razão para a diferença significativa no tamanho do conjunto de instruções é que algumas das instruções da JVM executam tarefas complexas. Por exemplo, “invokevirtual”, que invoca um método de instância. A descrição desta instrução na especificação JVM tem cinco páginas. Outra razão é que a JVM não possui registradores explícitos e utiliza a pilha para quase todas as operações.


Como um aparte, vou adicionar algumas coisas interessantes que aprendi enquanto pesquisava este post. A primeira é que a implementação da Sun da JVM (que se tornou OpenJDK) costumava ter 25 _quick bytecodes adicionais. Estes foram usados apenas internamente como substitutos para bytecodes que se referiam a entradas de pool constantes. A outra é que os primeiros desenvolvedores da JVM tiveram uma premonição sobre o bytecode invokedynamic adicionado no JDK 7. O número de bytecode 186 foi o único valor que não foi usado inicialmente, e é precisamente onde o invokedynamic precisava ir.


Novamente, usando nosso exemplo Sum.java, podemos compilar isso com javac Sum.java. O JDK também inclui uma ferramenta útil, javap, um desmontador de arquivos de classe. Usando a opção -c, podemos imprimir os bytecodes em nosso arquivo de classe recém-compilado


public static void main(java.lang.String[]);
    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: getstatic     #7 // Field System.out:Ljava/io/PrintStream;
      23: iload_1
      24: invokevirtual #13 // Method PrintStream.println:(I)V
      27: return


Como mencionado anteriormente, a JVM é baseada em pilha, portanto, em bytecodes, precisamos de 13 instruções em comparação com 7 no montador x64. Os Bytecodes gastam muito mais tempo empurrando e estourando.


Na próxima postagem desta série, veremos como os bytecodes do nosso conjunto de instruções virtuais são convertidos nas instruções nativas da plataforma de computação subjacente, que é onde começa a verdadeira diversão.



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