W3docs

Compilation JIT en Java

Comment le compilateur Just-In-Time de la JVM optimise le bytecode Java fréquemment exécuté en code machine natif au moment de l'exécution.

Java est célèbre pour son principe « compiler une fois, exécuter partout », mais ce n'est que la moitié de l'histoire. Le compilateur javac transforme votre code source en bytecode, et non en code machine natif, et la JVM commence par interpréter ce bytecode instruction par instruction. Ce qui rend Java rapide, c'est le compilateur JIT (Just-In-Time) : pendant l'exécution de votre programme, la JVM surveille quelles méthodes sont les plus appelées et compile à la volée ces méthodes « chaudes » en code natif optimisé.

Ce chapitre explique comment fonctionne le modèle de compilation en deux étapes, ce que fait le compilateur à niveaux de HotSpot, et pourquoi un programme Java devient plus rapide au fil du temps. Il s'appuie sur la façon dont la JVM charge et exécute votre code — voir Architecture de la JVM et Compiler et exécuter un programme Java pour le contexte général.

Deux compilateurs, deux rôles

Il existe en réalité deux compilateurs dans le monde Java, et les confondre est une erreur fréquente chez les débutants.

CompilateurQuand il s'exécuteEntréeSortie
javac (AOT)Au moment de la compilationSource .javaBytecode .class portable
JIT (HotSpot)Au moment de l'exécution, dans la JVMBytecodeCode machine natif

javac s'exécute une seule fois et produit du bytecode indépendant de la plateforme. Le JIT vit à l'intérieur de la JVM en cours d'exécution et produit du code machine spécifique au CPU, adapté au processeur exact sur lequel vous vous trouvez. C'est pourquoi le même fichier .jar s'exécute partout tout en pouvant atteindre des performances proches du natif.

// 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.");
    }
}

L'interpréteur d'abord, puis le JIT

Lorsqu'une méthode s'exécute pour la première fois, la JVM l'interprète : il n'y a pas de coût de compilation, donc le démarrage est rapide, mais chaque bytecode est lent à exécuter. La JVM maintient un compteur d'invocations par méthode (et un compteur de saut arrière pour les boucles). Une fois qu'une méthode est appelée assez souvent pour dépasser un seuil, la JVM la confie au JIT pour qu'elle soit compilée en code natif, et les appels futurs sautent directement vers cette version rapide.

C'est pourquoi un serveur à longue durée de vie s'améliore après le démarrage : les méthodes sur son chemin critique finissent par être compilées, tandis que le code rarement utilisé reste interprété (ainsi aucun effort de compilation n'est gaspillé sur lui).

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

Compilation à niveaux : C1 et C2

HotSpot moderne utilise la compilation à niveaux, qui combine deux compilateurs JIT pour obtenir à la fois un démarrage rapide et des performances maximales :

  • C1 (le compilateur client) compile rapidement avec une optimisation légère. Il amène les méthodes chaudes en code natif rapidement et insère des compteurs de profilage.
  • C2 (le compilateur serveur) compile plus lentement mais optimise de manière agressive, en utilisant le profil recueilli par C1 (inlining, déroulage de boucles, analyse d'échappement, élimination de code mort).

Une méthode monte dans les niveaux au fur et à mesure qu'elle devient plus chaude :

NiveauCe qui exécute le codeCompromis
Niveau 0InterpréteurPas de coût de compilation, exécution la plus lente
Niveau 3C1 avec profilageRapide à produire, vitesse modérée, collecte des données
Niveau 4C2 entièrement optimiséLent à produire, exécution la plus rapide

Parce que C2 optimise en fonction du comportement observé, il peut faire des paris que le compilateur statique javac ne pourrait jamais faire — par exemple, inliner un appel virtuel parce qu'en pratique une seule implémentation apparaît.

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

Déoptimisation : annuler un pari

Les optimisations spéculatives peuvent s'avérer erronées. Si C2 a inliné CreditCard.pay et qu'un objet PayPal arrive finalement, le code optimisé n'est plus valide. HotSpot gère cela avec la déoptimisation : il supprime le mauvais code natif, revient à l'interpréteur pour cette méthode, et peut la recompiler ultérieurement avec les nouvelles informations. Ce filet de sécurité est ce qui permet au JIT d'optimiser de manière agressive sans jamais produire de résultats incorrects.

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

Observer les niveaux avec un exemple exécutable

Un vrai benchmark de démarrage nécessite des millions d'itérations de boucle, qu'un environnement bac à sable ne peut pas exécuter. Au lieu de cela, le programme ci-dessous modélise la décision de promotion que HotSpot prend — en classifiant une méthode selon le nombre de fois qu'elle a été invoquée par rapport aux seuils de niveau par défaut — et lit de véritables informations JIT de la JVM en cours d'exécution via CompilationMXBean. Exécutez-le et observez une méthode passer de l'interprétation à C1, puis à C2 à mesure que son compteur d'appels augmente.

java— editable, runs on the server

Ce qu'il faut retenir de l'exécution :

  • Le JIT s'identifie comme les HotSpot 64-Bit Tiered Compilers (via CompilationMXBean.getName()), confirmant que C1 et C2 sont tous deux actifs lors d'un lancement java normal sur une JVM HotSpot.
  • Les méthodes appelées seulement 1 ou 500 fois restent au Niveau 0 (interprété) — le JIT ne gaspille pas d'effort sur le code froid.
  • Dépasser le seuil de 2000 promeut la méthode au Niveau 3 (compilé C1), la version native rapide à produire qui effectue également du profilage.
  • Dépasser 10000 (et 100000) la promeut au Niveau 4 (C2), le code entièrement optimisé qui délivre des performances maximales.
  • CompilationMXBean.getTotalCompilationTime() expose l'activité JIT réelle depuis l'intérieur de Java, prouvant que la compilation se produit pendant l'exécution du programme, et non en avance sur celle-ci.

Observer le JIT par vous-même

Lors d'un vrai lancement java (en dehors d'un bac à sable), vous pouvez observer HotSpot compiler en temps réel avec des options de ligne de commande :

# 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

Quelques points pratiques à retenir :

  • Réchauffez avant de faire des benchmarks. Mesurer une méthode lors de sa première exécution mesure l'interpréteur, pas le code optimisé. Les microbenchmarks doivent effectuer des milliers d'itérations d'abord (des outils comme JMH gèrent cela pour vous) afin que C2 ait compilé le chemin critique.
  • Le démarrage rapide vs la vitesse maximale est un vrai compromis. Les programmes de courte durée (outils CLI, fonctions serverless) peuvent se terminer avant que C2 n'intervienne, et s'exécutent donc principalement en mode interprété ou compilé C1. Les serveurs à longue durée de vie atteignent leur débit maximal après le démarrage.
  • Vous avez rarement besoin d'ajuster les seuils. Les valeurs par défaut fonctionnent bien pour la plupart des charges de travail. Les options ci-dessus sont pour la compréhension et le diagnostic, pas pour le code quotidien.

La compilation JIT et le ramasse-miettes sont les deux systèmes d'exécution qui donnent à la JVM ses performances ; tous deux fonctionnent automatiquement pendant l'exécution de votre programme.

Entraînement

Pratique
Dans la compilation à niveaux de HotSpot, qu'est-ce qui déclenche la promotion d'une méthode depuis l'interpréteur vers du code natif compilé par le JIT ?
Dans la compilation à niveaux de HotSpot, qu'est-ce qui déclenche la promotion d'une méthode depuis l'interpréteur vers du code natif compilé par le JIT ?
Was this page helpful?