Articles

Tutoriel JVM – Architecture de la machine virtuelle Java expliquée aux débutants

Posted on

Que vous ayez utilisé Java pour développer des programmes ou non, vous avez peut-être entendu parler de la machine virtuelle Java (JVM) à un moment ou à un autre.

La JVM est au cœur de l’écosystème Java et permet aux programmes logiciels basés sur Java de suivre l’approche  » écrire une fois, exécuter partout « . Vous pouvez écrire du code Java sur une machine, et l’exécuter sur n’importe quelle autre machine en utilisant la JVM.

La JVM a été initialement conçue pour prendre en charge uniquement Java. Cependant, au fil du temps, de nombreux autres langages tels que Scala, Kotlin et Groovy ont été adoptés sur la plateforme Java. Tous ces langages sont collectivement connus sous le nom de langages JVM.

Dans cet article, nous allons en apprendre davantage sur la JVM, son fonctionnement et les différents composants qui la composent.

Avant de nous lancer dans la JVM, revenons sur le concept de machine virtuelle (VM).

Une machine virtuelle est une représentation virtuelle d’un ordinateur physique. Nous pouvons appeler la machine virtuelle la machine invitée, et l’ordinateur physique sur lequel elle fonctionne est la machine hôte.

Une seule machine physique peut exécuter plusieurs machines virtuelles, chacune avec son propre système d’exploitation et ses propres applications. Ces machines virtuelles sont isolées les unes des autres.

Qu’est-ce que la machine virtuelle Java ?

Dans les langages de programmation comme C et C++, le code est d’abord compilé en code machine spécifique à la plate-forme. Ces langages sont appelés des langages compilés.

En revanche, dans des langages comme JavaScript et Python, l’ordinateur exécute directement les instructions sans avoir à les compiler. Ces langages sont appelés langages interprétés.

Java utilise une combinaison des deux techniques. Le code Java est d’abord compilé en code octet pour générer un fichier de classe. Ce fichier de classe est ensuite interprété par la machine virtuelle Java pour la plateforme sous-jacente. Le même fichier de classe peut être exécuté sur n’importe quelle version de la JVM fonctionnant sur n’importe quelle plateforme et système d’exploitation.

Similaire aux machines virtuelles, la JVM crée un espace isolé sur une machine hôte. Cet espace peut être utilisé pour exécuter des programmes Java indépendamment de la plate-forme ou du système d’exploitation de la machine.

Architecture de la machine virtuelle Java

La JVM se compose de trois éléments distincts :

  1. Le chargeur de classes
  2. Mémoire d’exécution/zone de données
  3. Moteur d’exécution

Regardons chacun d’eux plus en détail.

Chargeur de classe

Lorsque vous compilez un fichier source .java, il est converti en code octet sous la forme d’un fichier .class. Lorsque vous essayez d’utiliser cette classe dans votre programme, le chargeur de classe la charge dans la mémoire principale.

La première classe à être chargée en mémoire est généralement la classe qui contient la méthode main().

Il y a trois phases dans le processus de chargement des classes : le chargement, la liaison et l’initialisation.

Loading

Le chargement consiste à prendre la représentation binaire (bytecode) d’une classe ou d’une interface portant un nom particulier, et à générer la classe ou l’interface originale à partir de celle-ci.

Il existe trois chargeurs de classe intégrés disponibles en Java :

  • Chargeur de classe bootstrap – C’est le chargeur de classe racine. C’est la superclasse du chargeur de classe d’extension et il charge les paquets Java standard comme java.langjava.netjava.utiljava.io, et ainsi de suite. Ces paquets sont présents à l’intérieur du fichier rt.jar et d’autres bibliothèques de base présentes dans le répertoire $JAVA_HOME/jre/lib.
  • L’Extension Class Loader – Il s’agit de la sous-classe du Bootstrap Class Loader et de la super-classe de l’Application Class Loader. Il charge les extensions des bibliothèques Java standard qui sont présentes dans le répertoire $JAVA_HOME/jre/lib/ext.
  • Application Class Loader – Il s’agit du chargeur de classe final et de la sous-classe de l’Extension Class Loader. Il charge les fichiers présents sur le classpath. Par défaut, le classpath est défini sur le répertoire courant de l’application. Le classpath peut également être modifié en ajoutant l’option de ligne de commande -classpath ou -cp.

La JVM utilise la méthode ClassLoader.loadClass() pour charger la classe en mémoire. Elle essaie de charger la classe en se basant sur un nom pleinement qualifié.

Si un chargeur de classe parent est incapable de trouver une classe, il délègue le travail à un chargeur de classe enfant. Si le dernier chargeur de classe enfant ne parvient pas non plus à charger la classe, il lance NoClassDefFoundError ou ClassNotFoundException.

La liaison

Après avoir été chargée en mémoire, une classe subit le processus de liaison. Lier une classe ou une interface consiste à combiner les différents éléments et dépendances du programme ensemble.

La liaison comprend les étapes suivantes :

Vérification : Cette phase vérifie l’exactitude structurelle du fichier .class en le contrôlant par rapport à un ensemble de contraintes ou de règles. Si la vérification échoue pour une raison quelconque, on obtient un VerifyException.

Par exemple, si le code a été construit avec Java 11, mais qu’il est exécuté sur un système sur lequel Java 8 est installé, la phase de vérification échouera.

Préparation : Dans cette phase, la JVM alloue de la mémoire pour les champs statiques d’une classe ou d’une interface, et les initialise avec des valeurs par défaut.

Par exemple, supposons que vous ayez déclaré la variable suivante dans votre classe :

private static final boolean enabled = true;

Pendant la phase de préparation, la JVM alloue de la mémoire pour la variable enabled et définit sa valeur à la valeur par défaut pour un booléen, qui est false.

Résolution : Dans cette phase, les références symboliques sont remplacées par des références directes présentes dans le pool de constantes d’exécution.

Par exemple, si vous avez des références à d’autres classes ou des variables constantes présentes dans d’autres classes, elles sont résolues dans cette phase et remplacées par leurs références réelles.

Initialisation

L’initialisation implique l’exécution de la méthode d’initialisation de la classe ou de l’interface (appelée <clinit>). Cela peut inclure l’appel du constructeur de la classe, l’exécution du bloc statique et l’affectation de valeurs à toutes les variables statiques. C’est l’étape finale du chargement de la classe.

Par exemple, lorsque nous avons déclaré le code suivant plus tôt :

private static final boolean enabled = true;

La variable enabled a été définie à sa valeur par défaut de false pendant la phase de préparation. Dans la phase d’initialisation, cette variable se voit attribuer sa valeur réelle de true.

Note : la JVM est multithread. Il peut arriver que plusieurs threads essaient d’initialiser la même classe en même temps. Cela peut entraîner des problèmes de concurrence. Vous devez gérer la sécurité des threads pour vous assurer que le programme fonctionne correctement dans un environnement multithread.

Zone de données d’exécution

Il y a cinq composants à l’intérieur de la zone de données d’exécution :

Examinons chacun d’eux individuellement.

Zone des méthodes

Toutes les données de niveau classe, telles que le pool de constantes d’exécution, les données des champs et des méthodes, ainsi que le code des méthodes et des constructeurs, sont stockées ici.

Si la mémoire disponible dans la zone des méthodes n’est pas suffisante pour le démarrage du programme, la JVM lance un OutOfMemoryError.

Par exemple, supposons que vous ayez la définition de classe suivante :

public class Employee { private String name; private int age; public Employee(String name, int age) { this.name = name; this.age = age; }}

Dans cet exemple de code, les données de niveau champ telles que name et age et les détails du constructeur sont chargés dans la zone de méthode.

La zone de méthodes est créée au démarrage de la machine virtuelle, et il n’y a qu’une seule zone de méthodes par JVM.

Zone de tas

Tous les objets et leurs variables d’instance correspondantes sont stockés ici. C’est la zone de données d’exécution à partir de laquelle la mémoire pour toutes les instances de classe et les tableaux est allouée.

Par exemple, supposons que vous déclarez l’instance suivante :

Employee employee = new Employee();

Dans cet exemple de code, une instance de Employee est créée et chargée dans la zone de tas.

Le tas est créé au démarrage de la machine virtuelle, et il n’existe qu’une seule zone de tas par JVM.

Note : Comme les zones Method et Heap partagent la même mémoire pour plusieurs threads, les données stockées ici ne sont pas thread safe.

Zone de pile

Chaque fois qu’un nouveau thread est créé dans la JVM, une pile d’exécution séparée est également créée en même temps. Toutes les variables locales, les appels de méthode et les résultats partiels sont stockés dans la zone de pile.

Si le traitement effectué dans un thread nécessite une pile d’une taille supérieure à celle qui est disponible, la JVM lance un StackOverflowError.

Pour chaque appel de méthode, une entrée est effectuée dans la mémoire de la pile qui est appelée Stack Frame. Lorsque l’appel de méthode est terminé, le Stack Frame est détruit.

Le Stack Frame est divisé en trois sous-parties :

  • Variables locales – Chaque frame contient un tableau de variables connu sous le nom de ses variables locales. Toutes les variables locales et leurs valeurs sont stockées ici. La longueur de ce tableau est déterminée au moment de la compilation.
  • Pile d’opérandes – Chaque trame contient une pile de type dernier entré-premier sorti (LIFO) connue sous le nom de sa pile d’opérandes. Celle-ci agit comme un espace de travail d’exécution pour effectuer toute opération intermédiaire. La profondeur maximale de cette pile est déterminée à la compilation.
  • Données de trame – Tous les symboles correspondant à la méthode sont stockés ici. Cela stocke également les informations du bloc de capture en cas d’exceptions.

Par exemple, supposez que vous avez le code suivant:

double calculateNormalisedScore(List<Answer> answers) { double score = getScore(answers); return normalizeScore(score);}double normalizeScore(double score) { return (score – minScore) / (maxScore – minScore);}

Dans cet exemple de code, des variables comme answers et score sont placées dans le tableau Local Variables. La pile des opérateurs contient les variables et les opérateurs nécessaires pour effectuer les calculs mathématiques de la soustraction et de la division.

Note : Comme la zone de pile n’est pas partagée, elle est intrinsèquement thread safe.

Compteur de programme (PC) Registres

La JVM supporte plusieurs threads en même temps. Chaque thread possède son propre registre PC pour contenir l’adresse de l’instruction JVM en cours d’exécution. Une fois l’instruction exécutée, le registre PC est mis à jour avec l’instruction suivante.

Piles de méthodes natives

La JVM contient des piles qui prennent en charge les méthodes natives. Ces méthodes sont écrites dans un langage autre que le Java, comme le C et le C++. Pour chaque nouveau thread, une pile de méthodes natives distincte est également allouée.

Moteur d’exécution

Une fois que le bytecode a été chargé dans la mémoire principale et que les détails sont disponibles dans la zone de données d’exécution, l’étape suivante consiste à exécuter le programme. Le moteur d’exécution s’en charge en exécutant le code présent dans chaque classe.

Cependant, avant d’exécuter le programme, le bytecode doit être converti en instructions en langage machine. La JVM peut utiliser un interprète ou un compilateur JIT pour le moteur d’exécution.

Interprète

L’interprète lit et exécute les instructions du bytecode ligne par ligne. En raison de l’exécution ligne par ligne, l’interpréteur est comparativement plus lent.

Un autre inconvénient de l’interpréteur est que lorsqu’une méthode est appelée plusieurs fois, chaque fois une nouvelle interprétation est nécessaire.

Compilateur JIT

Le compilateur JIT surmonte l’inconvénient de l’interpréteur. Le moteur d’exécution utilise d’abord l’interpréteur pour exécuter le code octet, mais lorsqu’il trouve du code répété, il utilise le compilateur JIT.

Le compilateur JIT compile ensuite l’ensemble du bytecode et le transforme en code machine natif. Ce code machine natif est utilisé directement pour les appels de méthode répétés, ce qui améliore les performances du système.

Le compilateur JIT comporte les composants suivants :

  1. Générateur de code intermédiaire – génère du code intermédiaire
  2. Optimiseur de code – optimise le code intermédiaire pour de meilleures performances
  3. Générateur de code cible – convertit le code intermédiaire en code machine natif
  4. Profileur – trouve les points chauds (code exécuté de manière répétée)

Pour mieux comprendre la différence entre l’interpréteur et le compilateur JIT, supposez que vous avez le code suivant :

int sum = 10;for(int i = 0 ; i <= 10; i++) { sum += i;}System.out.println(sum);

Un interpréteur va chercher la valeur de sum en mémoire pour chaque itération de la boucle, y ajouter la valeur de i, et la réécrire en mémoire. Cette opération est coûteuse car elle accède à la mémoire chaque fois qu’elle entre dans la boucle.

Cependant, le compilateur JIT reconnaîtra que ce code a un HotSpot, et effectuera des optimisations sur celui-ci. Il stockera une copie locale de sum dans le registre PC du thread et continuera à y ajouter la valeur de i dans la boucle. Une fois la boucle terminée, il écrira la valeur de sum en mémoire.

Note : un compilateur JIT prend plus de temps pour compiler le code que pour que l’interprète interprète le code ligne par ligne. Si vous comptez exécuter un programme une seule fois, il vaut mieux utiliser l’interprète.

Collecteur d’ordures

Le collecteur d’ordures (GC) collecte et supprime les objets non référencés de la zone du tas. Il s’agit du processus de récupération automatique de la mémoire inutilisée à l’exécution en les détruisant.

La collecte d’ordures rend la mémoire Java efficace car car elle supprime les objets non référencés de la mémoire du tas et libère de l’espace pour les nouveaux objets. Elle implique deux phases :

  1. Marque – dans cette étape, la GC identifie les objets inutilisés en mémoire
  2. Sweep – dans cette étape, la GC supprime les objets identifiés lors de la phase précédente

La collecte d’ordures est effectuée automatiquement par la JVM à intervalles réguliers et ne doit pas être traitée séparément. Il peut également être déclenché en appelant System.gc(), mais l’exécution n’est pas garantie.

La JVM contient 3 types différents de garbage collectors :

  1. GC en série – Il s’agit de la mise en œuvre la plus simple de GC, et elle est conçue pour les petites applications fonctionnant sur des environnements à un seul thread. Elle utilise un seul thread pour la collecte des ordures. Lorsqu’elle s’exécute, elle conduit à un événement « stop the world » où l’application entière est mise en pause. L’argument de la JVM pour utiliser Serial Garbage Collector est -XX:+UseSerialGC
  2. GC parallèle – Il s’agit de l’implémentation par défaut de GC dans la JVM, et est également connu sous le nom de Throughput Collector. Elle utilise plusieurs threads pour la collecte des ordures, mais met toujours l’application en pause lors de son exécution. L’argument de la JVM pour utiliser Parallel Garbage Collector est -XX:+UseParallelGC.
  3. Garbage First (G1) GC – G1GC a été conçu pour les applications multithreads qui ont une grande taille de tas disponible (plus de 4 Go). Il partitionne le tas en un ensemble de régions de taille égale, et utilise plusieurs threads pour les analyser. G1GC identifie les régions contenant le plus de déchets et effectue la collecte des déchets sur cette région en premier. L’argument de la JVM pour utiliser le G1 Garbage Collector est -XX:+UseG1GC

Note : Il existe un autre type de garbage collector appelé Concurrent Mark Sweep (CMS) GC. Cependant, il est déprécié depuis Java 9 et complètement supprimé dans Java 14 au profit de G1GC.

Java Native Interface (JNI)

Parfois, il est nécessaire d’utiliser du code natif (non-Java) (par exemple, C/C++). Cela peut être dans les cas où nous devons interagir avec le matériel, ou pour surmonter les contraintes de gestion de la mémoire et de performance de Java. Java prend en charge l’exécution de code natif via l’interface Java Native Interface (JNI).

JNI agit comme un pont pour permettre les paquets de support pour d’autres langages de programmation tels que C, C++, et ainsi de suite. Cela est particulièrement utile dans les cas où vous devez écrire du code qui n’est pas entièrement pris en charge par Java, comme certaines fonctionnalités spécifiques à la plate-forme qui ne peuvent être écrites qu’en C.

Vous pouvez utiliser le mot-clé native pour indiquer que l’implémentation de la méthode sera fournie par une bibliothèque native. Vous devrez également invoquer System.loadLibrary() pour charger la bibliothèque native partagée en mémoire, et mettre ses fonctions à la disposition de Java.

Les bibliothèques de méthodes natives

Les bibliothèques de méthodes natives sont des bibliothèques écrites dans d’autres langages de programmation, tels que C, C++ et assembly. Ces bibliothèques sont généralement présentes sous la forme de fichiers .dll ou .so. Ces bibliothèques natives peuvent être chargées par l’intermédiaire de JNI.

Erreurs JVM courantes

  • ClassNotFoundExcecption – Cela se produit lorsque le chargeur de classes essaie de charger des classes en utilisant Class.forName()ClassLoader.loadClass() ou ClassLoader.findSystemClass() mais qu’aucune définition de la classe portant le nom spécifié n’est trouvée.
  • NoClassDefFoundError – Cela se produit lorsqu’un compilateur a réussi à compiler la classe, mais que le Class Loader n’est pas en mesure de localiser le fichier de classe au moment de l’exécution.
  • OutOfMemoryError – Cela se produit lorsque la JVM ne peut pas allouer un objet parce qu’il n’a plus de mémoire, et qu’aucune mémoire supplémentaire n’a pu être mise à disposition par le garbage collector.
  • StackOverflowError – Cela se produit si la JVM manque d’espace lors de la création de nouveaux cadres de pile pendant le traitement d’un thread.

Conclusion

Dans cet article, nous avons abordé l’architecture de la machine virtuelle Java et ses différents composants. Souvent, nous ne creusons pas profondément dans la mécanique interne de la JVM ou ne nous soucions pas de son fonctionnement pendant que notre code fonctionne.

C’est seulement lorsque quelque chose ne va pas, et que nous devons bidouiller la JVM ou réparer une fuite de mémoire, que nous essayons de comprendre sa mécanique interne.

C’est également une question d’entretien très populaire, tant au niveau junior que senior pour les rôles backend. Une compréhension profonde de la JVM vous aide à écrire un meilleur code et à éviter les pièges liés aux erreurs de pile et de mémoire.

Merci de rester avec moi jusqu’à présent. J’espère que vous avez aimé l’article. Vous pouvez vous connecter avec moi sur LinkedIn où je discute régulièrement de technologie et de vie. Jetez également un œil à certains de mes autres articles et à ma chaîne YouTube. Bonne lecture 🙂

Laisser un commentaire

Votre adresse e-mail ne sera pas publiée. Les champs obligatoires sont indiqués avec *