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
Le parcours de ton code :
- Compilation :
javactransforme ton.javaen.class(bytecode). - Chargement : le class loader charge les
.classen mémoire. - Interprétation : la JVM interprète le bytecode instruction par instruction.
- Optimisation : le JIT compiler détecte les "hot spots" et les compile en code machine natif.
- 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 :
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
.classtourne 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.
javapte montre une représentation lisible, mais le fichier.classcontient des octets. - Le bytecode est vérifiable. La JVM vérifie le bytecode avant de l'exécuter (bytecode verification). Ça empêche un
.classmalformé de crasher la JVM.
Un exemple plus complexe pour voir les structures de contrôle :
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.
Les 3 niveaux
-
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. -
Platform ClassLoader (anciennement Extension ClassLoader) : charge les modules de la plateforme Java (
java.sql,javax.crypto, etc.). -
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
| Tier | Mode | Quand | Optimisation |
|---|---|---|---|
| 0 | Interpréteur | Au démarrage | Aucune |
| 1 | C1 simple | Après ~200 invocations | Basique (inlining simple) |
| 2 | C1 avec profiling | Après ~500 invocations | Basique + collecte de données |
| 3 | C1 full | Après ~2000 invocations | Modéré |
| 4 | C2 optimisé | Après ~10000 invocations | Agressif (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 :
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").
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.
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.
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
| GC | Pause max typique | Throughput | Heap max recommandé | Cas d'usage |
|---|---|---|---|---|
| G1 | 50-200ms | Très bon | 4-64 Go | Applications générales |
| ZGC | < 1ms | Bon | 256 Go+ | Faible latence |
| Shenandoah | < 10ms | Bon | 128 Go | Faible latence (Red Hat) |
| Parallel GC | 500ms+ | Maximum | 4-32 Go | Batch processing |
Le modèle mémoire : heap, stack, metaspace
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 :
-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.
-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 :
-XX:ReservedCodeCacheSize=512m # Défaut : 240m
Les flags à connaître
Pour le diagnostic et l'optimisation, voici les flags les plus utiles :
# 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
- JVM Specification (Oracle) -- la spécification officielle de la JVM
- Understanding JIT Compilation (Oracle) -- documentation officielle du JIT
- G1 GC Tuning Guide -- guide officiel de tuning du GC
- ZGC Wiki (OpenJDK) -- tout sur ZGC
- JVM Anatomy Quarks (Aleksey Shipilev) -- analyses techniques profondes de la JVM
- javap Documentation -- outil de désassemblage du bytecode