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:
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).
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:
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
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
Postar um comentário