JVM Hardcore – Part 0 – Sneak Peek

academic_duke Alors que des frameworks apparaissent presque tous les jours, ajoutant de plus en plus d’abstraction et rendant, par conséquent, le travail du développeur beaucoup plus simple, la connaissance du fonctionnement de la JVM n’est plus autant diffusée qu’il y a une dizaine d’années. Pour preuve, aucun livre sur la machine virtuelle Java n’est sorti depuis le début des années 2000. Pour de nombreuses personnes, la JVM est une boîte noire et elles ne cherchent pas à comprendre ce qu’il se cache derrière cet outil magique. Bien que la JVM soit dans son ensemble extrêmement complexe, nous pouvons l’étudier en la découpant en plusieurs parties. De plus, je reste convaincu, qu’apprendre comment fonctionne la JVM ou tout autre Machine Virtuelle fait de nous de meilleurs développeurs, que ce soit dans la compréhension du langage, mais aussi des performances.

Pour cette raison, j’ai décidé de consacrer une quarantaine d’articles à la JVM, et d’une manière plus générale aux Machines Virtuelles.

Un article publié sur le web a plusieurs avantages. Outre le fait d’être gratuit :

  • il n’y a aucune limitation dans le nombre de pages et par conséquent les bouts de code seront complets et présentés sous une forme lisible.
  • toute erreur peut être corrigée rapidement
  • le côté communautaire d’Internet pourra être utilisé pour améliorer le contenu, partager des connaissances, etc.

Notes importantes :

  • Tous les outils développés pour cet article ont uniquement un but éducatif.
  • Tout le code présenté sera librement disponible sur Github. Chaque partie aura son propre tag et branche.
  • Sauf mention contraire les termes bytecode et JVM feront respectivement référence au bytecode Java et à toute machine virtuelle Java respectant la Spécification de la JVM (JVMS).

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 APIs de la JDK, les rendant plus difficile à 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.

Sommaire

Ce premier article 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

Série en cours

Articles déjà publiés :

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.

Utiliser le code

Github

Tout le code présenté dans cet article et les suivants 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).

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 cette série d’articles pour compiler et tester les exemples.

Ant

En cours de ces articles, 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

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 :

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 d’articles allant couvrir de nombreux sujets différents, nous allons avoir plusieurs projets, il nous faut donc un niveau supplémentaire :

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)

Tester les exemples

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

<chemin_jvm_hardcore>$ ant

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

<chemin_jvm_hardcore>$ ant clean-all

Pour supprimer le répertoire 04_archives :

<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 par exemple :

<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 au la classe ou méthode appartiennent. Par exemple pour exécuter la méthode de test byteToHex0Test() de la classes org.isk.jvmhardcore.mystery.ByteToHexTest :

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

Mystery

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).

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

Source

Pour ceux qui l’on 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:

<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.

<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éfinit 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 :

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

peut avoir la valeur 20130726.123057

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 convertit en valeur hexadécimale ASCII de 2 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 à 8 bits ou 2 nibbles. En hexadécimal, 4 bits (ou 1 nibble) sont représentés par un caractère de 0 à F (dans cet exemple nous nous contenterons des lettres en majuscule). 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 2 parties de 4 bits chacune représentée par un caractère. Cependant, en pratique, il est y quelques pièges à éviter.

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

@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 :

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 :

chars[5] == '5'
chars[14] == 'E'

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

byte[] byteToHex0(byte number)

La première question qui nous vient à l’esprit est : “Comment découper un octet en 2 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 4 premiers bits, le plus simple est de décaler les bits de 4 positions vers la droite :

94 >> 4 // 0101 1110 >> 4 = 0000 0101

quant aux 4 derniers nous pouvons masquer les 4 premiers :

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 2 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’une 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 :

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.

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 methode 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 :

@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

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

ant test -DtestClass=org.isk.ByteToHexTest -DtestMethod=byteToHex1Test 

Reprenons donc notre méthode byteToHex0(). La méthode fait 4 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, voir même les deux.

Commençons par la première :

-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, sont MSB est égal à 1, ce qui explique que ce sont des 1 qui ont été ajouts 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é.

-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 :

-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

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

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

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 articles que moi à les écrire.

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

Nombre de vue : 103

COMMENTAIRES 1 commentaire

  1. […] était une nécessité. En effet, comme nous l’avons vu dans le premier article de la série (Part 0 – Sneak Peek) l’un des intérêts de Java était de pouvoir exécuter des fichiers compilés envoyés au […]

AJOUTER UN COMMENTAIRE