Tutoriel sur la compréhension de la machine virtuelle Java

Image non disponible


précédentsommairesuivant

I. Préambule

I-A. Passé, présent et futur de la JVM

Depuis le début, la devise de Java est : « Write once, run everywhere ». Ceci a été possible grâce à sa machine virtuelle qui - elle - est dépendante de la plate-forme, mais qui exécute une forme de code intermédiaire, le bytecode. Il n'aura pas fallu longtemps pour voir apparaître de nombreux langages, anciens ou nouveaux, compilés en bytecode pour s'exécuter sur la JVM. Ce phénomène a été transcendé ces dernières années, depuis l'apparition dans Java 7 de l'instruction invokedynamic rendant - potentiellement - n'importe quel langage typé dynamiquement extrêmement rapide. On compte aujourd'hui près de 250 implémentations de langages pour la JVM.

Les plus connus/nouveaux étant :

Il est difficile de dire de quoi sera fait l'avenir, mais nous pouvons constater que trois langages de cette liste ont plus d'une décennie et peu nombreux sont ceux à être utilisés dans le monde de l'entreprise, en tout cas en France. Néanmoins, ils ont tous leur personnalité, avec leurs points forts et leurs points faibles, et il faut considérer que l'avenir sera probablement favorable aux polyglottes, avec des applications constituées de plusieurs langages. Mais, il faudra encore compter sur Java pendant de nombreuses années.

Si l'on remonte quelques années en arrière, il est intéressant de mentionner qu'avant les années 2000, le fait de pouvoir charger du code à la volée, depuis une base de données, ou même un serveur externe, était mis en avant. La technologie des applets utilisait pleinement ce concept. Malheureusement, nous savons tous ce qu'il en est advenu…

Aujourd'hui, l'architecture des applications Java est très loin d'être exotique. Nous nous contentons de développer des applications Web - et de moins en moins de clients lourds - que nous packageons en WAR ou EAR. Et l'architecture révolutionnaire du système de sécurité de Java 2 impacte de nombreuses API de la JDK, les rendant plus difficiles à maintenir, sans que nous puissions nous en débarrasser pour des questions de rétrocompatibilité. Jigsaw sera peut-être une solution à ces divers problèmes.

I-B. Sommaire

Cette première partie fera office de sommaire pour toutes les parties à venir.

Au cours de cette série, nous aborderons les sujets suivants :

  • le Bytecode Java ;
  • l'implémentation de langages pour la JVM ;
  • le fonctionnement d'une machine virtuelle.

I-C. What's next ?

Dans la prochaine partie, nous rentrerons de plain-pied dans le monde de la JVM. Seuls les éléments indispensables à la compréhension des parties suivantes seront abordés, puisque l'objectif n'est pas de vous noyer sous une masse d'informations indigestes, mais d'expliquer petit bout par petit bout le fonctionnement de la JVM.

I-D. Utiliser le code

I-D-1. Github

Tout le code présenté dans cette partie et les suivantes est disponible sur Github. Chaque partie aura son propre tag et sa propre branche, contenant aussi le code des parties précédentes. La branche master contiendra le code du dernier article publié.

Par exemple, le code de cette partie est disponible sur le tag et la branche nommés « part00 » et la branche master jusqu'à la publication de la partie suivante (c'est-à-dire la semaine prochaine).

I-D-2. Chaîne de compilation

Avant de voir un peu de code, il nous faut évoquer les outils qui nous seront nécessaires tout au long de ces chapitres pour compiler et tester les exemples.

I-D-2-a. ANT

En cours de ces parties, pour compiler, tester et packager les différents projets que nous allons créer, nous utiliserons Ant, un outil souple, simple à utiliser et fonctionnant dans tout environnement ayant une JVM, puisqu'il est écrit en Java.

Je vous invite donc à télécharger Ant, à le désarchiver et à ajouter dans votre variable d'environnement PATH, le chemin vers le répertoire <chemin_de_ant>/bin.

Pour plus de détails sur l'utilisation de Ant, je vous invite à consulter l'ouvrage suivant : Ant in Action.

I-D-3. Organisation du projet

Ant offrant une liberté infinie, il est nécessaire d'adopter certaines conventions. L'organisation standard d'un projet aura donc la forme suivante :

 
Sélectionnez
project
|  +- 01_src (code source)
|  |  +- main
|  |  |  +- java
|  |  |  +- resources
|  |  +- test
|  |  |  +- java
|  |  |  +- resources
|  +- 02_build (généré)
|  |  |  +- classes
|  |  |  +- junit-data
|  |  |  +- junit-reports
|  |  |  +- test-classes
|  +- 03_dist (généré)

Cette série de parties allant couvrir de nombreux sujets différents, nous allons avoir plusieurs projets, il nous faut donc un niveau supplémentaire :

 
Sélectionnez
jvm_hardcore
|  +- 01_conf (fichiers de configuration Ant)
|  +- 02_libs (*.jar)
|  +- 03_projects
|  |  +- mystery
|  |  +- [...]
|  +- 04_archives (tous les JARs générés par le projet)

I-E. Tester les exemples

Il est très simple de compiler tout le code en une seule fois :

 
Sélectionnez
<chemin_jvm_hardcore>$ ant

Pour supprimer les répertoires 02_build et 03_dist de tous les projets, et le répertoire 04_archives :

 
Sélectionnez
<chemin_jvm_hardcore>$ ant clean-achives

Les autres targets sont les suivantes :

  • clean : supprime les répertoires 02_build et 03_dist de tous les projets ;
  • compile : compile les sources Java de tous les projets ;
  • test : lance les tests unitaires de tous les projets ;
  • archive : génère un JAR pour tous les projets.

Toutes ces targets sont aussi disponibles pour chacun des projets, comme :

 
Sélectionnez
<chemin_du_projet_mystery>$ ant compile

qui compilera les sources du projet mystery uniquement.

Pour exécuter les tests d'une classe ou une méthode, il est nécessaire d'aller à la racine du projet auquel la classe ou méthode appartiennent. Par exemple pour exécuter la méthode de test byteToHex0Test() de la classe org.isk.jvmhardcore.mystery.ByteToHexTest :

 
Sélectionnez
<mystery_project_path>$ ant test -DtestClass=org.isk.jvmhardcore.mystery.ByteToHexTest -DtestMethod=byteToHex0Test

I-F. Nombre magique

Le code est disponible sur Github (tag et branche)

L'exemple suivant a pour but d'introduire le dumping d'un fichier .class et sa construction à partir d'un fichier texte contenant des caractères hexadécimaux au format texte (les retours à la ligne sont présents uniquement pour ne pas déformer la page).

 
Sélectionnez
CAFEBABE00000034001D0A0006000F09001000110800120A001300140700150700160100063C696
                        E69743E010003282956010004436F646501000F4C696E654E756D6265725461626C650100046D61
                        696E010016285B4C6A6176612F6C616E672F537472696E673B295601000A536F7572636546696C6
                        501000C4D7973746572792E6A6176610C000700080700170C0018001901002248656C6C6F20576F
                        726C6421204A564D2048617264636F726520726F636B732E2E2E07001A0C001B001C0100074D797
                        3746572790100106A6176612F6C616E672F4F626A6563740100106A6176612F6C616E672F537973
                        74656D0100036F75740100154C6A6176612F696F2F5072696E7453747265616D3B0100136A61766
                        12F696F2F5072696E7453747265616D0100077072696E746C6E010015284C6A6176612F6C616E67
                        2F537472696E673B2956002100050006000000000002000100070008000100090000001D0001000
                        1000000052AB70001B100000001000A000000060001000000010009000B000C0001000900000025
                        0002000100000009B200021203B60004B100000001000A0000000A0002000000030008000400010
                        00D00000002000E

Source

Pour ceux qui l'ont remarqué, CAFEBABE, n'est pas une blague, il s'agit du « nombre magique » qui apparaît dans tous les fichiers .class, créés hier, aujourd'hui et demain.

Pour construire une classe à partir de la chaîne de caractères présentée ci-dessus, il suffit d'ouvrir un Shell et d'exécuter les commandes suivantes :

 
Sélectionnez
<projet_mystery>$ ant execute -Dargs="assemble=01_src/test/resources/Mystery.hex"
<projet_mystery>$ java Mystery
[Message mystère]

Il est aussi possible de créer un fichier .hex à partir d'un fichier .class.

 
Sélectionnez
<projet_mystery>$ ant execute -Dargs="dump=Mystery.class"

Notes :

  • dans l'exemple ci-dessus, le fichier Mystery.class doit être à la racine du projet ;
  • -Dargs est un argument défini dans la target execute de Ant, dont la valeur est passée à la méthode main() de la classe AssembleAndDump ;
  • assemble et dump, sont des paramètres de notre mini application. Seul l'un ou l'autre peut être utilisé lors de l'exécution de l'application (pas les deux à la fois). Ils prennent des chemins relatifs par rapport au répertoire courant ;
  • les fichiers de résultat (.class ou .hex) sont créés dans le répertoire courant. Ils portent le même nom que le fichier original, seule l'extension change ;
  • le paramètre help affiche l'aide.

Il est aussi possible d'exécuter l'application sans passer par Ant. Le jar mystery est disponible dans les répertoires 04archives et 03projects/mystery/03_dist :

 
Sélectionnez
<chemin_jvm_hardcore>$ java -jar mystery.<date>.jar assemble="../03_projects/mystery/Mystery.hex"

où le résultat peut avoir la valeur 20130726.123057.

I-F-1. Implémentation

Le code de cette application n'a rien d'extraordinaire. Mais vous pouvez tout de même le consulter, ainsi que les tests unitaires. Néanmoins, la méthode byteToHex() mérite notre attention.

La méthode byteToHex() est utilisée pour dumper les fichiers .class. Après ouverture du fichier dont le chemin est passé en paramètre - de l'application -, nous récupérons un tableau d'octets que nous passons à la méthode. Chaque élément du tableau est converti en valeur hexadécimale ASCII de deux caractères ajoutés dans un nouveau tableau d'octets. Ce nouveau tableau d'octets est ensuite retourné par la méthode, puis écrit dans un nouveau fichier.

Pour comprendre la méthode byteToHex(), il faut tout d'abord prendre conscience que manipuler un fichier signifie généralement manipuler un tableau d'octets (nous verrons plus tard qu'il y bien d'autres solutions - parfois plus simples -, mais ici l'objectif étant éducatif nous allons souvent recréer la roue).

Pour rappel, un octet (ou un byte) est égal à huit bits ou deux nibbles. En hexadécimal, quatre bits (ou un nibble) sont représentés par un caractère de 0 à F (dans cet exemple nous nous contenterons des lettres en majuscules). Par conséquent, convertir un octet en hexadécimal ASCII de deux caractères est théoriquement extrêmement simple, puisqu'il suffit de découper notre octet en deux parties de quatre bits chacune représentée par un caractère. Cependant, en pratique, il y a quelques pièges à éviter.

En prenant par exemple le nombre 94, nous pouvons écrire un test unitaire de la façon suivante :

 
Sélectionnez
1.
2.
3.
4.
5.
6.
7.
8.
@Test
public void byteToHex0Test() {
    // 94(10) = 5E(16) = 0101 1110(2)
    final byte number = 94;
    final byte[] hex = byteToHex0(number);
 
    Assert.assertArrayEquals(new byte[]{5, 14}, hex);
}

Source

Dans ce test, nous obtenons deux nombres décimaux. Pour les remplacer par des caractères, il suffit de prendre le tableau suivant :

 
Sélectionnez
1.
byte[] chars = {'0','1','2','3','4','5','6','7','8','9','A','B','C','D','E','F'};

Les nombres décimaux sont utilisés comme index du tableau :

 
Sélectionnez
1.
2.
chars[5] == '5'
chars[14] == 'E'

Il nous faut à présent implémenter la méthode

 
Sélectionnez
1.
byte[] byteToHex0(byte number)

La première question qui nous vient à l'esprit est : « Comment découper un octet en deux nibbles ? »

Pour y répondre, nous devons tout d'abord représenter le nombre décimal au format binaire (0101 1110). Nous souhaitons donc avoir d'un côté « 0101 » et de l'autre «  1110 ». Pour récupérer les quatre premiers bits, le plus simple est de décaler les bits de quatre positions vers la droite :

 
Sélectionnez
1.
94 >> 4 // 0101 1110 >> 4 = 0000 0101

quant aux quatre derniers, nous pouvons masquer les quatre premiers :

 
Sélectionnez
1.
2.
3.
4.
94 & 0x0f //   0101 1110 
          // & 0000 1111
          // -----------
          // = 0000 1110

Un élément important à prendre en compte est qu'avant l'exécution d'une opération de manipulation de bits, les primitifs de type byte, short et char sont promus en int (lors d'une opération de décalage de bits une promotion unaire est effectuée et lors d'une opération logique bit à bit une promotion binaire). De fait, bien que les opérations représentées en commentaire ne comptent que 8 bits, il y en a 32 en réalité. 94 étant positif, cela n'a pas d'importance.

De plus, dans les deux exemples précédents, les deux opérandes de chaque opération étant des valeurs littérales il est possible d'assigner le résultat à une variable de type byte. Néanmoins, si l'un des opérandes est une variable il est obligatoire d'effectuer explicitement un downcast ; et il en est de même dans certains cas d'utilisation de valeurs littérales. La priorité du compilateur est d'éviter - autant que possible - tout overflow ou perte d'information.

Implémentons à présent notre méthode :

 
Sélectionnez
1.
2.
3.
4.
5.
6.
public byte[] byteToHex0(byte number) {
    final byte[] bytes = new byte[2];
    bytes[0] = (byte)(number >> 4);
    bytes[1] = (byte)(number & 0x0f);
    return bytes;
}

Source

Si vous lancez le test unitaire, il devrait se terminer en succès.

 
Sélectionnez
1.
$ ant test -DtestClass=org.isk.jvmhardcore.mystery.ByteToHexTest -DtestMethod=byteToHex0Test

Il est temps à présent de se demander si notre test est complet ! Y a-t-il des cas que nous n'avons pas testés ?

Bien évidemment, si je pose la question, la réponse est non. En Java, un byte représente un nombre signé. Il nous faut donc tester une valeur négative.

Il est important de noter qu'en bytecode toutes les valeurs sont positives, or en utilisant la méthode statique byte[] java.nio.Files.readAllBytes() chaque octet est placé dans une variable de type byte. Par conséquent, les valeurs supérieures à 127 sont représentées sous la forme de nombres négatifs (cf. Complément à deux).

Prenons par exemple le nombre -23 et écrivons un nouveau test :

 
Sélectionnez
1.
2.
3.
4.
5.
6.
7.
@Test
public void byteToHex1Test() {
    // -23(10) = E9(16) = 1110 1001 = 233(10)
    final byte number = -23;
    final byte[] hex = byteToHex0(number);
 
    Assert.assertArrayEquals(new byte[]{14, 9}, hex);

Source

À présent le test unitaire est en échec, comme le confirme le test suivant (Source) :

 
Sélectionnez
$ ant test -DtestClass=org.isk.ByteToHexTest -DtestMethod=byteToHex1Test

Reprenons donc notre méthode byteToHex0(). La méthode fait quatre lignes ; la première et la dernière ne font pas grand-chose. Par conséquent, le problème doit venir de l'une des deux autres, voire les deux.

Commençons par la première :

 
Sélectionnez
-23 >> 4 // 1111 1111 1111 1111 1111 1111 1110 1001 >> 4
         // = 1111 1111 1111 1111 1111 1111 1110

ce qui n'est absolument pas ce que nous souhaitons.

Note : l'opérateur >> décale de x positions des bits en dupliquant le MSB (Most Significant Bit, le bit le plus à gauche). -23 étant négatif, son MSB est égal à 1, ce qui explique que ce sont des 1 qui ont été ajoutés et non des 0.

En Java, nous avons aussi l'opérateur >>> qui décale de x positions les bits d'un nombre considéré comme non signé.

 
Sélectionnez
-23 >>> 4 // 1111 1111 1111 1111 1111 1111 1110 1001 >>> 4
          // = 0000 1111 1111 1111 1111 1111 1110

Malheureusement - une fois de plus -, ce n'est pas le résultat escompté.

Il nous faut donc supprimer le signe avant de décaler les bits. Nous allons utiliser à nouveau un masque :

 
Sélectionnez
-23 & 0xff //   1111 1111 1111 1111 1111 1111 1110 1001 
           // & 0000 0000 0000 0000 0000 0000 1111 1111 
           // -----------------------------------------
           // = 0000 0000 0000 0000 0000 0000 1110 1001

À présent en utilisant le nombre non signé (233) et l'opérateur >>> (ou >>) nous obtiendrons le bon résultat.

 
Sélectionnez
1.
2.
3.
4.
5.
6.
7.
byte[] byteToHex1(byte number) {
    final int unsignedByte = number & 0xff;
    final byte[] bytes = new byte[2];
    bytes[0] = (byte)(unsignedByte >>> 4);
    bytes[1] = (byte)(unsignedByte & 0x0f);
    return bytes;
}

Source

 
Sélectionnez
$ ant test -DtestClass=org.isk.ByteToHexTest -DtestMethod=byteToHex2Test

Ceci conclut le départ de notre fabuleux voyage dans le monde de la JVM. J'espère que vous prendrez autant de plaisir à lire ces parties que moi à les écrire.

Et bien sûr toutes critiques constructives sont les bienvenues.


précédentsommairesuivant

Vous avez aimé ce tutoriel ? Alors partagez-le en cliquant sur les boutons suivants : Viadeo Twitter Facebook Share on Google+   

  

Copyright © 2016 SOAT. Aucune reproduction, même partielle, ne peut être faite de ce site et de l'ensemble de son contenu : textes, documents, images, etc. sans l'autorisation expresse de l'auteur. Sinon vous encourez selon la loi jusqu'à trois ans de prison et jusqu'à 300 000 € de dommages et intérêts.