Compilação JIT em Java
Como o compilador Just-In-Time da JVM otimiza bytecode Java em código nativo de máquina em tempo de execução.
Java é famoso por "compile uma vez, execute em qualquer lugar", mas essa é apenas metade da história. O compilador javac transforma seu código-fonte em bytecode, não em código nativo de máquina, e a JVM começa interpretando esse bytecode uma instrução de cada vez. O componente que torna o Java rápido é o compilador JIT (Just-In-Time): enquanto seu programa é executado, a JVM observa quais métodos são chamados com mais frequência e compila esses métodos "quentes" em código nativo otimizado em tempo real.
Este capítulo explica como o modelo de compilação em dois estágios funciona, o que o compilador em camadas do HotSpot faz e por que um programa Java fica mais rápido quanto mais tempo é executado. Ele se baseia em como a JVM carrega e executa seu código — veja Arquitetura da JVM e Compilar e Executar um Programa Java para o contexto geral.
Dois compiladores, dois trabalhos
Na verdade, existem dois compiladores no mundo Java, e confundi-los é um erro comum para iniciantes.
| Compilador | Quando é executado | Entrada | Saída |
|---|---|---|---|
javac (AOT) | Em tempo de compilação | Código-fonte .java | Bytecode .class portátil |
| JIT (HotSpot) | Em tempo de execução, dentro da JVM | Bytecode | Código nativo de máquina |
O javac é executado uma vez e produz bytecode independente de plataforma. O JIT vive dentro da JVM em execução e produz código de máquina específico para a CPU adaptado ao processador exato em que você está. É por isso que o mesmo .jar funciona em qualquer lugar, mas ainda pode atingir velocidade próxima à nativa.
// Build time: javac Hello.java -> Hello.class (bytecode)
// Run time: java Hello -> JVM interprets, then JIT-compiles hot methods
public class Hello {
public static void main(String[] args) {
System.out.println("Bytecode now, native code soon.");
}
}Interpretador primeiro, depois JIT
Quando um método é executado pela primeira vez, a JVM o interpreta: não há custo de compilação, então a inicialização é rápida, mas cada bytecode é lento para executar. A JVM mantém um contador de invocação por método (e um contador de back-edge para loops). Assim que um método é chamado com frequência suficiente para cruzar um limite, a JVM o entrega ao JIT para ser compilado em código nativo, e as chamadas futuras saltam diretamente para essa versão rápida.
É por isso que um servidor de longa execução fica mais rápido após o aquecimento: os métodos no caminho quente eventualmente são compilados, enquanto o código raramente usado permanece interpretado (portanto, nenhum esforço de compilação é desperdiçado com ele).
// 'process' is on the hot path. After enough calls it gets JIT-compiled;
// 'logRareError' may stay interpreted forever because it almost never runs.
void handleRequest(Request r) {
process(r); // hot: many invocations -> compiled
if (r.isMalformed()) {
logRareError(r); // cold: rarely called -> stays interpreted
}
}Compilação em camadas: C1 e C2
O HotSpot moderno usa compilação em camadas, que combina dois compiladores JIT para que você obtenha inicialização rápida e desempenho de pico:
- C1 (o compilador cliente) compila rapidamente com otimização leve. Ele leva os métodos quentes ao código nativo rapidamente e insere contadores de profiling.
- C2 (o compilador servidor) compila mais lentamente, mas otimiza de forma agressiva, usando o perfil que o C1 coletou (inlining, desenrolamento de loops, análise de escape, eliminação de código morto).
Um método sobe pelas camadas conforme fica mais quente:
| Camada | O que executa o código | Compensação |
|---|---|---|
| Camada 0 | Interpretador | Sem custo de compilação, execução mais lenta |
| Camada 3 | C1 com profiling | Rápido de produzir, velocidade moderada, coleta dados |
| Camada 4 | C2 totalmente otimizado | Lento de produzir, execução mais rápida |
Como o C2 otimiza com base no comportamento observado, ele pode fazer apostas que o compilador estático javac jamais poderia — por exemplo, fazer o inlining de uma chamada virtual porque, na prática, apenas uma implementação aparece.
// C2 can speculatively inline this even though 'pay' is virtual,
// because profiling showed every call so far used CreditCard.
abstract class Payment { abstract void pay(int cents); }
class CreditCard extends Payment { void pay(int cents) { /* ... */ } }
void checkout(Payment p) {
p.pay(1999); // megamorphic in theory; monomorphic in practice -> inlined
}Desotimização: desfazendo uma aposta
Otimizações especulativas podem se revelar erradas. Se o C2 fez o inlining de CreditCard.pay e então um objeto PayPal finalmente chega, o código otimizado não é mais válido. O HotSpot lida com isso através da desotimização: descarta o código nativo incorreto, volta ao interpretador para aquele método e pode recompilá-lo posteriormente com as novas informações. Essa rede de segurança é o que permite ao JIT otimizar agressivamente sem nunca produzir resultados incorretos.
// First 100000 calls: only CreditCard -> C2 inlines aggressively.
// Call 100001 passes a PayPal -> the assumption breaks ->
// HotSpot deoptimizes, reverts to interpreter, and recompiles later.
checkout(new CreditCard());
checkout(new PayPal()); // triggers deoptimization of the inlined versionObservando as camadas com um exemplo executável
Um benchmark de aquecimento real precisa de milhões de iterações de loop, o que um sandbox não pode executar. Em vez disso, o programa abaixo modela a decisão de promoção que o HotSpot toma — classificando um método por quantas vezes ele foi invocado em relação aos limites padrão de camada — e lê informações genuínas do JIT da JVM em execução através do CompilationMXBean. Execute-o e observe um método passar de interpretado, para C1, para C2 conforme sua contagem de chamadas aumenta.
O que aprender com a execução:
- O JIT se identifica como os HotSpot 64-Bit Tiered Compilers (via
CompilationMXBean.getName()), confirmando que tanto o C1 quanto o C2 estão ativos em uma inicialização normal dojavaem uma JVM HotSpot. - Métodos chamados apenas
1ou500vezes ficam na Camada 0 (interpretado) — o JIT não desperdiça esforço em código frio. - Cruzar o limite de
2000promove o método para a Camada 3 (C1 compilado), a versão nativa rápida de produzir que também realiza profiling. - Cruzar
10000(e100000) promove-o para a Camada 4 (C2), o código totalmente otimizado que entrega velocidade de pico. CompilationMXBean.getTotalCompilationTime()expõe atividade real do JIT de dentro do Java, provando que a compilação acontece enquanto o programa é executado, não antecipadamente.
Vendo o JIT por si mesmo
Em uma inicialização real do java (fora de um sandbox) você pode observar o HotSpot compilar em tempo real com flags de linha de comando:
# Print each method as it is compiled, with its tier number in the second column.
java -XX:+PrintCompilation MyApp
# Dump a one-line summary of every compilation method HotSpot supports.
java -XX:+PrintFlagsFinal -version | grep -i tierAlgumas conclusões práticas:
- Aqueça antes de fazer benchmark. Medir o tempo de um método na primeira execução mede o interpretador, não o código otimizado. Microbenchmarks devem executar milhares de iterações primeiro (ferramentas como JMH lidam com isso para você) para que o C2 tenha compilado o caminho quente.
- Velocidade de inicialização versus velocidade de pico é uma compensação real. Programas de curta duração (ferramentas CLI, funções serverless) podem sair antes de o C2 sequer entrar em ação, então eles são executados principalmente interpretados ou compilados pelo C1. Servidores de longa execução atingem o throughput máximo após o aquecimento.
- Raramente é necessário ajustar os limites. Os padrões funcionam bem para a maioria das cargas de trabalho. As flags acima são para compreensão e diagnóstico, não para código do dia a dia.
A compilação JIT e o coletor de lixo são os dois sistemas de tempo de execução que conferem à JVM seu desempenho; ambos funcionam automaticamente enquanto seu programa é executado.