Cozinhando o bytecode na cozinha da JVM

Meu nome é Alexander Kotsyuruba, lidero o desenvolvimento de serviços internos na DomKlik. Muitos desenvolvedores Java com experiência passam a entender a estrutura interna da JVM. Para facilitar essa jornada do Java Samurai, decidi apresentar o básico da Java Virtual Machine (JVM) e trabalhar com o bytecode em uma linguagem simples.

O que é um bytecode misterioso e onde ele mora?

Vou tentar responder a essa pergunta usando o exemplo de decapagem.



Por que preciso de uma JVM e bytecode?


A JVM se originou sob o slogan Write Once Run Anywhere (WORA) na Sun Microsystems. Ao contrário do conceito Write Once Compile Anywhere (WOCA) , o WORA implica uma máquina virtual para cada sistema operacional que executa o código compilado uma vez (bytecode).


Escrever uma vez executado em qualquer lugar (WORA)


Write Once Compile Anywhere (WOCA) A

JVM e o bytecode são a base do conceito WORA e nos salvam das nuances e da necessidade de compilar para cada sistema operacional.

Bytecode


Para entender o que é um bytecode, vejamos um exemplo. Obviamente, esse código não faz nada de útil, servirá apenas para análises posteriores.

Fonte:

class Solenya(val jarForPickles: Any? = Any(), var ingredientsCount: Int = 0) {


    /**
     *   
     *  @param ingredient -  
     */
    fun add(ingredient: Any) {
        ingredientsCount = ingredientsCount.inc()
        //- 
    }

    /**
     *   
     *  @param duration -   
     */
    fun warmUp(duration: Int) {
        for (x in 1..duration)
            println("Warming")
    }

    init {
        //   
        val jarForPickles = takeJarForPickles()
        // 
        val pickles = Any()
        // 
        val water = Any()

        //
        add(pickles)
        add(water)

        //
        warmUp(10)
    }

    /**
     *   
     */
    private fun takeJarForPickles(): Any = openLocker()

    /**
     *   
     */
    private fun openLocker(): Any = takeKeyForLocker()

    /**
     *     
     */
    private fun takeKeyForLocker(): Any = {}
}

Usando as ferramentas Intellij IDEA integradas ( Ferramentas -> Kotlin -> Mostrar Kotlin Bytecode ), obtemos um bytecode desmontado (apenas uma parte é mostrada no exemplo):

...
   INVOKEVIRTUAL java/io/PrintStream.println (Ljava/lang/Object;)V
   L5
   L6
    LINENUMBER 12 L6
    RETURN
   L7
    LOCALVARIABLE this Lcom/company/Solenya; L0 L7 0
    LOCALVARIABLE ingredient Ljava/lang/Object; L0 L7 1
    LOCALVARIABLE $i$f$add I L1 L7 2
    MAXSTACK = 2
    MAXLOCALS = 5

  // access flags 0x11
  public final warmUp(I)V
    // annotable parameter count: 1 (visible)
    // annotable parameter count: 1 (invisible)
   L0
    LINENUMBER 19 L0
    ICONST_1
    ISTORE 2
...

À primeira vista - um conjunto incompreensível de instruções. Para entender como e com o que eles trabalham, você precisará mergulhar na cozinha interna da JVM.

JVM Kitchen


Vejamos a memória de tempo de execução da JVM:



Podemos dizer que a JVM é a nossa cozinha. Em seguida, considere os participantes restantes:

Área de método - Livro de receitas



A área Método armazena o código compilado para cada função. Quando um thread começa a executar uma função, em geral, recebe instruções dessa área. Na verdade, é um livro de receitas culinárias que detalha como cozinhar tudo, desde ovos mexidos até a zarzuela catalã.

Tópico 1..N - Team Cooks



Os fluxos seguem rigorosamente as instruções prescritas por eles (área de método), para isso, possuem PC Register e JVM Stack. Você pode comparar cada fluxo com um cozinheiro que executa a tarefa dada a ele, seguindo exatamente as receitas do livro de receitas.

Registro de PC - Anotações de campo



Program Counter Register - o contador de comandos do nosso fluxo. Ele armazena o endereço da instrução que está sendo executada. Na cozinha, essas seriam algumas notas em que página do livro de receitas estamos agora.

Pilha de Jvm


Pilha de quadros. Um quadro é alocado para cada função, dentro da qual o encadeamento atual trabalha com variáveis ​​e operandos. Como parte da analogia com a preparação de nossos pickles, este poderia ser um conjunto de operações aninhadas:

-> -> -> ...

Frame - Desktop



A moldura atua como a área de trabalho do cozinheiro, na qual repousa uma placa de corte e recipientes assinados.

Variáveis ​​locais - Contêineres assinados



Essa é uma matriz de variáveis ​​locais (tabela de variáveis ​​locais) que, como o nome indica, armazena os valores, tipo e escopo das variáveis ​​locais. É semelhante aos contêineres assinados, onde você pode adicionar resultados intermediários da atividade profissional.

Pilha de operando - placa de corte



A pilha do operando armazena argumentos para instruções da JVM. Por exemplo, valores inteiros para a operação de adição, referências a objetos de pilha etc.

O exemplo mais próximo que posso dar é uma placa de corte na qual um tomate e pepino se transformam em salada em um ponto. Diferentemente das variáveis ​​locais, colocamos no quadro apenas o que executaremos na próxima instrução.

Heap - tabela de distribuição



Como parte do trabalho com o quadro, operamos em links para objetos; os próprios objetos são armazenados na pilha. Uma diferença importante é que o quadro pertence a apenas um encadeamento, e as variáveis ​​locais "ativam" enquanto o quadro está ativo (a função é executada). E o heap é acessível para outros fluxos e permanece até o coletor de lixo ser ativado. Por analogia com a cozinha, podemos dar um exemplo com uma tabela de distribuição, que por si só é comum. E é limpo por uma equipe separada de produtos de limpeza.

Cozinha JVM. Um olhar por dentro. Trabalhar com Moldura


Vamos começar com a função warmUp:

    /**
     *   
     *  @param duration -   
     */
    fun warmUp(duration: Int) {
        for (x in 1..duration)
            println("Warming...")
    }

Função bytecode desmontada:

  public final warmUp(I)V
    // annotable parameter count: 1 (visible)
    // annotable parameter count: 1 (invisible)
   L0
    LINENUMBER 19 L0
    ICONST_1
    ISTORE 2
    ILOAD 1
    ISTORE 3
    ILOAD 2
    ILOAD 3
    IF_ICMPGT L1
   L2
    LINENUMBER 20 L2
    LDC "Warming..."
    ASTORE 4
   L3
    ICONST_0
    ISTORE 5
   L4
    GETSTATIC java/lang/System.out : Ljava/io/PrintStream;
    ALOAD 4
    INVOKEVIRTUAL java/io/PrintStream.println (Ljava/lang/Object;)V
   L5
   L6
    LINENUMBER 19 L6
    ILOAD 2
    ILOAD 3
    IF_ICMPEQ L1
    IINC 2 1
   L7
    GOTO L2
   L1
    LINENUMBER 21 L1
    RETURN
   L8
    LOCALVARIABLE x I L2 L7 2
    LOCALVARIABLE this Lcom/company/Solenya; L0 L8 0
    LOCALVARIABLE duration I L0 L8 1
    MAXSTACK = 2
    MAXLOCALS = 6

Inicialização de estrutura - Preparação para o local de trabalho


Para executar esta função, um quadro será criado no fluxo da pilha da JVM. Deixe-me lembrá-lo de que a pilha consiste em uma matriz de variáveis ​​locais e pilha de operandos.

  1. Para que possamos entender quanta memória alocar para esse quadro, o compilador forneceu meta-informações sobre esta função (explicação no comentário do código):

        MAXSTACK = 2 //    2*32bit
        MAXLOCALS = 6 //    6*32bit
    
  2. Também temos informações sobre alguns elementos da matriz de variáveis ​​locais:

        LOCALVARIABLE x I L2 L7 2 //  x  Int(I),      L2-L7   2
        LOCALVARIABLE this Lcom/company/Solenya; L0 L8 0
        LOCALVARIABLE duration I L0 L8 1
    
  3. Os argumentos da função ao inicializar o quadro caem em variáveis ​​locais. Neste exemplo, o valor da duração será gravado na matriz com o índice 1.

Assim, inicialmente o quadro ficará assim:


Comece a executar instruções


Para entender como o quadro funciona, basta armar-se com uma lista de instruções da JVM ( listagens de instruções do bytecode Java ) e percorrer o rótulo L0:

   L0
    LINENUMBER 19 L0 //     
    ICONST_1
    ISTORE 2
    ILOAD 1
    ISTORE 3
    ILOAD 2
    ILOAD 3
    IF_ICMPGT L1

ICONST_1 - add 1(Int) na pilha operando:



iStore 2 - valor puxar (o tipo Int) da pilha operando e grava em variáveis locais com o índice 2:



Estas duas operações pode ser interpretada de Java-código: int x = 1.

ILOAD 1 - carregar o valor de variáveis locais com índice 1 na pilha operando:



iStore 3 - valor puxar (o tipo Int) da pilha operando e escreve para as variáveis locais com um índice de 3:



Estas duas operações pode ser interpretada de Java-código: int var3 = duration.

ILOAD 2 - carrega um valor de variáveis ​​locais com o índice 2 na pilha de operandos.

ILOAD 3 - carregar valor de variáveis ​​locais com índice 3 na pilha de operandos:



IF_ICMPGT L1- instrução para comparar dois valores inteiros da pilha. Se o valor "inferior" for maior que o "superior", vá para o rótulo L1. Após executar esta instrução, a pilha ficará vazia.

Aqui está a aparência dessas linhas de bytecode Java:

      int x = 1;
      int var3 = duration;
      if (x > var3) {
         ....L1...

Descompilamos o código usando o Intellij IDEA no caminho Kotlin -> Java :

   public final void warmUp(int duration) {
      int x = 1;
      int var3 = duration;
      if (x <= duration) {
         while(true) {
            String var4 = "Warming";
            boolean var5 = false;
            System.out.println(var4);
            if (x == var3) {
               break;
            }
            ++x;
         }
      }
   }

Aqui você pode ver variáveis ​​não utilizadas ( var5) e a ausência de uma chamada de função println(). Não se preocupe, isso ocorre devido às especificidades da compilação de funções embutidas ( println()) e expressões lambda. Praticamente não haverá custos indiretos para a execução dessas instruções; além disso, o código morto será excluído graças ao JIT. Este é um tópico interessante, que deve ser dedicado a um artigo separado.

Fazendo uma analogia com uma cozinha, essa função pode ser descrita como uma tarefa para um cozinheiro "ferver água por 10 minutos". Além disso, nosso profissional em sua área:

  1. abre um livro de receitas (área de método);
  2. encontra instruções sobre como ferver água ( warmUp());
  3. prepara o local de trabalho, alocando uma placa quente (pilha de operandos) e recipientes (variáveis ​​locais) para armazenamento temporário de produtos.

Cozinha JVM. Um olhar por dentro. Trabalhar com Heap


Considere o código:

val pickles = Any()

Bytecode desmontado:

    NEW java/lang/Object
    DUP
    INVOKESPECIAL java/lang/Object.<init> ()V
    ASTORE 3

NEW java / lang / Object - alocação de memória para um objeto de classe Objectdo heap. O objeto em si não será colocado na pilha, mas um link para ele na pilha:


DUP - duplicação do elemento "top" da pilha. Um link é necessário para inicializar o objeto, o segundo para salvá-lo em variáveis ​​locais:


INVOKESPECIAL java / lang / Object. <init> () V - inicialização do objeto da classe correspondente ( Object) pelo link da pilha:


O ASTORE 3 é o último passo, salvando a referência ao objeto em variáveis ​​locais com o índice 3.

Fazendo uma analogia com a cozinha, eu compararia a criação de um objeto de classe com a culinária em uma tabela compartilhada (pilha). Para fazer isso, você precisa alocar espaço suficiente na tabela de distribuição, retornar ao local de trabalho e enviar uma nota com o endereço (referência) no contêiner apropriado (variáveis ​​locais). E somente depois disso comece a criar um objeto da classe.

Cozinha JVM. Um olhar por dentro. Multithreading


Agora considere este exemplo:

    fun add(ingredient: Any) {
        ingredientsCount = ingredientsCount.inc()
        //- 
    }

Este é um exemplo clássico do problema de encadeamento. Temos uma contagem de ingredientes ingredientsCount. Uma função add, além de adicionar um ingrediente, executa um incremento ingredientsCount.

Um bytecode desmontado fica assim:

    ALOAD 0
    ALOAD 0
    GETFIELD com/company/Solenya.ingredientsCount : I
    ICONST_1
    IADD
    PUTFIELD com/company/Solenya.ingredientsCount : I

O estado de nossa pilha de operandos conforme as instruções são executadas:


Ao trabalhar em um thread, tudo será executado corretamente. Se houver vários threads, o seguinte problema pode ocorrer. Imagine que os dois threads simultaneamente tenham o valor do campo ingredientsCounte o tenham gravado na pilha. Em seguida, o estado da pilha de operandos e o campo ingredientsCountpodem ficar assim:


A função foi executada duas vezes (uma vez por cada thread) e o valor ingredientsCountdeve ser igual a 2. Mas, na verdade, um dos threads trabalhou com um valor desatualizado ingredientsCounte, portanto, o resultado real é 1 (problema de atualização perdida).

A situação é semelhante ao trabalho paralelo de uma equipe de chefs que adicionam especiarias ao prato. Imagine:

  1. Há uma tabela de distribuição na qual o prato (Heap) se encontra.
  2. Existem dois cozinheiros na cozinha (Thread * 2).
  3. Cada cozinheiro tem sua própria mesa de corte, onde prepara uma mistura de especiarias (JVM Stack * 2).
  4. Tarefa: adicione duas porções de especiarias ao prato.
  5. Na tabela de distribuição, encontra-se um pedaço de papel com o qual lêem e no qual escrevem qual parte foi adicionada ( ingredientsCount). E para economizar temperos:
    • Antes de iniciar a preparação dos temperos, o cozinheiro deve ler em um pedaço de papel que o número de temperos adicionados não é suficiente;
    • depois de adicionar temperos, o cozinheiro pode escrever quantos, em sua opinião, temperos são adicionados ao prato.

Sob tais condições, pode ocorrer uma situação:

  1. O cozinheiro 1 leu que foram adicionadas 3 porções de especiarias.
  2. O cozinheiro # 2 leu que foram adicionadas 3 porções de especiarias.
  3. Ambos vão para as mesas e preparam uma mistura de especiarias.
  4. Ambos os chefs adicionam especiarias (3 + 2) ao prato.
  5. O cozinheiro 1 escreve que foram adicionadas 4 porções de especiarias.
  6. O cozinheiro n ° 2 escreve que foram adicionadas 4 porções de especiarias.

Conclusão: os produtos estavam faltando, o prato ficou apimentado etc.

Para evitar tais situações, existem várias ferramentas, como travas, funções de segurança de linha, etc.

Resumir


É extremamente raro um desenvolvedor precisar rastrear o código de bytes, a menos que isso seja específico do seu trabalho. Ao mesmo tempo, entender o trabalho do bytecode ajuda a entender melhor o multithreading e as vantagens de um idioma específico e também ajuda a crescer profissionalmente.

Vale a pena notar que estes estão longe de todas as partes da JVM. Há muitas "coisas" mais interessantes, por exemplo, pool constante, verificador de bytecode, JIT, cache de código, etc. Mas, para não sobrecarregar o artigo, concentrei-me apenas nos elementos necessários para um entendimento comum.

Links Úteis:


All Articles