Passer au contenu principal
RM
Retour au blog

Bytecode, JIT compilation, garbage collection, class loading — la JVM expliquée pour les devs qui l'utilisent tous les jours.

Radnoumane Mossabely13 min read
Sous le capot de la JVM
Java
JVM
Bytecode
GC
Performance
0 vues

TL;DR

  • La JVM transforme ton code Java en bytecode, puis le JIT compiler optimise les parties critiques en code machine natif au runtime.
  • Le class loading fonctionne en 3 niveaux : bootstrap, platform, application. Comprendre ça aide à debugger les ClassNotFoundException.
  • Le JIT utilise la compilation tiered (C1 rapide, C2 optimisé). C'est pour ça que Java "chauffe" et devient plus rapide avec le temps.
  • Pour le GC : utilise G1 par défaut, ZGC pour la faible latence, et Shenandoah si tu es sur Red Hat. Oublie CMS, il est deprecated.
  • La mémoire se divise en heap (tes objets), stack (tes appels de méthode), et metaspace (les métadonnées de classes).

Tu utilises la JVM tous les jours. Mais tu ne sais pas comment elle marche.

Tu écris du Java depuis des années. Tu sais que javac compile ton code, que java le lance, et que le garbage collector fait son travail quelque part en arrière-plan. Mais quand on te demande ce qu'est le bytecode, comment le JIT fonctionne, ou pourquoi il y a 4 garbage collectors différents, c'est le flou.

Pas de jugement. La JVM est conçue pour qu'on n'ait pas besoin de comprendre ses internals pour l'utiliser. Mais quand tu dois diagnostiquer un problème de performance, choisir les bons flags GC, ou comprendre pourquoi ton application met 3 secondes à démarrer, cette connaissance fait la différence entre un dev qui subit et un dev qui contrôle.

Cet article démonte le moteur pièce par pièce. Pas de la théorie académique -- juste ce qu'un dev Java a besoin de savoir pour faire de meilleurs choix techniques.

Vue d'ensemble : de .java à l'exécution

Architecture de la JVM : du code source à l'exécution

Le parcours de ton code :

  1. Compilation : javac transforme ton .java en .class (bytecode).
  2. Chargement : le class loader charge les .class en mémoire.
  3. Interprétation : la JVM interprète le bytecode instruction par instruction.
  4. Optimisation : le JIT compiler détecte les "hot spots" et les compile en code machine natif.
  5. Gestion mémoire : le garbage collector libère la mémoire des objets inutilisés.

Chaque étape mérite qu'on s'y arrête.

Le bytecode : ce que contiennent vraiment tes fichiers .class

Quand javac compile ta classe, il ne produit pas du code machine (comme le ferait un compilateur C). Il produit du bytecode -- un jeu d'instructions intermédiaire, indépendant de la plateforme.

Prenons un exemple simple :

hljs java
public class Hello {
    public static int add(int a, int b) {
        return a + b;
    }
}

Après compilation, tu peux inspecter le bytecode avec javap -c Hello :

public static int add(int, int);
  Code:
     0: iload_0      // Charge le premier argument (a) sur la pile
     1: iload_1      // Charge le deuxième argument (b) sur la pile
     2: iadd         // Additionne les deux valeurs en haut de la pile
     3: ireturn      // Retourne le résultat

Le bytecode est stack-based : les opérations manipulent une pile de valeurs. iload_0 pousse une valeur sur la pile, iadd en dépile deux et empile le résultat, ireturn renvoie le haut de la pile.

Quelques points importants :

  • Le bytecode est portable. Le même .class tourne sur Windows, Linux, macOS, ARM, x86. C'est le fameux "Write Once, Run Anywhere".
  • Le bytecode n'est pas du texte. C'est du binaire. javap te montre une représentation lisible, mais le fichier .class contient des octets.
  • Le bytecode est vérifiable. La JVM vérifie le bytecode avant de l'exécuter (bytecode verification). Ça empêche un .class malformé de crasher la JVM.

Un exemple plus complexe pour voir les structures de contrôle :

hljs java
public static String classify(int score) {
    if (score >= 90) return "A";
    else if (score >= 80) return "B";
    else return "C";
}
public static java.lang.String classify(int);
  Code:
     0: iload_0
     1: bipush        90
     3: if_icmplt     9       // Si score < 90, saute à l'instruction 9
     6: ldc           "A"
     8: areturn
     9: iload_0
    10: bipush        80
    12: if_icmplt     18      // Si score < 80, saute à 18
    15: ldc           "B"
    17: areturn
    18: ldc           "C"
    20: areturn

Tu vois les if_icmplt (if integer compare less than) qui implémentent tes if/else. Le bytecode est plus bas niveau que Java, mais reste lisible si tu connais les instructions de base.

Le class loading : 3 niveaux de chargement

Quand la JVM a besoin d'une classe, elle la charge via un système hiérarchique de class loaders.

Hiérarchie des class loaders

Les 3 niveaux

  1. Bootstrap ClassLoader : charge les classes fondamentales du JDK (java.lang.Object, java.lang.String, java.util.*). Écrit en code natif (C/C++), pas en Java.

  2. Platform ClassLoader (anciennement Extension ClassLoader) : charge les modules de la plateforme Java (java.sql, javax.crypto, etc.).

  3. Application ClassLoader : charge ton code et tes dépendances (tout ce qui est dans le classpath).

Le modèle de délégation

Quand l'Application ClassLoader doit charger une classe, il demande d'abord au Platform ClassLoader, qui demande d'abord au Bootstrap ClassLoader. Si personne ne la trouve en remontant, c'est l'Application ClassLoader qui la charge. C'est le parent delegation model.

C'est pour ça que si tu mets une classe java.lang.String dans ton projet, elle sera ignorée : le Bootstrap ClassLoader charge la vraie String avant que ton class loader ait l'occasion d'intervenir.

Quand ça casse

Les ClassNotFoundException et NoClassDefFoundError que tu as croisées viennent de ce système :

  • ClassNotFoundException : le class loader n'a pas trouvé le .class. Le fichier n'est pas dans le classpath.
  • NoClassDefFoundError : la classe existait à la compilation mais pas au runtime. Typiquement un problème de dépendance manquante dans le JAR.
  • ClassCastException avec le même nom : deux class loaders différents ont chargé la même classe. Pour la JVM, ce sont deux classes distinctes. C'est le cauchemar des serveurs d'applications.

Le JIT compiler : pourquoi Java chauffe

La JVM ne compile pas tout en code machine dès le départ. Elle utilise une stratégie tiered compilation : elle commence par interpréter le bytecode, puis compile progressivement les parties les plus utilisées.

Les niveaux de compilation

TierModeQuandOptimisation
0InterpréteurAu démarrageAucune
1C1 simpleAprès ~200 invocationsBasique (inlining simple)
2C1 avec profilingAprès ~500 invocationsBasique + collecte de données
3C1 fullAprès ~2000 invocationsModéré
4C2 optimiséAprès ~10000 invocationsAgressif (inlining, escape analysis, loop unrolling)

Pourquoi cette stratégie ?

L'interprétation est lente mais instantanée. La compilation C2 produit du code rapide mais prend du temps. La tiered compilation est un compromis : on commence vite (interpréteur), on compile les fonctions chaudes avec C1 (rapide à compiler, optimisations légères), et les fonctions vraiment critiques passent en C2 (lent à compiler, mais le code produit est quasi-optimal).

C'est pour ça qu'un serveur Java "chauffe" : les premières minutes, il tourne en mode interprété ou C1. Après quelques milliers de requêtes, les chemins chauds sont compilés en C2 et les performances atteignent leur croisière.

Les optimisations C2

Le compilateur C2 fait des optimisations impressionnantes :

  • Inlining : au lieu d'appeler une méthode, il copie le code directement dans l'appelant. Élimine le coût de l'appel.
  • Escape analysis : si un objet ne "s'échappe" pas de la méthode, il peut être alloué sur la stack au lieu du heap. Pas de GC nécessaire.
  • Loop unrolling : dérouler les boucles pour réduire le coût des conditions de branchement.
  • Dead code elimination : supprimer le code qui ne sera jamais exécuté.
  • Devirtualization : si une méthode virtuelle n'a qu'une implémentation, l'appel est résolu statiquement.

Tu peux voir les décisions du JIT avec -XX:+PrintCompilation :

hljs bash
java -XX:+PrintCompilation MonApp
# Sortie :
#   42    1       3       java.lang.String::hashCode (55 bytes)
#   45    2       3       java.lang.String::equals (81 bytes)
#  158    3       4       com.monapp.Service::process (28 bytes)

Les colonnes : timestamp, ID de compilation, tier, méthode compilée.

Le garbage collection : G1, ZGC, ou Shenandoah ?

Le GC est ce qui te libère de la gestion manuelle de la mémoire. Mais tous les GC ne sont pas égaux.

G1 (Garbage-First) -- Le défaut

G1 est le GC par défaut depuis Java 9. Il divise le heap en régions de taille égale et collecte en priorité les régions avec le plus de garbage (d'où le nom "Garbage-First").

hljs bash
java -XX:+UseG1GC -XX:MaxGCPauseMillis=200 MonApp
  • Forces : bon compromis throughput/latence, pauses prévisibles, adapté à la majorité des applications.
  • Faiblesses : les pauses peuvent dépasser 200ms sur les très gros heaps (>32 Go).
  • Utilise G1 si : tu ne sais pas quel GC choisir. C'est le défaut pour une bonne raison.

ZGC -- La faible latence

ZGC est un GC concurrent avec des pauses sous les 1ms, quelle que soit la taille du heap.

hljs bash
java -XX:+UseZGC MonApp
  • Forces : pauses ultra-courtes, supporte des heaps de plusieurs téraoctets, totalement concurrent.
  • Faiblesses : throughput légèrement inférieur à G1, consomme un peu plus de CPU.
  • Utilise ZGC si : la latence est critique (trading, gaming, API temps réel).

Shenandoah -- L'alternative Red Hat

Shenandoah est similaire à ZGC en objectif (pauses courtes), développé par Red Hat.

hljs bash
java -XX:+UseShenandoahGC MonApp
  • Forces : pauses courtes, disponible dans les builds Red Hat/Fedora.
  • Faiblesses : pas disponible dans tous les JDK (absent du Oracle JDK).
  • Utilise Shenandoah si : tu es sur un JDK Red Hat et tu veux de la faible latence.

Le tableau comparatif

GCPause max typiqueThroughputHeap max recommandéCas d'usage
G150-200msTrès bon4-64 GoApplications générales
ZGC< 1msBon256 Go+Faible latence
Shenandoah< 10msBon128 GoFaible latence (Red Hat)
Parallel GC500ms+Maximum4-32 GoBatch processing

Le modèle mémoire : heap, stack, metaspace

Organisation de la mémoire JVM

Heap (tas)

C'est là que vivent tes objets. Quand tu fais new Object(), il va dans le heap.

Le heap est divisé en générations :

  • Young Generation (Eden + Survivor) : les nouveaux objets. La plupart meurent jeunes (variables locales, objets temporaires). Le minor GC nettoie cette zone fréquemment et rapidement.
  • Old Generation : les objets qui ont survécu à plusieurs cycles de GC. Le major GC nettoie cette zone, mais c'est plus coûteux.

Flags courants :

hljs bash
-Xms512m    # Taille initiale du heap
-Xmx4g      # Taille maximum du heap
-XX:NewRatio=2  # Ratio Old/Young (Old = 2x Young)

Stack (pile)

Chaque thread a sa propre stack. Chaque appel de méthode crée un "frame" sur la stack avec :

  • Les variables locales
  • La pile d'opérandes (pour les calculs bytecode)
  • La référence à la constant pool de la classe

La stack est fixe en taille (-Xss, défaut 512K à 1M). Si tu as une récursion infinie, tu obtiens un StackOverflowError.

Metaspace

Depuis Java 8, les métadonnées de classes sont dans le metaspace (qui a remplacé le PermGen). Le metaspace utilise la mémoire native (hors heap) et grandit dynamiquement.

hljs bash
-XX:MaxMetaspaceSize=256m  # Limiter le metaspace

Si tu as des fuites de class loaders (fréquent avec les serveurs d'application qui rechargent des classes), le metaspace peut grossir indéfiniment. -XX:MaxMetaspaceSize met un garde-fou.

Code Cache

Le code compilé par le JIT est stocké dans le code cache. Si le code cache est plein, le JIT arrête de compiler de nouvelles méthodes. Sur les grosses applications, augmenter le code cache peut améliorer les performances :

hljs bash
-XX:ReservedCodeCacheSize=512m  # Défaut : 240m

Les flags à connaître

Pour le diagnostic et l'optimisation, voici les flags les plus utiles :

hljs bash
# Voir les décisions du GC
java -Xlog:gc* MonApp

# Voir les compilations JIT
java -XX:+PrintCompilation MonApp

# Heap dump en cas de OutOfMemoryError
java -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/tmp/dump.hprof MonApp

# Activer les métriques JMX pour monitoring
java -Dcom.sun.management.jmxremote MonApp

# GC ergonomique : objectif de pause
java -XX:MaxGCPauseMillis=100 MonApp

Ce qu'il faut retenir

La JVM est un moteur remarquablement bien conçu. Mais comme tout moteur, mieux tu comprends son fonctionnement, mieux tu peux l'utiliser.

Les réflexes à prendre :

  • Choisis ton GC en fonction de ton cas d'usage. G1 par défaut, ZGC si la latence est critique.
  • Laisse le JIT chauffer. Les premières secondes d'une application Java ne sont pas représentatives de ses performances en croisière.
  • Surveille le metaspace sur les applications qui chargent des classes dynamiquement.
  • Utilise -Xlog:gc* avant de tuner quoi que ce soit. Mesure d'abord, optimise ensuite.
  • Comprends le bytecode quand tu debugges des problèmes de performance au niveau des instructions.

La JVM est ce qui fait que Java, malgré son âge, reste un des langages les plus performants pour le backend. Et maintenant, tu sais pourquoi.

Ressources

Partager: