Introduction
À la manière d’un artisan qui rangerait soigneusement ses outils dans son atelier dans des espaces distincts, la JVM (ou Java Virtual Machine) organise sa mémoire en plusieurs espaces ayant chacun leur utilité.
Vous êtes actuellement en train de lire le premier article d’une série en trois parties qui tentera d’expliquer les rouages de la JVM en détaillant dans le futur le fonctionnement des Garbage Collectors et les optimisations de la JVM Hotspot au runtime.
Il est important de comprendre comment Java gère sa mémoire afin de pouvoir écrire du code performant, éviter les fuites de mémoire et diagnostiquer les problèmes.
Présentation de La JVM
La JVM est une machine virtuelle dans laquelle vont s’exécuter nos programmes Java. En effet, les programmes Java sont compilés dans un premier temps en bytecode non interprétable par le système d’exploitation. La JVM exécute le bytecode via un interpréteur et un compilateur JIT qui compile à la volée les parties « chaudes » en code natif.
Elle va donc servir d’intermédiaire entre l’application et le système d’exploitation pour les opérations système. C’est ce fonctionnement qui a donné le slogan : “write once, run everywhere” au langage, car la JVM sert d’abstraction du système d’exploitation permettant d’exécuter un programme Java compilé sur n’importe quel système pour peu qu’il supporte la JVM.
Mais ce n’est pas tout ! La JVM étant un processus utilisateur, elle s’occupe d’allouer la mémoire nécessaire auprès du système d’exploitation et de gérer l’espace mémoire nécessaire à nos applications. Pour ce faire, elle divise l’espace mémoire en plusieurs “compartiments” :
- La Heap
- La Stack
- Le Metaspace
- Le Program Counter Register (PC Register)
- La Native Method Stack
Nous allons voir maintenant en détail l’utilité de chacun de ces espaces mémoire.
La Heap : le tas d’objet
La Heap est l’espace mémoire le plus conséquent de la JVM, puisque c’est celui qui va contenir l’ensemble des objets et des tableaux créés par notre application. Il est partagé par tous les threads applicatifs, permettant à plusieurs threads d’accéder à un même objet. Ne faites pas l’erreur que j’ai longtemps faite en pensant que ce dernier était structuré en tas : il n’y a aucune relation entre la Heap au sein de la JVM et la structure de données homonyme. À chaque fois que vous souhaitez instancier un objet en utilisant l’opérateur new, vous allouez de la mémoire dans la heap pour stocker l’objet et retourner une référence vers cet objet que l’on va stocker dans une variable.
Cette référence est une étiquette qui va nous permettre d’accéder à l’objet dans la heap et non l’objet en lui-même. C’est pourquoi il est possible de manipuler la référence d’un objet à travers plusieurs variables sans pour autant copier l’objet.
C’est la seule manière d’accéder à l’objet ; sans elle, on dit que l’objet est déréférencé. Un objet qui n’a plus de référence permettant d’y accéder devient alors éligible au Garbage Collector (ou ramasse-miettes) qui va se charger de collecter l’objet afin de libérer l’espace occupé.
La taille de la heap n’est pas fixe. On définit une borne minimale pour sa taille au démarrage, grâce au flag -Xms, ainsi qu’une borne maximale grâce au flag -Xmx. Exemple : java -Xms2G -Xmx5G Par défaut, elles sont calculées dynamiquement en fonction de la RAM physique de la machine (ou du conteneur dans les versions récentes de Java). Si on essaie d’instancier un objet dans une Heap “pleine”, un GC est déclenché. Si, après ce dernier, on n’a toujours pas assez de mémoire dans la Heap pour ce nouvel objet, une erreur OutOfMemoryError: Java heap space est lancée par la JVM et, en général, celle-ci n’est plus utilisable.
Il faut savoir que la heap peut (encore) être divisée en plusieurs espaces logiques en fonction du GC choisi ! Nous ne rentrerons pas plus dans le détail ici, car ce sera le sujet du prochain article. Alors stay tuned ;)
La Stack : la pile d’exécution
La stack (ou la pile en français) est une zone mémoire utilisée pour gérer l’exécution des méthodes. On parle d’une pile, car c’est la structure de données utilisée pour stocker chaque méthode appelée dans une frame (un élément de la pile).
C’est une structure LIFO (Last In First Out) ; cette caractéristique permet à la pile d’exécuter la dernière méthode appelée en premier.
Chaque thread dispose naturellement de sa propre pile d’exécution, afin de permettre l’exécution des fonctions. C’est également dans les frames de cette pile que l’on stocke les primitives et les références vers les objets de la heap (les variables locales) ; ce mécanisme induit par conséquent** la notion de scope d’une variable, puisque les éléments d’une frame ne sont pas accessibles dans une autre frame.
Que se passe-t-il donc quand on appelle une fonction avec des paramètres ? En Java, tout est strictement passé par valeur ; cela signifie que pour une primitive, la valeur est tout simplement copiée dans la frame de la fonction appelée. Pour les objets, c’est une copie de la référence vers l’objet dans la heap qui est enregistrée : on pointe toujours sur le même objet.
Depuis Java 21, le projet Loom apportant les virtuals thread permets de sauvegarder les stack d’un virtual thread mis en attente dans la heap afin de pouvoir le recharger sur un thread platform quand ce dernier va pouvoir reprendre son exécution.
Un des problémes que l’on peut rencontrer lors de l’utilisation d’appel récursif est l’explosion de la taille de la stack, cette dernière étant souvent de 1 MB (en fonction de l’architecture système, de l’OS & de la JVM) un dépassement de cette limite va déclencher une StackOverflowException arrêtant l’éxecution du thread en cours.
Pour y remédier, on peut revoir notre algorithme afin d’empiler moins de frame sur la stack en utilisant une boucle par exemple, ou calibrer la mémoire allouée à chaque stack en utilisant le flag Xss au lancement de la JVM : java -Xss2m
Le Metaspace
C’est dans le Metaspace que les metadonnées relatives aux classes sont chargés par les classloaders. Avant Java 8, cet espace mémoire se nommait le PermGen, était de taille fixe et faisait partie de la heap causant quelques erreurs de dépassements de mémoire. Elle a été depuis externalisé dans la mémoire native et sa taille peut grandir dynamiquement (elle peut aussi être plafonnée via -XX:MaxMetaspaceSize).
Pour chaque classe, les métadonnées (structures Klass) sont associées à un objet miroir java.lang.Class dans la heap qui va nous servir d’interface vers les métadonnées de la classe pour de la reflexion par exemple mais aussi que contenir les variables de classe (static). Chaque objet de la heap contient, dans son en-tête, un pointeur vers les métadonnées de sa classe dans le Metaspace.
Les metadonnées d’une classe ne sont collectées par le GC seulement si son classloader est lui-même collectable, c’est-à-dire s’il n’existe plus de références vivantes aux classes qu’il a chargées (instances, champs static, références JNI, threads, etc.).
Par conséquence, s’il reste une seule instance d’un objet chargé par un classloader, toutes les metadonnées des classes qu’il a chargé restent dans le Metaspace.
Dans la spécification de la JVM, vous ne retrouverez pas de mentions du Metaspace car elle définit une zone de mémoire appelée Method Area, le Metaspace en est l’implémentation dans Hotspot.
Si vous souhaitez plonger plus profondément dans le fonctionnement du Metapace, je vous invite à lire l’impressionnante suite d’articles de Thomas Stüfe à ce sujet : https://stuefe.de/posts/metaspace/what-is-metaspace/
Le Program Counter Register
Le PC register est un espace stockant l’adresse de l’instruction (en bytecode) en cours d’exécution dans un thread. Une fois l’instruction exécutée, il pointe sur la suivante de manière séquentielle. Ce pointeur permet de garder le fil de l’exécution d’un thread dans un environnement “multithreadé” et permet l’éxecution parallèle des threads. Dans le cas des virtuals threads, le PC est stocké dans la heap avec le stack du virtual thread dans un objet Continuation permettant la reprise de celui-ci. En effet, si la stack peut nous donner la méthode en cours d’éxecution, on peut voir le program counter comme le curseur dans le code sur l’instruction en cours d’exécution. Petit détail mais pas des moindres, dans le cas où le thread exécute une méthode native (d’une librairie C par exemple), la valeur du PC register est undefined (non défini).
La Native method stack
C’est l’équivalent de la stack pour les méthodes dites natives c’est à dire dans un autre langage que Java & non compilé en bytecode (exemple du C & C++).
Chaque thread Java peut donc s’appuyer sur deux piles :
- La JVM stack (pile “classique”) pour exécuter les méthodes Java bytecode.
- La native stack pour exécuter les méthodes natives.
En effet, au moment des méthodes java appellent du code natif, le thread bascule sur la native stack où il crée et utilise des frames native pendant que la JVM stack est mise en pause pour ensuite être reprise quand l’éxecution du code natif est terminé.
Les spécifications de la JVM n’impose pas le support des appels au code natif : une implémentation peut choisir de ne pas les supporter (alors la native method stack serait inutile). À l’inverse, une JVM peut aussi partager la pile native du système (ex. la pile C du thread) plutôt que d’entretenir une pile séparée ; c’est un détail d’implémentation, laissé à la discrétion de la JVM (c’est le cas de la JVM Hotspot !).
Conclusion
Nous avons enfin passé en revue, les principaux espaces mémoires utilisé par la JVM d’après la spécification officiel de Java. La JVM est presque un véritable système d’exploitation au fonctionnement très complexe mettant à mal l’exhaustivité de cet article qui bien n’évidemment ne se limitent pas à sa mémoire, j’espère donc pouvoir faire une suite d’articles couvrant les différents aspects alors restez connectez mais j’espère tout de même qu’il vous permettra de mieux appréhender le fonctionnellement de cette fabuleuse machine !