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.
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.
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;
}
}
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)
Percebe-se que as informações obtidas são divididas por colunas e o objetivo é entender cada uma delas.
- O tempo de compilação em milissegundos desde o startup da máquina virtual
- Ordem de compilação
- Provê informações sobre a compilação. Ex: "!" significa que houve uma exceção, "n" significa que é um método nativo.
- 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.
- 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)
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)
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.
Eduardo, também acho interessante demais!! Na verdade, todo processo da JVM é muito interessante.