Architecture de la JVM Java
Structure de la JVM : chargeur de classes, zones de données d'exécution, moteur d'exécution et interface native.
La Java Virtual Machine (JVM) est le programme qui exécute votre programme. Vous compilez les sources .java en bytecode .class neutre vis-à-vis de la plateforme, et c'est la JVM qui charge ce bytecode, le vérifie, dispose ses objets en mémoire et l'exécute sur le matériel réel. « Écrire une fois, exécuter partout » signifie en réalité « compiler une fois, et laisser la JVM de chaque plateforme faire le reste. » Ce chapitre décrit la structure interne de la JVM — les trois sous-systèmes que toute implémentation partage — afin que les chapitres sur le modèle mémoire et le ramasse-miettes qui suivent disposent d'un cadre de référence.
Trois sous-systèmes
Une JVM, quel qu'en soit le fournisseur, est organisée en trois sous-systèmes coopérants. Tout le reste est un détail à l'intérieur de l'un d'eux.
| Sous-système | Responsabilité |
|---|---|
| Chargeur de classes | Trouve, charge, lie et initialise les fichiers .class dans l'environnement d'exécution |
| Zones de données d'exécution | La mémoire gérée par la JVM : tas, piles, zone de méthodes, registres PC |
| Moteur d'exécution | Interprète et compile le bytecode en JIT, et exécute le ramasse-miettes |
Le chargeur de classes importe les types, les zones de données d'exécution conservent l'état, et le moteur d'exécution exécute le code. Un appel de méthode touche les trois : sa classe est chargée, son frame est empilé sur une pile, et son bytecode est exécuté.
Le sous-système de chargement de classes
Les classes ne sont pas toutes chargées au démarrage. La JVM charge une classe de façon paresseuse, la première fois qu'elle est référencée, en trois phases : le chargement (lecture des octets), la liaison (vérification du bytecode, préparation des champs statiques, résolution des références), et l'initialisation (exécution des initialiseurs statiques et des blocs static { }).
Les chargeurs forment une hiérarchie parent-d'abord. Lorsqu'un chargeur reçoit une demande de classe, il délègue vers le haut à son parent avant d'essayer lui-même — ainsi, un type central comme String provient toujours du chargeur d'amorçage de confiance et ne peut jamais être masqué par le code applicatif.
// Walk the loader chain of any class
ClassLoader loader = MyType.class.getClassLoader();
while (loader != null) {
System.out.println(loader.getName());
loader = loader.getParent();
}
// A null result means the bootstrap loader (native, no Java object).
System.out.println(String.class.getClassLoader()); // prints: nullLes trois chargeurs standard, de l'enfant au parent, sont le chargeur applicatif (classpath), le chargeur de plateforme (modules JDK tels que java.sql), et le chargeur d'amorçage (noyau java.base, implémenté en code natif, représenté par null). Le chapitre sur le chargement de classes couvre ce cycle de vie et le modèle de délégation en détail ; les modules expliquent comment java.base et ses homologues sont empaquetés.
Les zones de données d'exécution
Une fois qu'une classe est chargée, son code et ses données résident dans des régions que la JVM partitionne à des fins distinctes :
- Tas — partagé entre tous les threads ; chaque objet et tableau y réside. C'est ce que gère le ramasse-miettes.
- Piles JVM — une par thread. Chaque appel de méthode empile un frame contenant des variables locales et des opérandes ; le frame est dépilé au retour. Une récursion trop profonde le déborde (
StackOverflowError). - Zone de méthodes (Metaspace dans les JVM modernes) — métadonnées de classe, pool de constantes d'exécution et champs statiques. Mémoire hors tas.
- Registre PC — par thread ; l'adresse de l'instruction bytecode en cours d'exécution.
// Heap allocation: 'new' carves space out of the heap
byte[] buffer = new byte[1024]; // lives on the heap, GC-managed
// Stack growth: each call adds a frame to this thread's stack
static long factorial(long n) {
return n <= 1 ? 1 : n * factorial(n - 1); // each call = one more frame
}La distinction tas / hors-tas est la séparation clé : les instances d'objets se trouvent sur le tas ; les structures de classes et la machinerie elle-même ne s'y trouvent pas. Le chapitre sur pile vs. tas compare ces deux régions en détail.
Le moteur d'exécution
Le moteur d'exécution transforme le bytecode en actions. Les JVM HotSpot modernes sont adaptatives : elles commencent par interpréter le bytecode (démarrage rapide), profilent les méthodes fréquemment exécutées, puis les confient au compilateur Just-In-Time (JIT), qui émet du code machine natif optimisé. Le code froid reste interprété ; le code chaud est compilé — vous ne payez le coût d'optimisation que là où cela se justifie.
Le moteur héberge également le ramasse-miettes, qui récupère les objets du tas qui ne sont plus accessibles depuis aucune référence active, et la Java Native Interface (JNI), le pont vers les bibliothèques écrites en C/C++.
// A hot loop: the JIT will compile sum() to native code after enough calls
long total = 0;
for (int i = 0; i < 100_000_000; i++) {
total += i; // interpreted at first, then JIT-compiled, then much faster
}Exemple concret : inspecter la JVM en cours d'exécution
Ce programme ne configure rien — il demande à la JVM en cours d'exécution de se décrire via les beans java.lang.management et l'API du chargeur de classes. Il nomme la VM et le moteur d'exécution, parcourt la hiérarchie des chargeurs, rend compte de la mémoire tas et hors-tas, et fait croître la pile par récursion.
Ce que l'on retient de l'exécution :
- Le
RuntimeMXBeannomme le moteur d'exécution — une VM de la famille HotSpot (la lignevm nameaffiche quelque chose comme 'OpenJDK 64-Bit Server VM') — confirmant que c'est le moteur « Server » capable de JIT qui exécute votre bytecode, et non un simple interpréteur. - La chaîne de chargeurs affiche quelque chose comme
<unnamed> -> app -> platform -> bootstrap(null): chaque itération de boucle a remonté vers le parent, et la chaîne s'est terminée à un parentnull— le chargeur d'amorçage, qui est du code natif sans objet Java. La hiérarchie est réelle et observable, pas une métaphore. String.class.getClassLoader()vautnull— les typesjava.basedu noyau proviennent de ce même chargeur d'amorçage au sommet de la chaîne, ce qui explique précisément pourquoi le code applicatif ne peut jamais substituer son propreString. La délégation parent-d'abord fait son travail.- Les lignes mémoire montrent le tas utilisé en Ko mais un maximum de tas en Mo, et une valeur hors-tas séparée : les instances d'objets résident sur le tas géré par le GC, tandis que les métadonnées de classes (Metaspace) sont comptabilisées comme hors-tas — les deux régions sont suivies indépendamment.
depth(1)retourne5car chaque appel récursif a empilé son propre frame sur la pile JVM de ce thread et l'a dépilé au retour ; la pile est par thread et structurée en frames, c'est pourquoi une récursion incontrôlée se termine parStackOverflowErrorplutôt que de corrompre le tas.