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.