W3docs

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.

CompiladorQuando é executadoEntradaSaída
javac (AOT)Em tempo de compilaçãoCódigo-fonte .javaBytecode .class portátil
JIT (HotSpot)Em tempo de execução, dentro da JVMBytecodeCó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:

CamadaO que executa o códigoCompensação
Camada 0InterpretadorSem custo de compilação, execução mais lenta
Camada 3C1 com profilingRápido de produzir, velocidade moderada, coleta dados
Camada 4C2 totalmente otimizadoLento 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 version

Observando 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.

java— editable, runs on the server

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 do java em uma JVM HotSpot.
  • Métodos chamados apenas 1 ou 500 vezes ficam na Camada 0 (interpretado) — o JIT não desperdiça esforço em código frio.
  • Cruzar o limite de 2000 promove o método para a Camada 3 (C1 compilado), a versão nativa rápida de produzir que também realiza profiling.
  • Cruzar 10000 (e 100000) 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 tier

Algumas 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.

Prática

Prática
Na compilação em camadas do HotSpot, o que faz um método ser promovido do interpretador para código nativo compilado pelo JIT?
Na compilação em camadas do HotSpot, o que faz um método ser promovido do interpretador para código nativo compilado pelo JIT?
Was this page helpful?