W3docs

Compilazione JIT in Java

Come il compilatore Just-In-Time della JVM ottimizza il bytecode Java caldo in codice macchina nativo a runtime.

Java è famoso per il principio "compila una volta, esegui ovunque", ma questa è solo metà della storia. Il compilatore javac trasforma il codice sorgente in bytecode, non in codice macchina nativo, e la JVM inizia interpretando quel bytecode un'istruzione alla volta. Il componente che rende Java veloce è il compilatore JIT (Just-In-Time): mentre il programma è in esecuzione, la JVM osserva quali metodi vengono chiamati più spesso e compila quei metodi "caldi" in codice nativo ottimizzato al volo.

Questo capitolo spiega come funziona il modello di compilazione in due fasi, cosa fa il compilatore a livelli di HotSpot e perché un programma Java diventa più veloce quanto più a lungo è in esecuzione. Si basa su come la JVM carica ed esegue il codice — consulta Architettura JVM e Compilare ed eseguire un programma Java per il contesto generale.

Due compilatori, due compiti

Nel mondo Java esistono davvero due compilatori, e confonderli è un errore comune per i principianti.

CompilatoreQuando viene eseguitoInputOutput
javac (AOT)In fase di buildSorgente .javaBytecode .class portabile
JIT (HotSpot)A runtime, all'interno della JVMBytecodeCodice macchina nativo

javac viene eseguito una volta e produce bytecode indipendente dalla piattaforma. Il JIT risiede all'interno della JVM in esecuzione e produce codice macchina specifico per la CPU, adattato al preciso processore in uso. Ecco perché lo stesso .jar funziona ovunque eppure può raggiungere una velocità quasi 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.");
    }
}

Prima l'interprete, poi il JIT

Quando un metodo viene eseguito per la prima volta, la JVM lo interpreta: non c'è costo di compilazione, quindi l'avvio è rapido, ma ogni bytecode è lento da eseguire. La JVM mantiene un contatore di invocazioni per metodo (e un contatore di back-edge per i cicli). Non appena un metodo viene chiamato abbastanza spesso da superare una soglia, la JVM lo affida al JIT per essere compilato in codice nativo, e le chiamate future saltano direttamente a quella versione veloce.

Ecco perché un server a lunga esecuzione diventa più veloce dopo il riscaldamento: i metodi nel suo percorso caldo vengono alla fine compilati, mentre il codice usato raramente rimane interpretato (quindi nessuno sforzo di compilazione viene sprecato per esso).

// '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
    }
}

Compilazione a livelli: C1 e C2

HotSpot moderno utilizza la compilazione a livelli, che combina due compilatori JIT in modo da ottenere sia un avvio rapido che le prestazioni di picco:

  • C1 (il compilatore client) compila rapidamente con un'ottimizzazione leggera. Porta i metodi caldi al codice nativo velocemente e inserisce contatori di profilazione.
  • C2 (il compilatore server) compila più lentamente ma ottimizza in modo aggressivo, utilizzando il profilo raccolto da C1 (inlining, srotolamento dei cicli, analisi dell'escape, eliminazione del codice morto).

Un metodo sale attraverso i livelli man mano che diventa più caldo:

LivelloCosa esegue il codiceCompromesso
Livello 0InterpreteNessun costo di compilazione, esecuzione più lenta
Livello 3C1 con profilazioneVeloce da produrre, velocità moderata, raccoglie dati
Livello 4C2 completamente ottimizzatoLento da produrre, esecuzione più veloce

Poiché C2 ottimizza in base al comportamento osservato, può fare scommesse che il compilatore statico javac non potrebbe mai fare — per esempio, applicare l'inlining a una chiamata virtuale perché in pratica appare sempre una sola implementazione.

// 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
}

Deottimizzazione: annullare una scommessa

Le ottimizzazioni speculative possono rivelarsi errate. Se C2 ha applicato l'inlining a CreditCard.pay e poi arriva un oggetto PayPal, il codice ottimizzato non è più valido. HotSpot gestisce questa situazione con la deottimizzazione: scarta il codice nativo non valido, torna all'interprete per quel metodo e potrebbe in seguito ricompilarlo con le nuove informazioni. Questa rete di sicurezza è ciò che consente al JIT di ottimizzare in modo aggressivo senza mai produrre risultati errati.

// 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

Osservare i livelli con un esempio eseguibile

Un vero benchmark di riscaldamento richiede milioni di iterazioni di ciclo, che una sandbox non può eseguire. Il programma qui sotto, invece, modella la decisione di promozione che HotSpot prende — classificando un metodo in base al numero di volte che è stato invocato rispetto alle soglie di livello predefinite — e legge dati JIT reali dalla JVM in esecuzione tramite CompilationMXBean. Eseguilo e osserva come un metodo passa dall'interpretazione, a C1, a C2 man mano che il suo contatore di chiamate aumenta.

java— editable, runs on the server

Cosa ricavare dall'esecuzione:

  • Il JIT si identifica come HotSpot 64-Bit Tiered Compilers (tramite CompilationMXBean.getName()), confermando che sia C1 che C2 sono attivi in un normale avvio di java su una JVM HotSpot.
  • I metodi chiamati solo 1 o 500 volte rimangono al Livello 0 (interpretato) — il JIT non spreca sforzi sul codice freddo.
  • Superare la soglia di 2000 promuove il metodo al Livello 3 (compilato C1), la versione nativa veloce da produrre che effettua anche la profilazione.
  • Superare 10000 (e 100000) lo promuove al Livello 4 (C2), il codice completamente ottimizzato che fornisce la velocità di picco.
  • CompilationMXBean.getTotalCompilationTime() espone l'attività JIT reale dall'interno di Java, dimostrando che la compilazione avviene mentre il programma è in esecuzione, non in anticipo.

Vedere il JIT in azione

Con un vero avvio di java (fuori da una sandbox) puoi osservare HotSpot compilare in tempo reale con i flag da riga di 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

Alcune considerazioni pratiche:

  • Riscalda prima di fare benchmark. Misurare un metodo alla prima esecuzione misura l'interprete, non il codice ottimizzato. I microbenchmark devono eseguire migliaia di iterazioni prima (strumenti come JMH gestiscono questo per te) affinché C2 abbia compilato il percorso caldo.
  • Avvio vs. velocità di picco è un vero compromesso. I programmi di breve durata (strumenti CLI, funzioni serverless) possono terminare prima che C2 entri mai in gioco, quindi vengono eseguiti principalmente in modalità interpretata o compilati con C1. I server a lunga esecuzione raggiungono il throughput di picco dopo il riscaldamento.
  • Raramente è necessario regolare le soglie. I valori predefiniti funzionano bene per la maggior parte dei carichi di lavoro. I flag sopra riportati servono per la comprensione e la diagnosi, non per il codice quotidiano.

La compilazione JIT e il garbage collector sono i due sistemi runtime che conferiscono alla JVM le sue prestazioni; entrambi funzionano automaticamente mentre il programma è in esecuzione.

Pratica

Pratica
Nella compilazione a livelli di HotSpot, cosa determina la promozione di un metodo dall'interprete al codice nativo compilato JIT?
Nella compilazione a livelli di HotSpot, cosa determina la promozione di un metodo dall'interprete al codice nativo compilato JIT?
Was this page helpful?