DEV Community

João Victor Martins
João Victor Martins

Posted on

[PT-BR] Falando sobre JIT Compiler

A JVM (Java Virtual Machine) é uma peça fundamental para a plataforma Java. Graças a este recurso, é possível escrever o código e executá-lo em diferentes sistemas operacionais. A diferença dessa abordagem em relação ao processo realizado por outras linguagens, por exemplo o C++, é que não é necessário compilar o código para diferentes S.O's. Apesar de trazer a vantagem citada anteriormente, a abordagem traz alguns trade-off's, entre eles, o impacto na performance. Enquanto no C++ o código é compilado e executado nativamente (binário), no Java é necessário ser compilado para bytecode (linguagem que a JVM entende), interpretado (binário) e executado nativamente. Parece ser pouca a diferença entre as duas linguagens, sendo necessário, no Java, realizar apenas um passo a mais, mas este passo é crucial na performance. Uma das ações tomadas pelos engenheiros e desenvolvedores da plataforma, foi criar um mecanismo que auxilia e provê mais performance na interpretação. Esse mecanismo é conhecido como JIT Compiler ou Just in Time Compiler.

JIT Compiler

Para entender o JIT, é necessário entender, mesmo que em linhas gerais, o que a JVM faz. Seus objetivos são interpretar os bytecodes e gerenciar a memória da aplicação. Por enquanto o foco será no primeiro objetivo.

Alt Text

A imagem acima mostra o processo de compilação do código Java para bytecode, o carregamento para a JVM e a interpretação para binário. O problema destes passos é que, em uma aplicação, o mesmo método ou bloco de código será chamado muitas vezes durante o ciclo de vida da aplicação, sendo necessária a interpretação em cada uma das vezes. Como pode-se imaginar, o impacto na performance cresce exponencialmente e pensando nisso, os engenheiros da plataforma tiveram a ideia do JIT Compiler. O JIT é um processo que ocorre de forma paralela à interpretação (em outra thread) e seu objetivo é transformar (compilar) os trechos de códigos mais executados em linguagem nativa (binário). A diferença da interpretação padrão, citada anteriormente, é que conforme o processo vai sendo executado, vai havendo melhora na compilação até chegar no grau máximo de otimização. Chegando neste grau, a JVM não precisa interpretar mais aquele trecho de código.

Alt Text

Praticando

O processo de compilação em runtime, citado anteriormente, pode ser visto usando a flag -XX:+PrintCompilation no momento da execução do projeto. Para exemplificar, será utilizado o código abaixo:

import java.util.*;
public class Principal {
    public static void main(String[] args) {
        Integer numeroMaximo = Integer.parseInt(args[0]);
        Principal principal = new Principal();
        principal.guardarNumerosPares(numeroMaximo);
    }
    private void guardarNumerosPares(Integer numeroMaximo){
        int numero = 0;
        List<Integer> numerosPares = new ArrayList<>();
        while(numero <= numeroMaximo) {
            if(validarSeEPar(numero)) numerosPares.add(numero);
            numero++;
        }
    }
    private Boolean validarSeEPar(Integer numero) {
        if (numero % 2 == 0) return true;
        return false;
    }
}
Enter fullscreen mode Exit fullscreen mode

A ideia do código é bastante simples. O usuário fornece um número como argumento e o programa valida quais são os números pares no intervalo de 0 até o número recebido. Para executá-lo com a flag, é necessário o comando java -XX:+PrintCompilation Principal 10. O resultado será:

24    1       3       java.lang.String::isLatin1 (19 bytes)
25    3       3       java.lang.StringLatin1::hashCode (42 bytes)
26    6       3       java.util.ImmutableCollections$SetN::probe (56 bytes)
28    9       3       java.lang.StringLatin1::equals (36 bytes)
28   10       3       java.util.ImmutableCollections$SetN::hashCode (46 bytes)
29    4       3       java.lang.Object::<init> (1 bytes)
29    2       3       java.lang.String::hashCode (60 bytes)
30    5       3       java.lang.Math::floorMod (20 bytes)
30    8       3       java.util.Set::of (4 bytes)
31   11       3       java.util.Objects::requireNonNull (14 bytes)
31   18       4       java.lang.Object::<init> (1 bytes)
31   13       3       java.lang.String::coder (15 bytes)
32    4       3       java.lang.Object::<init> (1 bytes)   made not entrant
33   22       3       java.util.ImmutableCollections$SetN$SetNIterator::next (47 bytes)
33   26     n 0       jdk.internal.misc.Unsafe::getReferenceVolatile (native)
33   20       3       java.util.ImmutableCollections$MapN::probe (60 bytes)
34   29   !   3       java.util.concurrent.ConcurrentHashMap::putVal (432 bytes)
36   16       3       java.util.ImmutableCollections$SetN$SetNIterator::nextIndex (56 bytes)
36   32       3       java.util.HashMap::hash (20 bytes)
37   35       3       java.util.HashMap::putVal (300 bytes)
38   23       3       java.util.ImmutableCollections$SetN$SetNIterator::hasNext (13 bytes)
38   44       4       java.lang.String::hashCode (60 bytes)
38    7       3       java.lang.String::equals (50 bytes)
39   36       3       java.util.HashMap::newNode (13 bytes)
42    2       3       java.lang.String::hashCode (60 bytes)   made not entrant
43   46       4       java.util.ImmutableCollections$SetN$SetNIterator::nextIndex (56 bytes)
44   14       1       java.lang.module.ModuleReference::descriptor (5 bytes)
44   16       3       java.util.ImmutableCollections$SetN$SetNIterator::nextIndex (56 bytes)   made not entrant
45   12       1       java.lang.module.ModuleDescriptor::name (5 bytes)
46   21       1       java.lang.module.ResolvedModule::reference (5 bytes)
46   44       4       java.lang.String::hashCode (60 bytes)   made not entrant
47   33       1       java.lang.module.ModuleDescriptor$Exports::source (5 bytes)
47   31       1       java.util.ImmutableCollections$SetN::size (5 bytes)
Enter fullscreen mode Exit fullscreen mode

Percebe-se que as informações obtidas são divididas por colunas e o objetivo é entender cada uma delas.

  1. O tempo de compilação em milissegundos desde o startup da máquina virtual
  2. Ordem de compilação
  3. Provê informações sobre a compilação. Ex: "!" significa que houve uma exceção, "n" significa que é um método nativo.
  4. Grau da compilação. Os números vão de 0 a 4. 0 significa que não houve compilação, apenas interpretação e 4 que houve compilação e a compilação obteve a máxima otimização.
  5. Full qualified name da classe e seu método que passou pela compilação.

Os métodos da aplicação não constam na lista dos métodos compilados, isso acontece pois o tempo de execução do projeto não foi o suficiente para necessitar da compilação otimizada. Aumentando o número para 50000 este comportamento será alterado.

java -XX:+PrintCompilation Principal 50000

//Resto das informações omitidas para facilitar a visualização
68   54       2       Principal::validarSeEPar (19 bytes)
68   52       2       java.lang.Integer::<init> (10 bytes)   made not entrant
69   60       4       java.lang.Integer::valueOf (32 bytes)
69   50       1       java.lang.Boolean::booleanValue (5 bytes)
70   55       2       java.lang.Boolean::valueOf (14 bytes)
70   53       2       java.lang.Integer::valueOf (32 bytes)   made not entrant
71   61       4       Principal::validarSeEPar (19 bytes)
73   56       2       java.util.ArrayList::add (25 bytes)
75   54       2       Principal::validarSeEPar (19 bytes)   made not entrant
76   57       2       java.util.ArrayList::add (23 bytes)
76   41       3       java.util.HashMap$Node::<init> (26 bytes)
77   62       4       java.util.ArrayList::add (25 bytes)
77   14       1       java.lang.module.ModuleReference::descriptor (5 bytes)
Enter fullscreen mode Exit fullscreen mode

Aumentando o número fornecido para o sistema, o método validarSeEPar passa a ser requisitado mais vezes, sendo necessário uma otimização por parte da JVM. É possível verificar já na primeira linha que o método foi compilado com grau 2 e com o passar do tempo houve uma melhora na compilação, indo para grau 4 na linha 7.

Por conta da execução paralela do JIT, a aplicação pode sofrer com performance momentaneamente, mas a longo prazo, quanto mais a aplicação vai sendo executada, mais a execução vai ficando fluída.

Concluindo ...

A ideia era mostrar sobre como os engenheiros do Java pensaram em melhorar a performance das aplicações e diminuir o trade-off das linguagens compiladas/interpretadas. Essa é só uma pequena parte do que a JVM faz. Ainda há detalhes de como o JIT guarda os métodos, compilados no grau 4, na memória, entre outras questões que ficarão para posts futuros. Dúvidas e sugestões são sempre bem-vindas. Até o próximo!!

Top comments (2)

Collapse
 
eduardoklosowski profile image
Eduardo Klosowski

Esse assunto do JIT é bem interessante, pois ao se compilar muitas vezes é necessário optar por uma heurística para otimizar o código, ao fazer isso em tempo de execução pode-se testar diferentes heurísticas com os dados de execuções reais para escolher a melhor heurística, diferente de C, por exemplo, que tem que presumir qual seria a melhor otimização para aquele código, e se essa não for uma boa heurística para aquele código ter uma performance pior do que poderia ser obtida.

Collapse
 
j_a_o_v_c_t_r profile image
João Victor Martins

Eduardo, também acho interessante demais!! Na verdade, todo processo da JVM é muito interessante.