Suje suas mãos com o Bytecode Scala JVM

Publicados: 2022-03-11

A linguagem Scala continuou a ganhar popularidade nos últimos anos, graças à sua excelente combinação de princípios de desenvolvimento de software funcional e orientado a objetos e sua implementação em cima da comprovada Java Virtual Machine (JVM).

Embora Scala compile para bytecode Java, ele foi projetado para melhorar muitas das deficiências percebidas da linguagem Java. Oferecendo suporte de programação funcional completo, a sintaxe central do Scala contém muitas estruturas implícitas que precisam ser construídas explicitamente por programadores Java, algumas envolvendo uma complexidade considerável.

A criação de uma linguagem que compila em bytecode Java requer uma compreensão profunda do funcionamento interno da Java Virtual Machine. Para apreciar o que os desenvolvedores do Scala conseguiram, é necessário ir por baixo do capô e explorar como o código-fonte do Scala é interpretado pelo compilador para produzir bytecode JVM eficiente e eficaz.

Vamos dar uma olhada em como tudo isso é implementado.

Pré-requisitos

A leitura deste artigo requer alguma compreensão básica do bytecode da Java Virtual Machine. A especificação completa da máquina virtual pode ser obtida na documentação oficial da Oracle. Ler toda a especificação não é fundamental para entender este artigo, portanto, para uma rápida introdução ao básico, preparei um pequeno guia na parte inferior do artigo.

Clique aqui para ler um curso intensivo sobre noções básicas de JVM.

Um utilitário é necessário para desmontar o bytecode Java para reproduzir os exemplos fornecidos abaixo e prosseguir com uma investigação mais aprofundada. O Java Development Kit fornece seu próprio utilitário de linha de comando, javap , que usaremos aqui. Uma rápida demonstração de como o javap funciona está incluída no guia na parte inferior.

E, claro, uma instalação funcional do compilador Scala é necessária para os leitores que desejam acompanhar os exemplos. Este artigo foi escrito usando Scala 2.11.7. Diferentes versões do Scala podem produzir bytecodes ligeiramente diferentes.

Getters e Setters Padrão

Embora a convenção Java sempre forneça métodos getter e setter para atributos públicos, os programadores Java são obrigados a escrevê-los, apesar do fato de que o padrão para cada um não mudou em décadas. Scala, por outro lado, fornece getters e setters padrão.

Vejamos o seguinte exemplo:

 class Person(val name:String) { }

Vamos dar uma olhada dentro da classe Person . Se compilarmos este arquivo com scalac , então executar $ javap -p Person.class nos dará:

 Compiled from "Person.scala" public class Person { private final java.lang.String name; // field public java.lang.String name(); // getter method public Person(java.lang.String); // constructor }

Podemos ver que para cada campo da classe Scala, um campo e seu método getter são gerados. O campo é privado e final, enquanto o método é público.

Se substituirmos val por var na fonte Person e recompilarmos, o modificador final do campo será descartado e o método setter também será adicionado:

 Compiled from "Person.scala" public class Person { private java.lang.String name; // field public java.lang.String name(); // getter method public void name_$eq(java.lang.String); // setter method public Person(java.lang.String); // constructor }

Se qualquer val ou var for definido dentro do corpo da classe, o campo privado e os métodos de acesso correspondentes serão criados e inicializados adequadamente na criação da instância.

Observe que tal implementação dos campos val e var de nível de classe significa que, se algumas variáveis ​​forem usadas no nível de classe para armazenar valores intermediários e nunca forem acessadas diretamente pelo programador, a inicialização de cada campo adicionará um ou dois métodos ao pegada de classe. Adicionar um modificador private para esses campos não significa que os acessadores correspondentes serão descartados. Eles apenas se tornarão privados.

Definições de Variáveis ​​e Funções

Vamos supor que temos um método, m() , e criamos três referências diferentes no estilo Scala para esta função:

 class Person(val name:String) { def m(): Int = { // ... return 0 } val m1 = m var m2 = m def m3 = m }

Como cada uma dessas referências a m é construída? Quando m é executado em cada caso? Vamos dar uma olhada no bytecode resultante. A saída a seguir mostra os resultados de javap -v Person.class (omitindo muitas saídas supérfluas):

 Constant pool: #22 = Fieldref #2.#21 // Person.m1:I #24 = Fieldref #2.#23 // Person.m2:I #30 = Methodref #2.#29 // Person.m:()I #35 = Methodref #4.#34 // java/lang/Object."<init>":()V // ... public int m(); Code: // other methods refer to this method // ... public int m1(); Code: // get the value of field m1 and return it 0: aload_0 1: getfield #22 // Field m1:I 4: ireturn public int m2(); Code: // get the value of field m2 and return it 0: aload_0 1: getfield #24 // Field m2:I 4: ireturn public void m2_$eq(int); Code: // get the value of this method's input argument 0: aload_0 1: iload_1 // write it to the field m2 and return 2: putfield #24 // Field m2:I 5: return public int m3(); Code: // execute the instance method m(), and return 0: aload_0 1: invokevirtual #30 // Method m:()I 4: ireturn public Person(java.lang.String); Code: // instance constructor ... // execute the instance method m(), and write the result to field m1 9: aload_0 10: aload_0 11: invokevirtual #30 // Method m:()I 14: putfield #22 // Field m1:I // execute the instance method m(), and write the result to field m2 17: aload_0 18: aload_0 19: invokevirtual #30 // Method m:()I 22: putfield #24 // Field m2:I 25: return

No pool de constantes, vemos que a referência ao método m() é armazenada no índice #30 . No código do construtor, vemos que este método é invocado duas vezes durante a inicialização, com a instrução invokevirtual #30 aparecendo primeiro no byte offset 11, depois no offset 19. A primeira invocação é seguida pela instrução putfield #22 que atribui o resultado de este método para o campo m1 , referenciado pelo índice #22 no conjunto constante. A segunda invocação é seguida pelo mesmo padrão, desta vez atribuindo o valor ao campo m2 , indexado em #24 no conjunto constante.

Em outras palavras, atribuir um método a uma variável definida com val ou var apenas atribui o resultado do método a essa variável. Podemos ver que os métodos m1() e m2() que são criados são simplesmente getters para essas variáveis. No caso de var m2 , também vemos que é criado o setter m2_$eq(int) , que se comporta como qualquer outro setter, sobrescrevendo o valor do campo.

No entanto, usar a palavra-chave def fornece um resultado diferente. Em vez de buscar um valor de campo para retornar, o método m3() também inclui a instrução invokevirtual #30 . Ou seja, cada vez que esse método é chamado, ele chama m() e retorna o resultado desse método.

Então, como podemos ver, Scala fornece três maneiras de trabalhar com campos de classe, e estas são facilmente especificadas através das palavras-chave val , var e def . Em Java, teríamos que implementar explicitamente os setters e getters necessários, e esse código clichê escrito manualmente seria muito menos expressivo e mais propenso a erros.

Valores preguiçosos

Um código mais complicado é produzido ao declarar um valor lento. Suponha que adicionamos o seguinte campo à classe definida anteriormente:

 lazy val m4 = m

A execução javap -p -v Person.class agora revelará o seguinte:

 Constant pool: #20 = Fieldref #2.#19 // Person.bitmap$0:Z #23 = Methodref #2.#22 // Person.m:()I #25 = Fieldref #2.#24 // Person.m4:I #31 = Fieldref #27.#30 // scala/runtime/BoxedUnit.UNIT:Lscala/runtime/BoxedUnit; #48 = Methodref #2.#47 // Person.m4$lzycompute:()I // ... private volatile boolean bitmap$0; private int m4$lzycompute(); Code: // lock the thread 0: aload_0 1: dup 2: astore_1 3: monitorenter // check the flag for whether this field has already been set 4: aload_0 5: getfield #20 // Field bitmap$0:Z // if it has, skip to position 24 (unlock the thread and return) 8: ifne 24 // if it hasn't, execute the method m() 11: aload_0 12: aload_0 13: invokevirtual #23 // Method m:()I // write the method to the field m4 16: putfield #25 // Field m4:I // set the flag indicating the field has been set 19: aload_0 20: iconst_1 21: putfield #20 // Field bitmap$0:Z // unlock the thread 24: getstatic #31 // Field scala/runtime/BoxedUnit.UNIT:Lscala/runtime/BoxedUnit; 27: pop 28: aload_1 29: monitorexit // get the value of field m4 and return it 30: aload_0 31: getfield #25 // Field m4:I 34: ireturn // ... public int m4(); Code: // check the flag for whether this field has already been set 0: aload_0 1: getfield #20 // Field bitmap$0:Z // if it hasn't, skip to position 14 (invoke lazy method and return) 4: ifeq 14 // if it has, get the value of field m4, then skip to position 18 (return) 7: aload_0 8: getfield #25 // Field m4:I 11: goto 18 // execute the method m4$lzycompute() to set the field 14: aload_0 15: invokespecial #48 // Method m4$lzycompute:()I // return 18: ireturn

Neste caso, o valor do campo m4 não é calculado até que seja necessário. O método especial e privado m4$lzycompute() é produzido para calcular o valor lento e o campo bitmap$0 para rastrear seu estado. O método m4() verifica se o valor deste campo é 0, indicando que m4 ainda não foi inicializado, nesse caso m4$lzycompute() é invocado, preenchendo m4 e retornando seu valor. Esse método privado também define o valor de bitmap$0 como 1, de modo que na próxima vez que m4() for chamado, ele pulará a chamada do método de inicialização e, em vez disso, simplesmente retornará o valor de m4 .

Os resultados da primeira chamada para um valor Scala lento.

O bytecode que Scala produz aqui foi projetado para ser seguro e eficaz para threads. Para ser thread-safe, o método lazy compute usa o par de instruções monitorenter / monitorexit . O método permanece eficaz, pois a sobrecarga de desempenho dessa sincronização ocorre apenas na primeira leitura do valor lento.

Apenas um bit é necessário para indicar o estado do valor lento. Portanto, se não houver mais de 32 valores preguiçosos, um único campo int poderá rastrear todos eles. Se mais de um valor lento for definido no código-fonte, o bytecode acima será modificado pelo compilador para implementar uma máscara de bits para essa finalidade.

Novamente, Scala nos permite tirar vantagem de um tipo específico de comportamento que teria que ser implementado explicitamente em Java, economizando esforço e reduzindo o risco de erros de digitação.

Função como valor

Agora vamos dar uma olhada no seguinte código-fonte Scala:

 class Printer(val output: String => Unit) { } object Hello { def main(arg: Array[String]) { val printer = new Printer( s => println(s) ); printer.output("Hello"); } }

A classe Printer possui um campo, output , com o tipo String => Unit : uma função que recebe uma String e retorna um objeto do tipo Unit (semelhante a void em Java). No método main, criamos um desses objetos e atribuímos a esse campo uma função anônima que imprime uma determinada string.

A compilação deste código gera quatro arquivos de classe:

O código-fonte é compilado em quatro arquivos de classe.

Hello.class é uma classe wrapper cujo método main simplesmente chama Hello$.main() :

 public final class Hello // ... public static void main(java.lang.String[]); Code: 0: getstatic #16 // Field Hello$.MODULE$:LHello$; 3: aload_0 4: invokevirtual #18 // Method Hello$.main:([Ljava/lang/String;)V 7: return

O oculto Hello$.class contém a implementação real do método main. Para dar uma olhada em seu bytecode, certifique-se de que você escape corretamente $ de acordo com as regras do seu shell de comando, para evitar sua interpretação como caractere especial:

 public final class Hello$ // ... public void main(java.lang.String[]); Code: // initialize Printer and anonymous function 0: new #16 // class Printer 3: dup 4: new #18 // class Hello$$anonfun$1 7: dup 8: invokespecial #19 // Method Hello$$anonfun$1."<init>":()V 11: invokespecial #22 // Method Printer."<init>":(Lscala/Function1;)V 14: astore_2 // load the anonymous function onto the stack 15: aload_2 16: invokevirtual #26 // Method Printer.output:()Lscala/Function1; // execute the anonymous function, passing the string "Hello" 19: ldc #28 // String Hello 21: invokeinterface #34, 2 // InterfaceMethod scala/Function1.apply:(Ljava/lang/Object;)Ljava/lang/Object; // return 26: pop 27: return

O método cria um Printer . Em seguida, ele cria um Hello$$anonfun$1 , que contém nossa função anônima s => println(s) . A Printer é inicializada com este objeto como campo de output . Este campo é então carregado na pilha e executado com o operando "Hello" .

Vamos dar uma olhada na classe de função anônima, Hello$$anonfun$1.class , abaixo. Podemos ver que ele estende a Function1 de Scala (como AbstractFunction1 ) implementando o método apply() . Na verdade, ele cria dois métodos apply() , um envolvendo o outro, que juntos realizam a verificação de tipo (neste caso, que a entrada é uma String ) e executam a função anônima (imprimindo a entrada com println() ).

 public final class Hello$$anonfun$1 extends scala.runtime.AbstractFunction1<java.lang.String, scala.runtime.BoxedUnit> implements scala.Serializable // ... // Takes an argument of type String. Invoked second. public final void apply(java.lang.String); Code: // execute Scala's built-in method println(), passing the input argument 0: getstatic #25 // Field scala/Predef$.MODULE$:Lscala/Predef$; 3: aload_1 4: invokevirtual #29 // Method scala/Predef$.println:(Ljava/lang/Object;)V 7: return // Takes an argument of type Object. Invoked first. public final java.lang.Object apply(java.lang.Object); Code: 0: aload_0 // check that the input argument is a String (throws exception if not) 1: aload_1 2: checkcast #36 // class java/lang/String // invoke the method apply( String ), passing the input argument 5: invokevirtual #38 // Method apply:(Ljava/lang/String;)V // return the void type 8: getstatic #44 // Field scala/runtime/BoxedUnit.UNIT:Lscala/runtime/BoxedUnit; 11: areturn

Olhando para o método Hello$.main() acima, podemos ver que, no deslocamento 21, a execução da função anônima é acionada por uma chamada ao seu método apply( Object ) .

Finalmente, para completar, vejamos o bytecode para Printer.class :

 public class Printer // ... // field private final scala.Function1<java.lang.String, scala.runtime.BoxedUnit> output; // field getter public scala.Function1<java.lang.String, scala.runtime.BoxedUnit> output(); Code: 0: aload_0 1: getfield #14 // Field output:Lscala/Function1; 4: areturn // constructor public Printer(scala.Function1<java.lang.String, scala.runtime.BoxedUnit>); Code: 0: aload_0 1: aload_1 2: putfield #14 // Field output:Lscala/Function1; 5: aload_0 6: invokespecial #21 // Method java/lang/Object."<init>":()V 9: return

Podemos ver que a função anônima aqui é tratada como qualquer variável val . Ele é armazenado no campo de classe output e o getter output() é criado. A única diferença é que esta variável deve agora implementar a interface Scala scala.Function1 (o que AbstractFunction1 faz).

Assim, o custo deste elegante recurso Scala são as classes utilitárias subjacentes, criadas para representar e executar uma única função anônima que pode ser usada como um valor. Você deve levar em consideração o número de tais funções, bem como os detalhes de sua implementação de VM, para descobrir o que isso significa para seu aplicativo específico.

Entrando nos bastidores com Scala: explore como essa linguagem poderosa é implementada no bytecode da JVM.
Tweet

Características de escala

As características do Scala são semelhantes às interfaces em Java. A característica a seguir define duas assinaturas de método e fornece uma implementação padrão da segunda. Vejamos como é implementado:

 trait Similarity { def isSimilar(x: Any): Boolean def isNotSimilar(x: Any): Boolean = !isSimilar(x) } 

O código-fonte é compilado em dois arquivos de classe.

Duas entidades são produzidas: Similarity.class , a interface que declara os dois métodos, e a classe sintética, Similarity$class.class , fornecendo a implementação padrão:

 public interface Similarity { public abstract boolean isSimilar(java.lang.Object); public abstract boolean isNotSimilar(java.lang.Object); }
 public abstract class Similarity$class public static boolean isNotSimilar(Similarity, java.lang.Object); Code: 0: aload_0 // execute the instance method isSimilar() 1: aload_1 2: invokeinterface #13, 2 // InterfaceMethod Similarity.isSimilar:(Ljava/lang/Object;)Z // if the returned value is 0, skip to position 14 (return with value 1) 7: ifeq 14 // otherwise, return with value 0 10: iconst_0 11: goto 15 // return the value 1 14: iconst_1 15: ireturn public static void $init$(Similarity); Code: 0: return

Quando uma classe implementa essa característica e chama o método isNotSimilar , o compilador Scala gera a instrução de bytecode invokestatic para chamar o método estático fornecido pela classe acompanhante.

Polimorfismo complexo e estruturas de herança podem ser criadas a partir de características. Por exemplo, várias características, bem como a classe de implementação, podem substituir um método com a mesma assinatura, chamando super.methodName() para passar o controle para a próxima característica. Quando o compilador Scala encontra essas chamadas, ele:

  • Determina qual característica exata é assumida por esta chamada.
  • Determina o nome da classe acompanhante que fornece bytecode de método estático definido para a característica.
  • Produz a instrução invokestatic necessária.

Assim, podemos ver que o poderoso conceito de traits é implementado no nível da JVM de forma que não leve a uma sobrecarga significativa, e os programadores Scala podem aproveitar esse recurso sem se preocupar que será muito caro em tempo de execução.

Singletons

Scala fornece a definição explícita de classes singleton usando a palavra-chave object . Vamos considerar a seguinte classe singleton:

 object Config { val home_dir = "/home/user" }

O compilador produz dois arquivos de classe:

O código-fonte é compilado em dois arquivos de classe.

Config.class é bem simples:

 public final class Config public static java.lang.String home_dir(); Code: // execute the method Config$.home_dir() 0: getstatic #16 // Field Config$.MODULE$:LConfig$; 3: invokevirtual #18 // Method Config$.home_dir:()Ljava/lang/String; 6: areturn

Este é apenas um decorador para a classe Config$ sintética que incorpora a funcionalidade do singleton. Examinar essa classe com javap -p -c produz o seguinte bytecode:

 public final class Config$ public static final Config$ MODULE$; // a public reference to the singleton object private final java.lang.String home_dir; // static initializer public static {}; Code: 0: new #2 // class Config$ 3: invokespecial #12 // Method "<init>":()V 6: return public java.lang.String home_dir(); Code: // get the value of field home_dir and return it 0: aload_0 1: getfield #17 // Field home_dir:Ljava/lang/String; 4: areturn private Config$(); Code: // initialize the object 0: aload_0 1: invokespecial #19 // Method java/lang/Object."<init>":()V // expose a public reference to this object in the synthetic variable MODULE$ 4: aload_0 5: putstatic #21 // Field MODULE$:LConfig$; // load the value "/home/user" and write it to the field home_dir 8: aload_0 9: ldc #23 // String /home/user 11: putfield #17 // Field home_dir:Ljava/lang/String; 14: return

Consiste no seguinte:

  • A variável sintética MODULE$ , por meio da qual outros objetos acessam esse objeto singleton.
  • O inicializador estático {} (também conhecido como <clinit> , o inicializador de classe) e o método privado Config$ , usado para inicializar MODULE$ e definir seus campos com valores padrão
  • Um método getter para o campo estático home_dir . Neste caso, é apenas um método. Se o singleton tiver mais campos, ele terá mais getters, bem como setters para campos mutáveis.

O singleton é um padrão de projeto popular e útil. A linguagem Java não fornece uma maneira direta de especificá-la no nível da linguagem; em vez disso, é responsabilidade do desenvolvedor implementá-lo no código-fonte Java. Scala, por outro lado, fornece uma maneira clara e conveniente de declarar um singleton explicitamente usando a palavra-chave object . Como podemos ver olhando sob o capô, ele é implementado de maneira acessível e natural.

Conclusão

Vimos agora como Scala compila vários recursos de programação implícita e funcional em sofisticadas estruturas de bytecode Java. Com este vislumbre do funcionamento interno de Scala, podemos obter uma apreciação mais profunda do poder de Scala, ajudando-nos a obter o máximo dessa linguagem poderosa.

Agora também temos as ferramentas para explorar a linguagem por conta própria. Existem muitos recursos úteis da sintaxe Scala que não são abordados neste artigo, como classes de caso, currying e compreensões de lista. Eu encorajo você a investigar a implementação dessas estruturas em Scala, para que você possa aprender como ser um ninja Scala de nível seguinte!


A máquina virtual Java: um curso intensivo

Assim como o compilador Java, o compilador Scala converte o código fonte em arquivos .class , contendo bytecode Java a ser executado pela Java Virtual Machine. Para entender como os dois idiomas diferem sob o capô, é necessário entender o sistema que ambos estão direcionando. Aqui, apresentamos uma breve visão geral de alguns dos principais elementos da arquitetura da Java Virtual Machine, estrutura de arquivos de classe e noções básicas de assembler.

Observe que este guia cobrirá apenas o mínimo para permitir o acompanhamento do artigo acima. Embora muitos componentes principais da JVM não sejam discutidos aqui, detalhes completos podem ser encontrados nos documentos oficiais, aqui.

Descompilando arquivos de classe com javap
Pool constante
Tabelas de Campos e Métodos
Bytecode JVM
Chamadas de método e a pilha de chamadas
Execução na pilha de operandos
Variáveis ​​locais
Voltar ao topo

Descompilando arquivos de classe com javap

O Java é fornecido com o utilitário de linha de comando javap , que descompila arquivos .class em um formato legível. Como os arquivos de classe Scala e Java visam a mesma JVM, o javap pode ser usado para examinar arquivos de classe compilados pelo Scala.

Vamos compilar o seguinte código-fonte:

 // RegularPolygon.scala class RegularPolygon( val numSides: Int ) { def getPerimeter( sideLength: Double ): Double = { println( "Calculating perimeter..." ) return sideLength * this.numSides } }

Compilar isso com scalac RegularPolygon.scala produzirá RegularPolygon.class . Se executarmos javap RegularPolygon.class veremos o seguinte:

 $ javap RegularPolygon.class Compiled from "RegularPolygon.scala" public class RegularPolygon { public int numSides(); public double getPerimeter(double); public RegularPolygon(int); }

Este é um detalhamento muito simples do arquivo de classe que simplesmente mostra os nomes e tipos dos membros públicos da classe. Adicionar a opção -p incluirá membros privados:

 $ javap -p RegularPolygon.class Compiled from "RegularPolygon.scala" public class RegularPolygon { private final int numSides; public int numSides(); public double getPerimeter(double); public RegularPolygon(int); }

Isso ainda não é muita informação. Para ver como os métodos são implementados no bytecode Java, vamos adicionar a opção -c :

 $ javap -p -c RegularPolygon.class Compiled from "RegularPolygon.scala" public class RegularPolygon { private final int numSides; public int numSides(); Code: 0: aload_0 1: getfield #13 // Field numSides:I 4: ireturn public double getPerimeter(double); Code: 0: getstatic #23 // Field scala/Predef$.MODULE$:Lscala/Predef$; 3: ldc #25 // String Calculating perimeter... 5: invokevirtual #29 // Method scala/Predef$.println:(Ljava/lang/Object;)V 8: dload_1 9: aload_0 10: invokevirtual #31 // Method numSides:()I 13: i2d 14: dmul 15: dreturn public RegularPolygon(int); Code: 0: aload_0 1: iload_1 2: putfield #13 // Field numSides:I 5: aload_0 6: invokespecial #38 // Method java/lang/Object."<init>":()V 9: return }

Isso é um pouco mais interessante. No entanto, para realmente obter toda a história, devemos usar a opção -v ou -verbose , como em javap -p -v RegularPolygon.class :

O conteúdo completo de um arquivo de classe Java.

Aqui finalmente vemos o que realmente está no arquivo de classe. O que tudo isso significa? Vamos dar uma olhada em algumas das partes mais importantes.

Pool constante

O ciclo de desenvolvimento para aplicativos C++ inclui estágios de compilação e vinculação. O ciclo de desenvolvimento para Java ignora um estágio de ligação explícita porque a ligação acontece em tempo de execução. O arquivo de classe deve oferecer suporte a essa vinculação de tempo de execução. Isso significa que quando o código-fonte se refere a qualquer campo ou método, o bytecode resultante deve manter as referências relevantes em forma simbólica, prontas para serem desreferenciadas assim que o aplicativo for carregado na memória e os endereços reais puderem ser resolvidos pelo vinculador de tempo de execução. Este formulário simbólico deve conter:

  • nome da classe
  • nome do campo ou método
  • tipo de informação

A especificação de formato de arquivo de classe inclui uma seção do arquivo chamada de pool constante , uma tabela de todas as referências necessárias para o vinculador. Ele contém entradas de diferentes tipos.

 // ... Constant pool: #1 = Utf8 RegularPolygon #2 = Class #1 // RegularPolygon #3 = Utf8 java/lang/Object #4 = Class #3 // java/lang/Object // ...

O primeiro byte de cada entrada é uma etiqueta numérica que indica o tipo de entrada. Os bytes restantes fornecem informações sobre o valor da entrada. O número de bytes e as regras para sua interpretação dependem do tipo indicado pelo primeiro byte.

Por exemplo, uma classe Java que usa um inteiro constante 365 pode ter uma entrada de pool constante com o seguinte bytecode:

 x03 00 00 01 6D

O primeiro byte, x03 , identifica o tipo de entrada, CONSTANT_Integer . Isso informa ao vinculador que os próximos quatro bytes contêm o valor do inteiro. (Observe que 365 em hexadecimal é x16D ). Se esta for a 14ª entrada no pool constante, javap -v irá renderizá-la assim:

 #14 = Integer 365

Muitos tipos de constantes são compostos de referências a tipos de constantes mais “primitivos” em outras partes do conjunto de constantes. Por exemplo, nosso código de exemplo contém a instrução:

 println( "Calculating perimeter..." )

O uso de uma constante de string produzirá duas entradas no pool de constantes: uma entrada com o tipo CONSTANT_String e outra entrada do tipo CONSTANT_Utf8 . A entrada do tipo Constant_UTF8 contém a representação UTF8 real do valor da string. A entrada do tipo CONSTANT_String contém uma referência à entrada CONSTANT_Utf8 :

 #24 = Utf8 Calculating perimeter... #25 = String #24 // Calculating perimeter...

Essa complicação é necessária porque existem outros tipos de entradas de pool constantes que se referem a entradas do tipo Utf8 e que não são entradas do tipo String . Por exemplo, qualquer referência a um atributo de classe produzirá um tipo CONSTANT_Fieldref , que contém uma série de referências ao nome da classe, nome do atributo e tipo de atributo:

 #1 = Utf8 RegularPolygon #2 = Class #1 // RegularPolygon #9 = Utf8 numSides #10 = Utf8 I #12 = NameAndType #9:#10 // numSides:I #13 = Fieldref #2.#12 // RegularPolygon.numSides:I

Para obter mais detalhes sobre o conjunto de constantes, consulte a documentação da JVM.

Tabelas de Campos e Métodos

Um arquivo de classe contém uma tabela de campos que contém informações sobre cada campo (ou seja, atributo) definido na classe. Essas são referências a entradas de pool constantes que descrevem o nome e o tipo do campo, bem como sinalizadores de controle de acesso e outros dados relevantes.

Uma tabela de métodos semelhante está presente no arquivo de classe. No entanto, além das informações de nome e tipo, para cada método não abstrato, ele contém as instruções de bytecode reais a serem executadas pela JVM, bem como as estruturas de dados usadas pelo quadro de pilha do método, descritas a seguir.

Bytecode JVM

A JVM usa seu próprio conjunto de instruções interno para executar o código compilado. A execução do javap com a opção -c inclui as implementações do método compilado na saída. Se examinarmos nosso arquivo RegularPolygon.class dessa maneira, veremos a seguinte saída para nosso método getPerimeter() :

 public double getPerimeter(double); Code: 0: getstatic #23 // Field scala/Predef$.MODULE$:Lscala/Predef$; 3: ldc #25 // String Calculating perimeter... 5: invokevirtual #29 // Method scala/Predef$.println:(Ljava/lang/Object;)V 8: dload_1 9: aload_0 10: invokevirtual #31 // Method numSides:()I 13: i2d 14: dmul 15: dreturn

O bytecode real pode ser algo assim:

 xB2 00 17 x12 19 xB6 00 1D x27 ...

Cada instrução começa com um opcode de um byte identificando a instrução JVM, seguido por zero ou mais operandos de instrução a serem operados, dependendo do formato da instrução específica. Normalmente, são valores constantes ou referências ao conjunto de constantes. javap traduz de maneira útil o bytecode em um formato legível por humanos exibindo:

  • O deslocamento ou posição do primeiro byte da instrução dentro do código.
  • O nome legível por humanos, ou mnemônico , da instrução.
  • O valor do operando, se houver.

Operandos que são exibidos com um sinal de sustenido, como #23 , são referências a entradas no conjunto de constantes. Como podemos ver, javap também produz comentários úteis na saída, identificando o que exatamente está sendo referenciado no pool.

Discutiremos algumas das instruções comuns abaixo. Para obter informações detalhadas sobre o conjunto completo de instruções da JVM, consulte a documentação.

Chamadas de método e a pilha de chamadas

Cada chamada de método deve poder ser executada com seu próprio contexto, que inclui coisas como variáveis ​​declaradas localmente ou argumentos que foram passados ​​para o método. Juntos, eles formam um quadro de pilha . Após a invocação de um método, um novo quadro é criado e colocado no topo da pilha de chamadas . Quando o método retorna, o quadro atual é removido da pilha de chamadas e descartado, e o quadro que estava em vigor antes da chamada do método é restaurado.

Um quadro de pilha inclui algumas estruturas distintas. Dois importantes são a pilha de operandos e a tabela de variáveis ​​locais , discutidas a seguir.

A pilha de chamadas da JVM.

Execução na pilha de operandos

Muitas instruções JVM operam na pilha de operandos de seus quadros. Em vez de especificar um operando constante explicitamente no bytecode, essas instruções recebem os valores no topo da pilha de operandos como entrada. Normalmente, esses valores são removidos da pilha no processo. Algumas instruções também colocam novos valores no topo da pilha. Dessa forma, as instruções da JVM podem ser combinadas para realizar operações complexas. Por exemplo, a expressão:

 sideLength * this.numSides

é compilado para o seguinte em nosso método getPerimeter() :

 8: dload_1 9: aload_0 10: invokevirtual #31 // Method numSides:()I 13: i2d 14: dmul 

As instruções da JVM podem operar na pilha de operandos para executar funções complexas.

  • A primeira instrução, dload_1 , empurra a referência de objeto do slot 1 da tabela de variáveis ​​locais (discutida a seguir) para a pilha de operandos. Neste caso, este é o argumento do método sideLength .- A próxima instrução, aload_0 , empurra a referência do objeto no slot 0 da tabela de variáveis ​​locais para a pilha de operandos. Na prática, essa é quase sempre a referência a this , a classe atual.
  • Isso configura a pilha para a próxima chamada, invokevirtual #31 , que executa o método de instância numSides() . invokevirtual o operando superior (a referência a this ) da pilha para identificar de qual classe ele deve chamar o método. Uma vez que o método retorna, seu resultado é colocado na pilha.
  • Nesse caso, o valor retornado ( numSides ) está no formato inteiro. Ele deve ser convertido para um formato de ponto flutuante duplo para multiplicá-lo por outro valor duplo. A instrução i2d o valor inteiro da pilha, converte-o para o formato de ponto flutuante e o empurra de volta para a pilha.
  • Neste ponto, a pilha contém o resultado de ponto flutuante de this.numSides na parte superior, seguido pelo valor do argumento sideLength que foi passado para o método. dmul esses dois valores superiores da pilha, executa a multiplicação de ponto flutuante neles e envia o resultado para a pilha.

Quando um método é chamado, uma nova pilha de operandos é criada como parte de seu quadro de pilha, onde as operações serão executadas. We must be careful with terminology here: the word “stack” may refer to the call stack , the stack of frames providing context for method execution, or to a particular frame's operand stack , upon which JVM instructions operate.

Local Variables

Each stack frame keeps a table of local variables . This typically includes a reference to this object, any arguments that were passed when the method was called, and any local variables declared within the method body. Running javap with the -v option will include information about how each method's stack frame should be set up, including its local variable table:

 public double getPerimeter(double); // ... Code: 0: getstatic #23 // Field scala/Predef$.MODULE$:Lscala/Predef$; 3: ldc #25 // String Calculating perimeter... // ... LocalVariableTable: Start Length Slot Name Signature 0 16 0 this LRegularPolygon; 0 16 1 sideLength D

In this example, there are two local variables. The variable in slot 0 is named this , with the type RegularPolygon . This is the reference to the method's own class. The variable in slot 1 is named sideLength , with the type D (indicating a double). This is the argument that is passed to our getPerimeter() method.

Instructions such as iload_1 , fstore_2 , or aload [n] , transfer different types of local variables between the operand stack and the local variable table. Since the first item in the table is usually the reference to this , the instruction aload_0 is commonly seen in any method that operates on its own class.

This concludes our walkthrough of JVM basics.

Relacionado: Reduzir o código do Boilerplate com macros Scala e quase aspas