JVM Hardcore – Part 1 – Introduction à la JVM

academic_duke Avant de nous intéresser au bytecode Java, il est nécessaire de comprendre comment fonctionne – dans les grandes lignes – la JVM.

Tous les articles déjà publiés de la série portent le tag jvmhardcore.

Le JRE est composée de l’API Java et de la JVM. Le rôle de la JVM est de lire une application composée de fichiers .class sous différentes formes (zip, jar, war, un simple tableau d’octets, etc.) à l’aide d’un chargeur de classes et de l’exécuter, ainsi que l’API Java.

D’une manière générale, une Machine Virtuelle (VM) est une implémentation logicielle d’une architecture physique ou hypothétique. Il existe deux types de Machines Virtuelles, les VMs système et les VMs applicatives. La JVM fait partie de la seconde catégorie. Elle est exécutée comme n’importe quelle application sur un système d’exploitation hôte et est associée à un seul processus. Son but est de fournir un environnement de programmation indépendant de la plate-forme qui fait abstraction du matériel sous-jacent et/ou du système d’exploitation, et permet à un programme de s’exécuter de la même manière sur n’importe quelle plate-forme. La JVM utilise un bytecode, un langage intermédiaire entre Java (le langage utilisateur) et le langage machine (Machine Virtuelle en anglais).

Bien que dans JVM, le “J” signifie Java, il est théoriquement possible d’implémenter un compilateur compilant n’importe quel langage en bytecode pour la JVM. Mais en pratique certains langages sont très difficiles à implémenter de manière efficiente.

Quelques particularités de la JVM :

  • Machine Virtuelle basée sur le modèle de la pile : Les architectures d’ordinateurs les plus populaires telles que l’architecture Intel x86 et l’architecture ARM sont basées sur des registres. Cependant la JVM est basée sur le modèle de la pile (LIFO, stack-based). Dalvik, la machine virtuelle d’Android ne suit pas la JVMS, puisqu’elle est basée sur des registres.
  • Références symboliques : Tous les types (classes et interfaces), à l’exception des primitifs (boolean, int, float, etc.), sont désignés par une référence symbolique et non une référence explicite d’une adresse mémoire.
  • Ramasse-miettes : L’instance d’une classe est créée explicitement par le code du développeur et est détruite automatiquement par le ramasse-miettes.
  • Garantit de l’indépendance de la plate-forme en définissant clairement les types primitifs : La taille des types primitifs est fixée par la JVMS.
  • Ordre des octets du réseau : Toujours dans un souci de maintenir l’indépendance de la plate-forme, la représentation des données doit être fixée. L’ordre des octets du réseau a été choisi. Il s’agit en réalité de l’orientation big-endian (l’octet de poids le plus fort se trouve à gauche et est stocké en premier en mémoire).

Zones de Données d’Exécution (Runtime Data Areas)

Le code Java est exécuté en suivant le processus ci-dessous :

02_01

Un chargeur de classes charge le bytecode dans les Zones de Données d’Exécution et le moteur d’exécution l’exécute.

Pour l’instant, seul l’étude des Zone de Données d’Exécution nous sera utile pour comprendre le format d’un fichier .class.

02_02

La JVM définit différentes zones de données d’exécution qui sont utilisées durant l’exécution d’un programme. Certaines de ces zones de données sont créées lorsque la JVM est lancée et sont détruites lorsqu’elle s’arrête. Les autres zones sont propres à chaque fil d’exécution (thread). Les zones de données par fils d’exécution sont créées lorsque le fil d’exécution est créé et sont détruites lorsqu’il se termine.

Le tas (Heap)

La JVM a un tas partagé par tous les fils d’exécution. Le tas est une zone de données d’exécution dans laquelle toutes les instances de classes et les tableaux sont stockés. Le tas est créé au démarrage de la JVM. Les objets n’étant pas explicitement désalloués de la mémoire, lorsqu’ils ne sont plus référencés le ramasse-miettes les supprime du tas.

Zone de Méthodes (Method Area)

Lorsque le chargeur de classes charge une classe, il stocke sa structure (le pool de constantes, les données des champs et des méthodes, et le code des méthodes et des constructeurs) dans la Zone de Méthodes. Il s’agit d’un espace mémoire partagé par tous les fils d’exécution et la JVMS n’impose pas qu’il soit libéré par le ramasse-miettes et donc qu’il fasse partit du tas (heap).

Pool de constantes d’exécution (Runtime Constant Pool)

Le pool de constantes est une représentation d’exécution par classe ou par interface du pool de constantes défini dans un fichier .class, sur lequel nous reviendrons dans quelques semaines.

Il contient plusieurs sortes de constantes, des littérales numériques connues lors de la compilation aux références de champs ou de méthodes devant être résolus lors de l’exécution. Le pool de constantes d’exécution est comparable à une table de symboles utilisée par une langage de programmation conventionnel, bien qu’il contienne plus de types de données qu’une table de symboles standard.

Chaque pool de constantes d’exécution est alloué à partir de la zone de méthodes. Le pool de constantes d’exécution pour une classe ou une interface est construit lorsque la classe ou l’interface est créée par la JVM.

Fils d’exécution (Threads)

Il existe deux types de fils d’exécution Java :

  • Les fils d’exécution de type démon
  • Et les fils d’exécution de type non-démon

Les fils d’exécution de type démon s’arrêtent lorsqu’il n’y a plus de fils d’exécution de type non-démon. Même si une application ne crée pas de fils d’exécution, la JVM en crée plusieurs, dont la plupart sont des fils d’exécution de type démon.

Le fil d’exécution principal d’une application est celui exécutant la méthode main(). Il est le premier et le dernier fil d’exécution de type non-démon à s’exécuter. Lorsque l’on atteint la fin de la méthode main() – et de tous les fils d’exécution de type non-démon lancés lors de son exécution – le fil d’exécution se termine, mettant fin aux fils d’exécution de type démon et par conséquent à l’exécution de la JVM.

A noter qu’un fil d’exécution de type démon peut être créé au sein d’une application.

Pointeur d’instruction (Program counter)

La JVM peut supporter l’exécution de plusieurs fils d’exécution en parallèle. Chaque fil d’exécution a son propre pointeur d’instruction (PC – Program Counter). A tout moment, chaque fil d’exécution exécute le code d’une seule méthode, la méthode courante pour ce fil d’exécution. Si cette méthode n’est pas native, le pointeur d’instruction contient l’adresse de l’instruction de la JVM en cours d’exécution. Si la méthode en cours d’exécution par le fil d’exécution est native, le pointeur d’instruction est indéfini.

Piles Java (Java Virtual Machine Stacks)

Chaque fil d’exécution de la JVM a une pile qui lui est propre, créée en même temps que le fil d’exécution. Une pile stocke des cadres (identifiés par C0, C1, etc. dans l’illustration ci-dessous).

02_03

Piles de méthodes natives (Native Method Stacks)

A l’instar des méthodes Java qui sont exécutées dans les piles Java, les méthodes natives (généralement en C/C++) sont exécutées dans les piles de méthodes natives. Les piles de méthodes natives sont généralement allouées par fil d’exécution lorsqu’il est créé.

Cadres (Frames)

Un cadre est utilisé pour stocker des données et des résultats partiels, ainsi que pour effectuer des liaisons dynamiquement, retourner des valeurs de méthodes et transmettre des exceptions.

02_04

Un nouveau cadre est créé à chaque fois qu’une méthode est appelée. Un cadre est détruit lorsque l’exécution de la méthode est terminée, que ce soit normalement ou non (si une exception est levée). Les cadres sont alloués dans la pile Java du fil d’exécution créant le cadre. Chaque cadre a son propre tableau de variables locales, sa pile d’opérandes et une référence vers le pool de constantes d’exécution de la classe de la méthode courante.

Seulement un cadre est actif à la fois par fil d’exécution, le cadre de la méthode en cours d’exécution. Ce cadre est appelé le cadre courant, et sa méthode la méthode courante. La classe dans laquelle la méthode courante est définie est la classe courante.

Un cadre n’est plus courant si sa méthode appelle une autre méthode ou si sa méthode se termine. Quand une méthode est appelée, un nouveau cadre est créé et devient le cadre courant lorsque le contrôle est transféré à la nouvelle méthode. Au retour de la méthode, le cadre courant retransmet le résultat de sa méthode au cadre précédent – s’il existe -. Le cadre courant est alors supprimé et le cadre précédent redevient le cadre courant.

Variables locales

Chaque cadre contient un tableau de variables nommé variables locales. La taille du tableau est déterminé lors de la compilation (nous y reviendrons dans une partie ultérieure).

Les variables locales sont adressées par index comme n’importe quel tableau d’un langage de programmation standard. L’index de la première variable locale est zéro et celui de la dernière est égal à la taille du tableau moins un.

La JVM utilise les variables locales pour passer des paramètres à la méthode appelée. Lorsqu’une méthode de classe (statique) est appelée, tous les paramètres sont stockés dans les variables locales de manière consécutive à partir de l’index zéro. Lorsqu’une méthode d’instance est appelée, la variable à l’index zéro est toujours une référence de l’objet (this) sur laquelle la méthode a été appelée. Les paramètres sont quant à eux stockés de manière consécutive à partir de l’index un.

Les variables locales sont aussi utilisées pour stocker des résultats partiels.

Pile d’opérandes

Chaque cadre contient une pile Last In First Out (LIFO) nommée pile d’opérandes. La taille maximum de la pile est déterminée lors de la compilation (nous y reviendrons aussi dans une partie ultérieure). La pile d’opérande est vide lorsque le cadre la contenant est créé.

Chaque entrée de la pile peut contenir une valeur de n’importe quel type connu de la JVM.

Liaison dynamique

Chaque cadre contient une référence pointant vers le pool de constantes d’exécution pour que le type de la méthode courante supporte la liaison dynamique du code de la méthode. En d’autres termes lorsqu’une méthode d’une classe A fait référence à une classe B, dans un fichier .class le lien entre A et B est symbolique. La liaison dynamique traduit cette référence symbolique en une référence concrète, chargeant si nécessaire la classe B et traduisant les différents accès à cette classe (champs, méthodes d’instance, etc) par des offsets permettant de localiser leur position en mémoire.

[slideshare id=25352975&doc=0205-130818082547-phpapp01]

Notes :

  • Dans le cadre correspondant à la méthode main(), la variable à l’index 0 (ra) correspond à une référence au tableau passé en paramètre (nommé “a” ici).
  • vl0 et vl1 correspondent aux variables locales d’index 0 et 1.

Si certains points vous semblent encore obscures, normalement tout devrait devenir plus clair dans les prochaines parties. Nous aborderons notamment le cas de la surchage/rédéfinition d’une méthode

What’s next ?

Dans la prochaine partie, nous nous intéresserons à un outil nommé Plume que nous utiliserons au cours des prochains articles. Plume est un assembleur/désassembleur de bytecode, qui – comme Jasmin – permet d’écrire un fichier au format texte en utilisant une syntaxe proche de celle décrite dans la spécification de la Machine Virtuelle Java pour le transformer en un fichier .class. Plume peut aussi être utilisé comme ASM pour créer du bytecode à la volée en Java, le charger à l’aide d’un chargeur de classes et l’exécuter.

Plume ayant un objectif éducatif, pour son implémentation – que nous étudierons – la simplicité du code a été privilégiée face aux performances.

Nombre de vue : 286

COMMENTAIRES 4 commentaires

  1. Merci pour ce step1, j’attends la suite avec impatience.

    Une remarque:
    “Le fil d’exécution principal d’une application est celui exécutant la méthode main(). Il est le premier et le dernier fil d’exécution de type non-démon à s’exécuter.
    Lorsque l’on atteint la fin de la méthode main() – et de tous les fils d’exécution de type non-démon lancés lors de son exécution – le fil d’exécution se termine, mettant fin aux fils d’exécution de type non-démon et par conséquent à l’exécution de la JVM.”
    Ce sont les fils d’exécution de type démon qui sont arrêtés, non ?

  2. Yohan BESCHI dit :

    Exact, sinon la phrase n’a aucun sens.
    L’erreur a été corrigée.

    Merci pour ta remarque.
    L’article suivant ne saurait tarder.

  3. […] Quand une méthode a terminé de s’exécuter, elle doit rendre le contrôle à la méthode appelante. L’appelant attend généralement une valeur/référence de la méthode appelée. Après qu’une instruction xreturn (où x est un type) soit exécutée, le contrôle est transféré à l’instruction suivant l’une des instructions invoke (utilisées pour appeler un méthode, nous étudierons les instructions invoke en détail dans une prochaine partie). La valeur au sommet de la pile avant l’exécution d’un instruction xreturn est retournée à l’appelant et est placée au sommet de la pile du cadre contenant la méthode appelante comme nous l’avons vu dans la partie JVM Hardcore – Part 1 – Introduction à la JVM. […]

  4. […] nous l’avons vu dans l’article Part 1 – Introduction à la JVM les variables locales désignent un tableau présent dans chaque cadre […]

AJOUTER UN COMMENTAIRE