Et non, vous ne rêvez pas, avec cet article, l’hérésie s’installe chez Ours & Hippy : on y parle de Java ! La fin est proche…
Pour anticiper sur la libération prochaine du code du framework de programmation par contrats pour Java que j’ai écrit durant mon stage à Google, j’ai décidé de vous parler un peu de mon expérience avec la JVM et le bytecode Java.
C’est un article qui se veut accessible ; si vous êtes un débutant (averti) et ne comprenez pas, c’est que j’ai raté mon coup. :-°
Des machines qui n’existent pas en vrai
La distinction entre langages interprétés et langages compilés est une dichotomie bien connue des langage-trolls amateurs. Et vient alors immanquablement la question qui déjoue les cabales et divise les cultes :
Mais, Java est-il compilé ou interprété ?
Aha ! Ça vous en pose, une colle ! :-° Mais revenons plutôt aux
faits : en Java, les fichiers source (.java) sont compilés… en
fichiers bytecode (.class). Ces derniers sont similaires aux
fichiers objets (.o ou .obj) traditionnellement obtenus par
compilation de fichiers source C, excepté qu’ils visent une machine un
peu différente… une machine virtuelle !
A priori, le terme « machine virtuelle » n’implique rien de plus que le fait qu’une telle architecture n’a pas été conçue pour être implantée au niveau matériel, directement par un processeur. Cela n’est pas impossible, en théorie, mais ce n’est pas forcément intéressant.
Le plus souvent, une machine virtuelle sera implémentée au niveau logiciel, à l’aide d’un interpréteur exécutant les instructions de celle-ci. L’avantage de cette approche peut se résumer à ceci : on profite ce faisant des plates-formes et services en place. De cela découle entre autre tout ce que l’on entend (trop) souvent sur la portabilité de Java.
De plus, l’évolution de l’architecture est bien plus flexible (changer de JVM logicielle est bien plus aisé que changer de matériel), et cela a sans nulle doute contribué à donner à la JVM son jeu d’instructions particulier, de haut niveau, et taillé sur mesure pour le langage Java, sans grande considération pour une utilisation plus générale.1 En particulier, le modèle objet de Java est omniprésent dans le jeu d’instructions.
1 Je m’étonne ainsi de voir apparaître autant de langages ciblant la JVM, en particulier des langages tels que Clojure, car, de mon expérience personnelle, le modèle adopté n’est pas ce que l’on pourrait appeler propice à un style fonctionnel.
Une machine à piles… ?
Mais avant de jeter un œil aux instructions, intéressons nous un peu à l’architecture en elle-même : dans quel environnement un programme Java évolue-t-il ?
Et bien dans le monde de Java, il était une fois… des piles, des tables de variables locales, un tas d’objets, et des tables de constantes. Le tas et les tables des constantes sont simples à décrire.
Le tas est un énorme dictionnaire associant des références à des
objets. Lors d’un new, un nouvel objet prend vie dans le tas et sa
référence est retournée au programme ; c’est durant la phase de
collecte de la mémoire (garbage collection) qu’elle sera recyclée,
si devenue inutile.
Une table de constantes est une structure encore plus primitive ; il y en a une par fichier classe, et on y trouve les unes à la suite des autres les valeurs de toutes les constantes utilisées dans le code de la classe. L’accès se fait simplement en indexant ce gros tableau. Chaque constante non triviale est ainsi remplacée dans le programme par une entrée dans la table qui va bien.
Un peu plus intéressant, les piles. La JVM est une machine à piles,
oui, mais que cela signifie-t-il au juste ? Pour bien comprendre la
différence, rien ne vaut un petit exemple : une simple addition ;
imaginons que l’on veuille ajouter deux nombres a et b.
Sur une machine à registres classique, cela donne ça :
- charger
adans un registrer1; - charger
bdans un registrer2; - additionner
r1etr2et placer le résultat dansr3; - faire ce que l’on veut avec
r3…
Pour une machine à piles, cela ressemble plutôt à ça (ici, on ne considère qu’une seule pile) :
- empiler
b; - empiler
a; - remplacer les deux nombres au sommet de la pile par un seul : leur somme ;
- faire ce que l’on veut avec le résultat au sommet de la pile…
Dans le premier cas, on a un jeu de registres (qui peuvent avoir un nom, une adresse, etc. selon les modèles), et dans le second, une pile. Dans les deux cas, cela sert à stocker des valeurs à manipuler.
Tout cela est très bien, mais la JVM est une machine à piles, au pluriel ; pourquoi donc a-t-on besoin de plusieurs piles ? Strictement parlant, ce n’est pas une nécessité. C’est un choix de conception : dans la JVM, chaque appel de méthode dispose virtuellement de sa propre pile. C’est une contrainte assez forte, dans le sens où il est ainsi impossible d’écrire des méthodes manipulant la pile de leur appelant librement, mais d’un autre côté, cela signifie également que le programmeur Java ne pourra jamais foutre en l’air son programme (car en Java, cette notion de pile est invisible) en invoquant une méthode « magique » de la JVM.
En cela, la JVM diffère sensiblement du modèle avancé par les langages à piles tels que Forth, ou, plus récemment, Factor.
En plus d’une pile, chaque appel de méthode réserve un tableau de variables locales ; celles-ci sont accessibles directement par leur numéro, mais ne sont pas adressables (par une référence) comme le serait un objet dans le tas.
Petit aperçu du jeu d’instructions
Passons donc aux instructions. Grossièrement, les instructions se divisent en deux groupes :
Les instructions de copie et de déplacement. Dans tous les cas, soit la source, soit la destination doit être la pile. On ne peut pas, par exemple, directement affecter une variable locale à une autre : il faut passer par la pile.
Les opérations entre valeurs de la pile. Ce sont, par exemple, les additions, soustractions, multiplications, ou encore les appels de méthode.
Dans tous les cas, l’exécution d’une instruction, qu’elle soit de copie ou non, suit typiquement le schéma suivant :
- l’instruction consomme un certain nombre de valeurs au sommet de la pile, ce sont les arguments ;
- elle fait son travail à proprement parler, en utilisant les arguments fraîchement récupérés ;
- elle empile les résultats, s’il y en a.
D’autre part, comme je l’ai signalé plus haut, la JVM dispose d’un
grand nombre de codes spécifiquement adaptés au modèle du langage
Java. Entre autres, la JVM intègre des notions plus ou moins
primitives de variables, objets, classes, interfaces, champs, et
méthodes. On y trouve, par exemple, une instruction de copie d’un
champ d’un objet du tas vers la pile (getfield), à partir de sa
référence.
Pour les curieux (et les masos), une liste complète des instructions de la JVM est disponible sur Wikipédia.
Que retenir ?
En résumé, la JVM est une machine virtuelle à piles, principalement destinée à être implémentée côté logiciel (du moins, c’est mon ressenti), et fortement liée au modèle objet du langage Java.
La conséquence la plus directe est que si vous voulez écrire du bytecode en vous basant sur du Java, ce sera simple et (probablement) sans douleur. En revanche, je suis d’avis que le bytecode Java, de ce que j’en ai vu, n’est pas adapté à d’autres types de langages.
# Cygal
14.11.10, 10:19.
Merci, c’est rigolo.
# Raphael
14.11.10, 11:33.
@cygal :
Je ne connais pas le bytecode Java, mais pour son équivalent .NET, la CLI (Common Language Infrastructure), "l’assembleur" possède des opérateurs pour manipuler et utiliser des objets (instanciation, appel de méthode, généricité …) ce qui fait que le modèle objet de C# est directement dicté par le modèle objet de la CLI.
Résultat, c’est plus compliqué de réaliser un langage plus fonctionnel compilant vers la CLI ou vers la JVM car la runtime est faite pour exécuter du code purement composé d’objets et de méthodes (je pense qu’il n’existe même pas d’opérateur pour appeller une fonction autre qu’anonyme en CLI).
Cependant, ça reste possible. Mais dans ce cas, il faut représanter les structures du langage fonctionnel (fonctions, records, unions, …) via des objets compatibles avec la CLI. Par exemple en F#, les fonctions ne sont pas représentées sous le même type qu’en C# (la classe générique System.Func) mais par une nouvelle classe générique FastFunc, qui permet la composition et l’application partielle de fonction (elle contient une surcharge sur l’appel ainsi que la référence vers une autre FastFunc, cette dernière étant appellée dans la surcharge, ça permet de simuler l’approche fonctionnelle des fonctions à N arguments qui sont des généralisations de fonctions à N-1 arguments).
Le problème, c’est lorsque l’on souhaite utiliser les API non fonctionnelles dans un langage fonctionnel ou utiliser les modules écrits en F# dans un autre langage non fonctionnel (un des avantages de la CLI via la standardisation du modèle objet est de pouvoir mélanger des classes écrites en différents langages dans un même projet très facilement).
Le langage fonctionnel est alors obligé de fournir un deuxième modèle objet identique à celui de la CLI (avec de vrais classes, méthodes et interfaces) pour assurer la compatibilité.
Personnellement, je trouve que ça reste intéressant de réaliser un compilateur pour la CLI ou la JVM, même pour des langages fonctionnels. Tout l’aspet générique, garbage collector, portabilite, librairie de base, modèle objet … qui sont tout de même des parties lourdes et parfois critiques ne sont plus à refaire.
# bluestorm
14.11.10, 12:28.
J’ai discuté un peu avec rz0 de ce point. Je pense que son ton globalement plutôt pessimiste quant au support d’autres langages par la JVM sont globalement semblables à ce que raconte Raphaël : le bytecode est fortement ancré dans le modèle sémantique du langage Java, et il semble désagréable pour un langage totalement différent de devoir se plier à ce format.
Je crois que c’est aussi une forme de perfectionnisme : tout le monde aime les logiciels qui reposent sur des couches adaptées à l’usage qu’on en fait. Le choix de la JVM est un choix de facilité (convenience), pas un choix fondé sur le fait que c’est l’outil le plus adapté à la tâche. Viser la JVM c’est un peu un "hack" qui oblige à des sacrifices au niveau de la conception, pour profiter en contrepartie des services de la JVM, de la compatibilité relative avec Java, etc.
La JVM a aussi quelques soucis au niveau des fonctionnalités, par exemple la tail-récursion n’est pas supportée. Je m’apprêtais à dire que le CLR avait sans doute moins de ces défauts historique, puisque plus jeune et construit avec l’expérience durement acquise pendant le développement de la JVM, mais en y repensant il me semble me souvenir que la CLR ne supporte pas directement la tail-rec non plus.
Pour faire un début de réponse à la troisième question de Cygal (le lien entre la description du bytecode et les contrats), oui, le travail de rz0 se place en partie au niveau directement du bytecode, et il a prévu d’en parler à l’avenir. Motivé, peut-être, par mes efforts de communication sur mon stage ? :-’
Enfin, je me pose une question : on entend pas mal parler d’un modèle de sécurité par "inspection de la pile", qui aurait été très apprécié par les utilisateurs de la JVM, mais qui aurait aussi des défauts de conception. Qu’est-ce que c’est, et est-ce qu’il est prévu d’en parler ?
# Raphael
14.11.10, 13:06.
@bluestorm:
La CLI a une instruction tail pour les appels terminaux, mais il n’est pas optimisé par la CLR avant la version 4.0 de .NET. Par contre, ce n’est pas encore le cas pour Mono.
Par contre, on peut faire l’optimisation au niveau du compilateur en amont, c’est ce qui se passe pour F#.
# rz0
14.11.10, 21:29.
@Cygal Pour ce qui est de mon travail à proprement parler, oui, je touille pas mal de bytecode, mais seulement pour injecter les contrats ; la compilation du code des contrats elle-même utilise javac directement, avec une technique de stubbing assez gore. Comme blue l’a dit, je vais consacrer un ou deux articles rien qu’à ça donc vlà. Autrement, les contrats sont implémentés en tant qu’annotations.
Pour ce qui est des performances, hum, l’usage d’une pile plutôt que de registres implique que les opérations se passent dans une zone de mémoire adressable (il faut pouvoir dire « on prend le truc au sommet de la pile », genre, et le sommet de la pile, il change). Ça peut tout à fait se faire, y compris en hardware (genre avoir les N premiers octets de la pile mappés sur de la mémoire rapide type registres du CPU). En revanche, simuler une pile sur une machine à registres non adressables (genre x86), c’est bof bof. Ceci dit, on peut compiler le code pour machine à pile en code natif efficace (ce que font les JIT), pourvu que l’on puisse calculer statiquement l’occupation de la pile (c-à-d qu’il n’y a pas d’opération magique qui peut consommer un nombre indéterminé d’opérandes sur la pile, p.ex.), mais d’une certaine manière, ça demande d’anéantir l’idiome de base. :p
Une transformation simple d’opérations à pile en opérations à registres, c’est de considérer que ce qu’il y a sur la pile à un instant donné représente un ensemble de variables : empiler, c’est créer une variable, désempiler, c’est marquer sa fin de vie. Avec ça, on obtient un graphe de durées de vie des variables, et on fait sa tambouille d’affectation de registres classique.
@Raphael Il me semble bien que sans une construction de saut non local, il y aura toujours des cas où la tailrec ne pourra pas être optimisée en saut local (p.ex. en boucle, quoi) par le compilo. Un bête truc du genre :
Cela n’a, on est d’accord, aucun intérêt, mais ici l’appel est terminal, pourtant, si on compile de manière séparée, la destination du saut est inconnue donc on ne peut pas déplier l’appel localement.
Une manière de faire, qui n’est pas viable en pratique (’fin je pense), ce serait de convertir (au niveau du compilo) toutes les fonctions en trampolines. Au lieu d’avoir le corps de la fonction directement, on aurait une boucle qui appellerait une fermeture à chaque itération (retournant un truc du genre
type r = Result of t | Cont of (unit -> r)), en commençant par la fonction de départ.@bluestorm Pour ce qui est de la vérification de la pile, bah, disons que c’est lourd. :p Ça demande une analyse de flots et tout, donc c’est pas rien ; dans Java 7 et au-delà, ils veulent obliger les compilos à précalculer les valeurs à vérifier, mais du coup ça rend la réécriture du bytecode très laborieux… parce que l’agent qui récrit doit faire l’analyse de flots lui-même. Heureusement ya des libs qui font ça, mais Sun/Oracle devrait clairement offrir des outils pour faire ça, à mon avis, au lieu de laisser les gens se débrouiller…