Le langage Java est un langage de programmation orienté objet multi usage et concurrent. Sa syntaxe est similaire au C et C++. Il a été conçu par Sun Microsystem ™ et commercialisé en 1995.
La machine virtuelle Java, appelée JVM, est une machine à pile classique qui est spécifiquement conçue pour exécuter du code compilé à partir de Java. Le jeu d'instructions possède des instructions spécifiques au modèle d'objet de ce langage (héritage simple, implémentations d'interfaces, ...). La machine virtuelle travaille sur des données typées qui sont soit des types primitifs (entiers, flottants, ...) ou des références à des objets.
Pour une certaine opération, comme une addition, il existe une version de l'instruction pour chaque type applicable. Par exemple, il y a 4 instructions d'addition pour les types entier, entier long, flottant, et double flottant.
Les données peuvent se trouver soit sur la pile ou dans des variables locales. Les emplacements des variables locales et paramètres ne sont pas typés: ils peuvent contenir indifféremment n'importe quel type au cours de l'exécution, tant que les types référencés sont cohérent (contrôle effectué par le vérificateur).
Similairement à la plate-forme Java, la machine virtuelle de .Net est aussi une machine à pile. Une différence notable par rapport à Java est que les instructions ne sont pas typées, mais vont dépendre du type actuellement sur la pile. Par exemple, il y a une seule instruction d'addition pour les nombres entiers, longs où a virgule flottante.
La plate-forme est aussi basée sur le concept d'héritage simple et du système d'interfaces. La machine virtuelle, la CLR ("Common language runtime"), repose sur le principe de pouvoir exécuter plusieurs langages de programmation, ce qui fait que le jeu d'instructions est plus large que celui de Java (avec des instruction pour manipuler directement la mémoire, par exemple).
Les données de la plate-forme .Net peuvent résider dans la pile, dans les arguments ou dans des variables locales. Les emplacements des arguments et variables locales sont typés, contrairement à Java. Ceci implique qu'il n'est pas possible de stocker un entier dans une variable locale d'un autre type.
Les données peuvent être des scalaires, des références ou des instances de classes valeur ("value classes"). Les classes valeurs n'héritent pas de comportement et n'ont pas de méthodes virtuelles. Les affectations à de telles classes se font, comme le nom l'indique, par valeur. Le "Virtual object system" (VOS) permet un système de mise en boite ("boxing"): les classes valeur peuvent être stockées dans des objets alloués dynamiquement. Comme la JVM ne comporte pas d'équivalent concernant les classes valeur et le système de mise en boite, ce sujet ne sera pas traité plus en profondeur par la suite, car il n'intervient pas dans la traduction.
Les deux spécifications des machines virtuelles mettent en évidence le fait qu'elles sont basées sur un modèle similaire orienté objet. Celle de Java fait une correspondance directe entre le modèle d'objet du langage et le jeu d'instruction. Pour la CLR, le fait de pouvoir supporter des multiples langages de programmation fait que le modèle d'instruction est plus étendu.
Une autre différence majeure par rapport au design des deux machines est que les instructions de la CLR ne sont pas typées. Par exemple, l'instruction add de la CLR ne spécifie pas les types des opérandes dans l'instruction.
Pour plus d'informations, Gough [[Gough01]] fait une comparaisons des machines virtuelles JVM et CLR. Meijer et Miller [[MeijerMiller01]] font aussi une comparaison plutot orientée sur l'aspect multi-langages des deux plates-formes. Finalement, la thèse de Olsen [[Olsen02]] fournit des détails de la compilation de Java sur la CLR.
Cette partie décrit les méthodes et algorithmes de traduction de tout ce qui n'est pas des instructions. Cela concerne la traduction des paquetages, classes, membres, droits d'accès, etc. Pour la traduction des instructions, voir la la section intitulée “Traduction des instructions”.
Unités de déploiement JVM: Au moment du déploiement, un programme Java est représenté par un ensemble de classes. Plusieur classes peuvent appartenir à un paquetage, pour former une organisation en hiérarchie. La JVM permet de spécifier des droits d'accès au niveau des paquetages, classes, et membres.
Unité de déploiement CLR: L'unité de déploiement des programmes .Net est l'assemblage ("assembly"). Les assemblages contiennent un module, qui est une représentation physique des données sur le disque. Un module est finalement composé de plusieurs classes. Les assemblages permettent de stocker des informations sur les versions, des signatures cryptographiques, etc.
Problème posé: Les deux plates-formes n'utilisent pas la même notion d'unité de déploiement. La démarche pour faire une traduction entre la JVM et la CLR est donc de trouver un équivalent entre la notion de paquetages et celle d'assemblages. Il faudra donc à partir d'un nom de paquetage et d'une classe, trouver l'assemblage correspondant dans la CLR.
Algorithme de traduction: L'algorithme consiste simplement à pouvoir passer d'un couple (nom de paquetage Java, nom classe Java) à un couple (nom d'assemblage CLR, nom de la classe CLR). Il nécessite aussi des informations pour localiser les assemblages à partir des noms des paquetages Java.
Input: package_to_assembly table de hachage qui fait une correspondance
entre un nom de paquetage Java et un nom d'assemblage CLR.
Cette table peut par exemple contenir:
{ "java.lang" ==> "vjslib"
"java.util" ==> "util_lib"
"system" ==> "mscorlib"
"java" ==> "vjslib" }
Input: currentAssembly le nom de l'assemblage courant de la traduction.
// fonction utilitaire: getParentPackage retourne le paquetage directement // supérieur, comme par exemple: // getParentPackage("org.apache.bcel") retourne "org.apache" // getParentPackage("java") retourne NULL // Cette fonction permet d'obtenir le nom d'un assemblage CLR à partir // d'une classe Java getCLRAssemblyName(Class : c) : string return getCLRAssemblyFromPackage( c.packageName ) // fonction récursive utilisée par getCLRAssemblyName getCLRAssemblyFromPackage(string: package) if ( package_to_assembly.containsKey ( package ) return package_to_assembly.get( package ) else nextPackageToTry = getParentPackage( package ) if nextPackageToTry is not NULL return getCLRAssemblyFromPackage( package ) else return currentAssembly
Pour implémenter cet algorithme, il ne reste plus qu'à définir la variable pacakge_to_assembly. Elle sera typiquement initialisée à partir d'un fichier de configuration.
En ce qui concerne la traduction du nom des classes, elles vont conserver le même nom. Ainsi, la classe java.lang.Double se nommera aussi java.lang.Double une fois traduite.
Il y a cependant quelques exceptions: Java s'attend à avoir java.lang.Object au sommet de la hiérarchie. Par contre, la CLR aura System.Object au sommet de la hiérarchie des classes. Pour résoudre ce problème, il existe deux solutions:
Substituer java.lang.Object par System.Object. Cela implique d'apporter des modifications pour que tous les membres de java.lang.Object puissent être hérités et accessibles dans les objects.
Faire de java.lang.Object une sous classes de System.Object. Cette façon de faire résout les problèmes du point précédent, mais pose d'autres problèmes notamment pour la covariance des tableaux.
L'implémentation du traducteur se base sur les classes de J# de Microsoft. Comme ils ont opté pour la première solution dans leur implémentation, c'est cette première façon de faire qui sera discutée ici.
Dans le traducteur, deux cas de correspondance sont considérés:
java.lang.Object ==> System.Object
java.lang.String ==> System.String
Les noms des champs peuvent se traduire directement, comme pour le nom des classes. Notons en passant que les instructions relatives aux accès des champs n'ont pas une traduction triviale. Le sujet et traité dans la la section intitulée “Instructions d'accès aux champs”.
De manière générale, les noms des méthodes traduites comportent le même nom que les méthodes JVM. Cependant, la différence qui existe entre les hiérarchies d'objets fait que le nom de certaines méthodes peut parfois être différent. Voir la la section intitulée “Noms des classes” pour l'explication des noms des classes.
Rennomage des méthodes héritées de System.Object. Comme java.lang.Object est au sommet de la hiérarchie, tous les objets héritent des méthodes définies dans cet objet. Certaines méthodes Java ont un équivalent dans la CLR qui porte un autre nom, et d'autres n'ont pas d'équivalent. Le cas des méthodes qui n'ont pas d'équivalent dans la CLR est détaillé au paragraphe suivant. Les méthodes qui ont un équivalent ne sont pas nombreuses:
Tableau 1. Equivalence entres les méthodes JVM et CLR de java.lang.Object
méthode JVM | équivalent CLR |
---|---|
clone | MemberwiseClone |
equals | Equals |
finalize | Finalize |
hashCode | GetHashCode |
toString | ToString |
L'algorithme est le suivant: Si on tente d'accéder à une des méthode JVM décrite dans la table ci-dessus (avec la bonne signature), on la renomme vers le son équivalent de la CLR.
Cette façon de faire impose une restriction sur les méthodes d'une classe: une classe ne peut pas contenir simultanément une méthode JVM ci-dessus et son équivalent CLR. Par exemple, il n'est pas permis d'avoir simultanément une méthode nommée MemberwiseClone et clone (avec les signatures correspondantes). Un contrôle doit donc être effectué pour appliquer cette restriction..
Redirection des méthodes vers des classes auxiliaires. Le fait de substituer les objets JVM avec des objets CLR implique qu'il faudra rediriger certains appels de méthodes. Pour résoudre ce problème, on utilise des classes annexes, qui contiennent toutes les méthodes des objets JVM originaux, et on redirige les appels vers ces classes. Voici un exemple de ce qui se passe lors de l'appel de la méthode trim() sur un object de la classe java.lang.String qui a été substitué par System.String.
Exemple 1. Redirection d'une méthode vers une classe auxiliaire
Code Java:
"the_string".trim();
Bytecode JVM
ldc #20 <String "the_string">
invokevirtual #26 <Method java.lang.String trim()>
Bytecode CLR traduit
ldstr "the_string"
call string [vjslib] com.ms.vjsharp.lang.StringImpl ::'trim'(string)
Nous avons vu comment traduire les noms des classes et membres, il reste maintenant la traduction des modificateurs. Les deux tableaux ci-dessous donnent l'équivalence entre les modificateurs des deux plates-formes. On peut noter que la sémantique des droits d'accès n'est pas toujours conservée lors de la traduction, car la notion de paquetage n'existe pas dans la CLR. Il peut donc être possible d'avoir des droits d'accès moins restrictifs dans le code traduit que le code Java original.
Accessibilité dans la CLR. L'accessibilité des membres dans la CLR est définie par deux règles: Family et Assembly. Familiy signifie que le membre est accessible par toutes les classes qui étendent le type. Assembly veut dire que le membre est accessible pour tous les types du même assemblage. Il est aussi possible d'avoir une combiaison des deux concepts: Family and Assembly, et Family or Assembly.
Accessibilité dans la JVM. La JVM ne comportant pas de notion d'assemblage, il n'y a pas d'équivalent pour l' Assembly de la CLR. Le détail est donné dans les tables ci-dessous:
Pour faire une traduction de programmes Java, il est nécessaire de traduire la libraire standard de Java. On pourrait penser qu'un fois le traducteur fonctionnel, il suffirait de traduire le bytecode de toute la librairie. Il se pose cependant un autre problème: la librairie comporte des méthodes natives, i.e. des méthodes qui ne n'ont pas de code JVM, mais qui existent en langage machine natif.
Pour résoudre ce problème, il existe plusieurs solutions:
Coder en un langage .Net l'ensembles des classes natives de la libraire Java (dans un assemblage séparé, par exemple). On peut ensuite, à chaque fois que l'on rencontre une méthode native, appeler la méthode codée "à la main" de l'assemblage correspondant.
Utiliser une libraire Java déjà portée sur .Net.
C'est la deuxième solution qui a été utilisée dans le traducteur, en utilisant la librairie J# développée par Microsoft. La limitation de cette solution est que la librairie Java de J# implémente la version 1.1.4 du JDK. Par conséquent, certaines classes et méthodes apparues dans les versions plus récentes du JDK ne sont pas disponibles.
Les classes internes en Java sont apparues quelques temps après la sortie du langage. Comme la notion de classes interne n'existe pas pour la JVM, les classes internes doivent être traduites en classes globales par les compilateurs Java.
Ainsi, la traduction directe des classes internes fonctionnera à l'identique sur la CLR. Cependant, vu que cette dernière possède la notion de classes interne, il serait théoriquement possible de traduire ces classes internes Java en celles de la CLR.
Dans l'implémentation, la transformation des classes internes JVM en classe internes ("nested classes") CLR n'est pas implémentée.
La JVM comporte moins de types simples que la CLR. Cette dernière comporte des types signés et non signés, alors que la JVM ne comporte seulement des types signés, à l'exception du type char, non signé. La correspondance des types simples ne pose donc pas de problèmes particuliers.
Tableau 4. Traduction des types simples
Type simple JVM | Type simple CLR |
---|---|
byte | int8 |
boolean | bool (int8) |
short | int16 |
char | char (uint16) |
int | int32 |
long | int64 |
float | float32 |
double | float64 |
Beaucoup d'instructions ont un comportement similaire. Ainsi, il est possible de classifier les instructions par groupe, ce qui permet d'avoir un meilleur aperçu de la traduction.
Instructions de création d'objets: Les instructions qui permettent de créer de nouveaux objets.
Instructions d'appel: Les instructions pour appeler d'autres méthodes.
Instructions d'accès aux champs Les instructions qui permettent d'accéder aux variables statiques ou variables d'instance des classes.
Instructions d'accès aux variables / paramètres: Ce sont les instructions qui permettent d'accéder aux variables locales et aux paramètres d'une méthode. Ces accès peuvent placer une variable ou un paramètre sur la pile, ou le contraire.
Instructions manipulant la pile: Ce sont les instructions qui manipulent le contenu de la pile, par empilement et dépilement.
Instructions de branchement: Toutes les instructions qui peuvent avoir un effet sur le pointeur d'instruction courant. On y retrouve notamment les sauts conditionnels et inconditionnels.
Instructions switch: Ce sont les deux instructions tableswitch et lookupswitch.
Instructions relatives aux exceptions: Les instructions qui interviennent dans le mécanisme de gestion des exceptions.
Instructions à traduction triviale: Ce sont les instructions qui ont une traduction directe, souvent avec seulement une ou deux instructions CIL correspondantes. Dans cette catégorie, on trouve les instructions arithmétiques, de conversions, d'accès aux tableaux, de comparaisons, et de concurrence.
Notation pour les instructions. De nombreuses instructions JVM ne diffère que par le type sur lequel elles s'appliquent. Pour éviter une écriture trop lourde, la convention suivante est utilisée:
iload, aload, dload, fload s'écrivent ✱load
Instruction Java | Traduction IL possibles |
---|---|
new | newobj, et traitement spécifique |
La création des objets est différente entre la JVM et la CLR. La CLR possède une instruction newobj qui crée une nouvelle instance d'un objet spécifié en opérande et consomme les arguments du constructeur à partir de la pile. Avec la JVM, la création d'objets se fait en deux phases:
L'instruction new empile un nouvel objet non initialisé sur la pile, la classe étant définie dans l'opérande de l'instruction.
Avant de pouvoir utiliser l'objet, il faut appeler la méthode constructeur avec l'instruction invokestatic sur l'objet non initialisé. La liste des paramètres du constructeur doivent être sur la pile.
Un problème qui apparaît lors de la traduction du mécanisme de création d'un objet, est qu'il n'existe pas d'équivalent pour un objet non initialisé dans la CLR. De plus, il est possible en théorie de stocker plusieurs références à un objet non initialisé sur la pile ou dans les variables locales avant d'invoquer le constructeur. Ceci dit, un algorithme complet doit être capable d'aller rechercher toutes les références aux objets non initialisés lors de l'appel à l'instruction invokestatic pour initialiser toutes les instances.
Le détail d'un tel algorithme ne sera pas donné ici, car un algorithme simplifié décrit par la suite à été utilisé dans l'implémentation.
L'algorithme simplifié en question repose sur le fait que le compilateur Java produit toujours un schéma d'instructions identique lors de la création d'objets. Un exemple de bytecode est donné ci-dessous, où l'objet java.lang.Long est créé avec un entier en arguement:
Exemple 2. Création d'un objet avec new
Code Java:
Long l = new Long(1);
Bytecode Java
0 new #9 <Class java.lang.Long>
3 dup
4 lconst_1
5 invokespecial #13 <Method java.lang.Long(long)>
8 astore_2
On peut remarquer que le new est suivit immédiatement de l'instruction dup. On peut donc traduire une telle séquence en ignorant l'instruction new et l'instruction dup et en remplaçant l'instruction invokespecial par l'instruction CLR newobj. L'algorithme consiste alors simplement à se souvenir que l'on se trouve dans une séquence d'instructions de construction d'objet pour ne pas traduire l'instruction invokespecsial comme dans son contexte habituel. En plus, il faudra ignorer les instructions new et dup. Pour mieux voir se qui se passe, voici le code IL traduit:
Exemple 3. Traduction des instructions new - dup - invokestatic pour la création d'objets
Le code Java est le même que l'exemple précédent
ByteCode CLR traduit
ldc.i8 1
newobj instance void [vjslib] java.lang.Long ::'.ctor'(int64)
stloc 0
Instruction Java | Traduction IL possibles |
---|---|
invokevirtual | callvirt |
invokespecial | call, newobj, callvirt |
invokestatic | call |
invokeinterface | callvirt |
La CLR possède deux instructions pour l'invocation des méthodes: callvirt et call. La JVM possède par contre 4 instructions:
invokevirtual sert à invoquer les méthodes d'instance, le dispatching s'effectuant à partir de la classe.
invokespecial sert à invoquer les méthodes d'instance, avec un traitement spécial pour l'appel aux super classes, méthodes privées et les méthodes d'initialisation des instances.
static sert à invoquer les méthodes statiques.
invokeinterface sert à invoquer les méthodes des interfaces.
En ce qui concerne invokestatic, invokevirtual et invokeinterface, elles peuvent se traduire directement et ne posent pas de problèmes particuliers.
Pour l'instruction invokespecial, il faut faire une distinction plus fine:
Si un constructeur appelle le super constructeur il faut utiliser call
Dans le cas d'une création d'une nouvelle exception, il faut utiliser newobj.
Dans les autres situations, elle se traduit par callvirt.
Instruction Java | Traduction IL possibles |
---|---|
getstatic | ldsfld + recherche objet destination |
putstatic | stsfld + recherche objet destination |
getfield | ldfld + recherche objet destination |
putfield | stfld + recherche objet destination |
A priori, les instructions d'accès aux champs semblent triviales, mais il y a cependant une complication mise en évidence dans l'exemple suivant:
Exemple 4. Traduction d'un instruction d'accès à un champ
Code Java
class A {
int foo;
}
class B extends A {
void m() {
B b = new B();
b.foo = 1;
}
}
Bytecode JVM
9 iconst_1
10 putfield #14<Field B.foo int>
Bytecode CLR équivalent
IL_12: ldc.i4.1
IL_13: stfld int32 A ::foo'
La différence est que la JVM accède à la classe B où le champ foo est hérité, alors que la CLR accède à la classe A où le champ est défini.
L'algorithme de traduction sera cependant assez simple: il suffit, pour chaque accès à des champs, de contrôler si le champ est bien défini dans l'objet accédé en question. Si ce n'est pas le cas, alors il faudra aller voir dans la hiérarchie de super classe jusqu'à ce que l'on trouve la classe qui contient le champ recherché.
JVM: Sur la plate-forme JVM, les variables locales et paramètres sont stockés dans des emplacements appelés slots. Lorsqu'une méthode est appelée, les arguments sont stockés dans les premiers slots, et les variables locales dans les slots suivants. Le nombre de variables locales utilisés dans la méthodes est spécifiée par un paramètre du code. Les accès aux slots se font par des instructions spécifiques. Ce sont donc les mêmes instructions pour accéder à un paramètre que pour accéder à une variable locale.
CLR: Sur la CLR, il y a des instructions distinctes pour accéder aux variables locales ou aux paramètres. L'accès n'est donc pas unifié comme dans la JVM.
JVM: Sur la JVM, il est possible de stocker des éléments de n'importe quel type dans chaque slot. Il faut cependant que les types soient cohérents lorsque le vérificateur vérifie le code. Ceci dit, il est par exemple possible de stocker un entier dans le slot 0, puis de stocker un flottant quelques instructions plus tard.
CLR: Sur la CLR, les emplacements des variables locales et des arguments comportent un type, et ne peuvent seulement contenir des éléments de ce type spécifié. Il n'est pas exemple pas permis d'affecter un flottant dans un emplacement de variable locale qui comporte un type entier.
JVM: Sur la JVM, certains types peuvent occuper deux slots contigus. Ce sont les types long et double. Si l'on stocke un type long sur le slot 4, alors les slots 4 et 5 vont contenir l'élément long en question.
CLR: Sur la CLR, tous les arguments ou les variables locales occupent un seul emplacement.
Emplacements: Un seul type d'instructions d'accès aux slots peuvent se traduire en deux familles d'instructions selon que l'on accède à un argument ou une variable locale. Une première difficulté sera donc de connaître pour chaque index de slot accédé s'il faut accéder à un argument ou une variable locale.
Typage: La différence au niveau des types des variables et paramètres entre les deux plates-formes est un problème délicat: le fait de stocker deux types différents dans un même slot implique d'utiliser deux variables locales distinctes dans la CLR. Il faudra aussi faire une analyse complète du code JVM, pour connaître à quel type se réfère chaque accès à une variable locale ou paramètre.
Allocation: Le fait que certains types sur la JVM occupent deux slots n'est pas un problème difficile à résoudre. Il va falloir en tenir compte lors des calculs d'index des emplacements JVM ou CLR.
Gestions des types références dans les variables locales. Le fait que les slots ne sont pas typés peut poser un certain nombre de problèmes lorsque plusieurs types références différents sont stockés dans un même slot. En effet, il peut se produire deux cas de figure:
Les types sont des variables complètement indépendantes. Dans ce cas, il faudrait au mieux utiliser deux variables locales CLR différentes, une pour chaque slot.
L'un des type est une sous classe de l'autre. Ainsi, les deux types devraient utiliser une seule variable locale, et non deux.
Pour mieux comprendre le problème, voici un exemple simple où un même slot se voit attribuer deux types différents au cours de la méthode.
Exemple 5. Slot utilisé par deux types références
Code Java
{
String s = "a string";
}
{
Long l = new Long(1);
}
Code JVM
0 ldc #21 <String "a string">
2 astore_2
3 new #23 <Class java.lang.Long>
6 dup
7 lconst_1
8 invokespecial #26 <Method java.lang.Long(long)>
11 astore_2
12 iconst_0
13 ireturn
On remarque que le type java.lang.String et le type java.lang.Long sont tous les deux stockés dans un même slot. Cela fonctionne, car la variable locale s n'est plus accessible dès que l'on quitte le premier bloc.
Algorithme naïf de gestion des types référence dans les slots. Comme on peut le voir dans l'exemple précédent, on pourrait se dire que l'algorithme suivant permettrait de résoudre le problème: on conserve un type référence pour chaque slot. Chaque fois que l'on stocke dans le slot un type qui n'est pas un sous type du type courant, on utilise un nouveau slot. Cet algorithme fonctionnerait bien pour l'exemple précédent, cependant il existe des cas pour lesquels l'algorithme est erroné. Voici qui ne fonctionne pas avec cet algorithme:
Exemple 6. Cas où l'algorithme naïf ne fonctionne pas
Code Java
class A {
A foo() {
A a = new B();
a = new C();
return a;
}
}
class B extends A { }
class C extends A { }
Code JVM
0 new #9 <Class B>
3 dup
4 invokespecial #13 <Method B()>
7 astore_1
8 new #15 <Class C>
11 dup
12 invokespecial #16 <Method C()>
15 astore_1
16 aload_1
17 areturn
Si on applique l'algorithme précédent, on va tout d'abord affecter le type B au slot 1 à l'instruction 7. Lors de l'affectation à l'instruction 15, on voit que le type est différent et n'est pas une sous-classe. On va donc utiliser une autre variable locale. Ceci n'est pas correct, car le slot doit être partagé.
Algorithme actuel de gestion des types références . Pour résoudre ce problème, il y a deux possibilités
On peut faire une analyse de flux, pour connaître l'étendue de validité des variables locales, et ainsi savoir si on peut utiliser plusieurs variables pour un seul slot.
On utilise toujours le même slot pour tous les types référence. On place alors des instructions castclass si nécessaire là où il faut utiliser un type plus précis que le type déclaré.
C'est cette deuxième solution qui est utilisée ici, ainsi que dans l'implémentation, car la première se montre trop complexe à réaliser.
Comme mentionné plus haut, il faut récupérer l'information concernant les cadres entrants et sortants pour chaque instruction qui accède aux slots JVM. Une manière de faire est d'exécuter une vérification du bytecode comme spécifié dans la spécification de la machine virtuelle Java, tout en mémorisant les types accédés. L'algorithme en question ne sera pas détaillé ici, il peut être trouvé dans la spécification de la machine virtuelle Java [[JVM]]. Il faudra ajouter à l'algorithme une sauvegarde des cadres sortants et entrants pour chaque instruction.
La première phase de l'algorithme consiste à allouer les variables locales qui seront utilisées pendant la phase de production de code. Pour ce faire, une structure de donnée décrivant chaque slot est utilisée:
struct SlotMapper
Map : types Une Map type => variable locale. Elle contient tous les
types accédes dans ce slots
Map : positionToLocal Une Map position => tuples
( ARGUMENT / LOCALVAR, LocalVar : locvar ),
où locavar est la variable locale CLR à utiliser
Des fonctions sont utilisées pour allouer les variables locales, et faire le traitement adéquat lorsque l'on rencontre une instruction accédant à un slot.
Variables globales:
SlotMapper : slots un vecteur de structures SlotMapper pour
représenter les slots Java
Types[][] : frames un tableau à deux dimensions calculé
pendant la vérification du code. La première dimension
est l'index de l'instruction courante. La deuxième dimension est
l'index du slot ou récupérer l'information du type.
alloue une nouvelle variable locale: allocateLocalVar(Type : t) : LocalVar { // génère une variable locale IL du type t, et la retourne } informe qu'un certain type sur un slot a été accédé: setAccessedType(ArgOrLocal : kind, int: slot, int: pc, Type: t) if types.containsReferenceType() oldRefType = types.getReferenceType() t = commonSuperType( t, types.getReferenceType() ) types.replace ( oldRefType, t ); end if if slots[slot].types.containsKey(t) slots[slot].positionToLocal.set( pc => (kind, slots[slot].types(t))); else localVar = allocateLocalVar(t) slots[slot].types.set( t => localVar) slots[slot].positionToLocal.set( pos => (kind, localVar) ) end if
Avec les structures de donnée et les fonctions définies, il est maintenant possible de définir l'algorithme d'analyse en question:
int : argIndex = 0 if method.isStatic() setAccessedType(argIndex++, 0, method.classType()); foreach (argument in method.arguments) { setAccessedType(ARGUMENT, argIndex++, 0, argument.Type); if (argument.Type == double || argument.Type == long) argIndex++; } foreach (instruction in code) { pc = instruction.pc switch (instruction) case ?Load (slot): case ?Store (slot): setAccessedType(LOCALVAR, slot, pc, frames[pc][slot].type); end switch }
La deuxième partie de l'algorithme se déroule pendant la phase de production du bytecode. C'est à ce moment qu'il faudra connaître si les instructions d'accès se font sur un paramètre ou une variable locale, ainsi que la variable locale utilisée.
foreach (instruction in code) { pc = instruction.pc switch (instruction) case ?Load (slot): case ?Store (slot): switch (slots[slot].positionToLocal) case (ARGUMENT, localvar) switch (instruction) case ?Load (slot): ig.Emit(ldarg, localvar) case ?Store (slot): ig.Emit(starg, localvar) end switch case (LOCALVAR, localvar) switch (instruction) case ?Load (slot): ig.Emit(ldloc, localvar) case ?Store (slot): ig.Emit(stloc, localvar) end switch end switch end switch }
Utilisation de variables locales auxiliaires. Certaines instructions nécessitent des variables locales supplémentaires pour la traduction. Par exemple l'instruction iinc a besoin d'une variables locale pour stocker la valeur intermédiaire que l'on a incrémenté. Un algorithme naïf serait d'allouer des variables locales lors de la traduction de l'instruction. Cette façon de faire à le désavantage de ne pas réutiliser les variables locales, et pourrait avoir comme conséquence une consommation excessive de variables.
fonction qui enregistre que l'on veut utiliser un certain nombre de variables locales à une position donnée. Elle est utilisée pendant la phase d'analyse needTypes(int : position: Type[] : types) { ... } fonction qui retourne un tableau de variables locales pour une position donnée Elle est utilisée pendant la phase de production de code getAuxiliaryLocals(int : position) : LocalVar[] { }
Ce qu'il faut ajouter à la phase d'analyse sera donc:
... case {instruction qui a besoin de variables locales}: needTypes(pc, [ Type1, Type2, ...]); ...
De même, pendant la phase de production du code, il faudra ajouter:
case {instruction qui a besoin de variables locales}: types = getAuxiliaryLocals(pc) ... // Utilisation du tableau types pour utiliser les // variables locales disponibles ...
Instruction Java | Traduction IL possibles |
---|---|
pop | pop |
dup | dup |
pop2 | stloc✱, ldloc✱ |
dup_x1 | stloc✱, ldloc✱ |
dup_x2 | stloc✱, ldloc✱ |
dup2 | stloc✱, ldloc✱ |
dup2_x1 | stloc✱, ldloc✱ |
dup2_x2 | stloc✱, ldloc✱ |
swap | stloc✱, ldloc✱ |
La JVM possède beaucoup plus d'instructions de manipulation de la pile que la CLR. Certaines instructions peuvent paraître complexes et leur utilisation peu utile à première vue, mais elles sont cependant très utiles dans certaines constructions spécifiques à Java, comme pour la manipulation des tableaux.
Pour palier l'inexistence d'une correspondance d'instructions entre les deux plates-formes, une solution possible est d'utiliser la pile. Il suffit alors d'empiler les éléments de la pile dans des variables locales, et de les dépiler ensuite sur la pile de telle manière à obtenir la même transition que celle de l'instruction JVM.
La JVM possède la notion de catégorie de type. Ces catégories représentent la taillent des éléments: 2 mots pour les types long et double et de 1 pour les autres. Cette distinction intervient dans les instructions de transition: les transitions peuvent être différentes en fonctions de la catégorie des types présents sur la pile. Voici un exemple de transition pour l'instruction dup_x2 extrait de la Spécification de la machine virtuelle Java [[JVM]].
Exemple 7. Transitions de pile extrait de la spécification de la machine virtuelle
Form 1:
..., value3, value2, value1 ➠ ..., value1, value3, value2, value1
where value1, value2, and value3 are all values of a category 1 computational type (§3.11.1).
Form 2:
..., value2, value1 ➠ ..., value1, value2, value1
Cette distinction de transitions implique qu'il faudra calculer l'état de la pile pour l'instruction à traduire, de façon à connaître dans quel cas de la transition on se situe. On peut remarquer que les contraintes de la vérification des classes JVM nous prouvent que chaque instruction aura toujours le même type de transition. Ceci dit, il est suffisant d'avoir une traduction statique de ces instructions.
L'algorithme peut se faire en une ou deux phases. Pour éviter d'allouer inutilement des variables locales, une phase préliminaire d'analyse permet de réutiliser des variables locales allouées. L'algorithme général est le suivant:
Phase d'analyse
Analyse de la catégorie des éléments sur la pile En fonction de l'instruction à traduire et de l'état calculé de la pile à l'instruction en question, on peut connaître la transition à effectuer.
Allocation des variables locales pour dépiler la pile En fonction de la transition, allouer les variables locales en bon nombre et type. Enregistrer dans une structure de donnée les opérations d'empilement et de dépilement qu'il faudra effectuer, et dans quelle variable locale il faudra stocker l'élément de la pile. Réutiliser autant que possible d'autres variables locales allouées à des instructions de transition de la pile.
Phase d'émission du code
Dans cette phase, il suffit de réutiliser les informations mémorisées dans la phase d'analyse pour produire les instructions de dépilement et d'empilement correspondantes.
Instruction Java | Traduction IL possibles |
---|---|
ifeq | brfalse |
ifne | brtrue |
iflt | ldc.0, blt |
ifge | ldc.0, bge |
ifgt | ldc.0, bgt |
ifle | ldc.0, ble |
if_icmpeq | beg |
if_icmpne | bne_un |
if_icmplt | blt |
if_icmpge | bge |
if_icmpgt | bgt |
if_icmple | ble |
if_acmpeq | beq |
if_acmpne | bne_un |
goto | br |
ifnull | brfalse |
ifnonnull | brtrue |
goto_w | br |
tableswitch | switch |
lookupswitch | switch ou série de beq et br |
tableswitch | switch |
lookupswitch | suites de beq et br, ou switch |
Dans le cas général, les instructions de saut se traduisent facilement, car elles ont une correspondance directe dans la CLR.
Cependant, la CLR impose des restrictions quant à l'emplacement de la destination des sauts dans le contexte des exceptions. En effet, il n'est par exemple pas autorisé de sauter à l'extérieur d'un bloc try excepté par l'instruction leave. Les cas particuliers relatifs aux exceptions seront traités plus en détail dans la la section intitulée “Instructions relatives aux exceptions”. L'algorithme de gestion des sauts devra donc prendre en compte si l'instruction se situe dans un bloc d'exception. Si ce n'est pas le cas, l'instruction se traduit comme indiqué dans le tableau. Si c'est le cas, il faut alors prendre des mesures spécifiques.
Instruction Java | Traduction IL possibles |
---|---|
tableswitch | switch |
lookupswitch | suites de beq et br, ou switch |
Dans la JVM, il existe deux instructions pour gérer les switch: lookupswitch et tableswitch. Dans la CLR, il n'en existe qu'une seule: switch.
Tableswitch est une instruction à longueur variable permettant d'implémenter une table de saut d'index croissants (n, n+1, n+2, ...). L'instruction CLR switch est très similaire à l'exception près que la table des index doit commencer à l'index 0, alors que la JVM peut spécifier l'index de départ. Pour la traduction, il suffit de soustraire un offset correspondant avant d'émettre l'instruction switch.
L'instruction lookupswitch implémente une table de sauts d'index non forcément contigus. Comme il n'y a pas d'équivalent direct pour la CLR, on peut envisager plusieurs solutions. La solution la plus simple est simplement un enchaînement de tests conditionnels et de branchements, comme dans l'exemple qui suit:
Exemple 8. Traduction de l'instruction lookupswitch avec enchaînement de tests
Code Java
switch (i) {
case 10:
case 15:
return 123;
case 20:
case 25:
return 124;
default:
return 125;
}
Bytecode JVM
0 iload_1
1 lookupswitch 4: default=50
10: 44
15: 44
20: 47
25: 47
44 bipush 123
46 ireturn
47 bipush 124
49 ireturn
50 bipush 125
52 ireturn
Instructions CLR traduites:
ldarg 1
stloc 0
ldloc 0
ldc.i4.s 10
beq label_10_15
ldloc 0
ldc.i4.s 15
beq label_10_15
ldloc 0
ldc.i4.s 20
beq label_20_25
ldloc 0
ldc.i4.s 25
beq label_20_25
br label_default
label_10_15:
ldc.i4.s 123
ret
label_20_25:
ldc.i4.s 124
ret
label_default: ldc.i4.s 125
ret
Une deuxième façon de traduire une telle instruction lookupswitch est d'utiliser un algorithme dichotomique. L'algorithme n'est pas détaillé ici, car c'est la première façon de faire qui a été utilisée dans l'implémentation. Voici cependant un exemple pour comprendre le fonctionnement:
Exemple 9. Traduction de l'instruction lookupswitch avec dichotomie
Code Java
Le code Java est le même que dans l'exemple précédent.
Bytecode CLR traduit avec dichotomie
stloc 1 // stockage dans var auxiliaire
ldloc 1
ldc.i4 15
bge above_15
ldloc 1
ldc.i4 10
bge above_10
br label_10
ldloc 1
ldc.i4 10
beq label_10
br default
above10:
ldloc 1
ldc.i4 15
beq 15
br default
above_15:
ldloc 1
ldc.i4 20
bge above_20
ldloc 1
ldc.i4 20
beq label_20
br default
above_20:
ldloc 1
ldc.i4 25
beq label_25
br default
label_10:
...
label_15:
...
label_20:
...
label_25:
...
default:
...
Performance des algorithmes. L'enchaînement de tests conditionnels est l'algorithme le moins performant, car il est de l'ordre de O(n). En revanche, l'algorithme par dichotomie sera de l'ordre de O(log n). Dans le cas de switch très long exécutés régulièrement, la différence peut être notable. Cependant, la plate-forme .Net fait un usage extensif de la compilation "juste à temps" (JIT). Il est donc fort possible que la première solution soit optimisée par le compilateur, et que ses performances soient équivalentes à la solution par dichotomie.
La traduction des exceptions de la JVM vers la CLR est un problème délicat. En effet, la CLR place beaucoup de contraintes sur les instructions et leur comportement par rapport aux blocs d'exception, alors que la JVM est assez libérale sur le sujet. De plus, les deux systèmes ont des mécanismes de fonctionnement qui diffèrent sur certains points, comme la gestion de comment parvenir vers le bloc finally. Pour mieux comprendre les mécanismes impliqués ici, les contraintes imposées sur les instructions des deux plates-formes seront tout d'abord discutées. Ensuite, les mécanismes qui diffèrent seront abordés, comme la gestion des instructions relatives aux blocs finally.
La CLR possède un mécanisme de gestion des exceptions plus étendu que la JVM. Elle peut contenir 4 blocs de gestion différents: finally, fault, type-filtered et user-filtered (la description exacte se trouve dans la spécification). Pour la JVM, elle possède deux types de blocs de gestion: les blocs catch et finally. Dès lors, la correspondance logique de traduction est indiquée dans le tableau suivant:
Tableau 5. Correspondance entre les blocs d'exception JVM et CLR
bloc JVM | bloc CLR |
---|---|
try | try |
catch | catch (user-filtered) |
finally | finally |
aucun | filter (type-filtered) |
La CLR impose des restrictions beaucoup plus strictes que la JVM concernant le flot d'exécution. Les contraintes exactes sont dans la spécification de la CLR, partition I [[Ecma]]. De manière informelle, on peut les décrire comme suit: on ne peut rentrer dans un bloc que par la première instruction, et dans un gestionnaire seulement s'il y a eu une exception. On ne peut sortir d'un bloc d'exception que par une instruction leave.
De telles contraintes n'existent pas dans la JVM, ce qui peut poser problème pour toutes les instructions de saut. Pour résoudre ce problème, il faut analyser la destination du saut: si la destination reste dans le bloc en cours, il n'y a pas de traitement spécifique à faire. Si la destination sort du bloc, et que l'instruction est un saut conditionnel, il faudra utiliser une instruction de test avec une instruction leave, sinon seulement une instruction leave.
Un problème important dans le processus de traduction est le codage des blocs de gestion des exceptions. Dans la CLR, tous les gestionnaires d'exceptions sont spécifiés avec leurs adresses de départ ainsi que leur adresse de fin. Au contraire, la JVM n'encode pas les positions de fin des gestionnaires d'exception. Ceci fait qu'il est nécessaire de retrouver les positions de fin des blocs d'exception pour pouvoir les traduire vers la CLR. Cette étape n'est pas triviale et il est nécessaire de développer des algorithmes et heuristiques pour reconstituer cette information.
Recherche des fins de blocs. La recherche des fins des gestionnaires ("handlers") d'exception de la JVM est très difficile dans le cas général, car la JVM ne pose pas de contraintes par rapport à l'emplacement des blocs de gestion des exceptions. Il serait possible, par exemple, d'avoir un gestionnaire d'exception qui se situe avant le bloc try qu'il protège. Dès lors, un algorithme gérant toutes les situations devrait faire une analyse approfondie de la structure du code.
Une simplification majeure d'une telle recherche est de partir du principe que le compilateur va placer les blocs d'exceptions dans un ordre séquentiel, avec le bloc try suivit d'éventuellement plusieurs blocs catch et finalement d'un block finally facultatif. Avec cette hypothèse, on trouve 3 cas de figure, listé ci-dessous:
bloc try suivit de catch
bloc try
bloc catch
...
dernier bloc catch
bloc try suivi de catch et finally
bloc try
bloc catch
...
dernier bloc catch
bloc finally
bloc try suivi d'un finally
bloc try
bloc finally
De manière générale, on peut supposer que la fin de tous les blocs non terminaux se situe juste avant l'instruction du bloc suivant.
Algorithme de recherche de la fin des blocs d'exceptions. Une étape importante des algorithmes de traduction des exceptions est de trouver la fin du bloc d'exception. Le principe de l'algorithme est de parcourir les instructions à partir d'une certaine position, afin de mémoriser la position la plus élevée de la destination des sauts. Il considère aussi une valeur minimale de destination à observer, nommée lowestBound. Le détail de l'algorithme est donné ci-dessous.
Input startPc l'offset de l'instruction du début de la recherche
Output endPos l'offset de l'instructions de fin de bloc trouvé
Input lowestBound Il faut que endPos > lowestBound
if catchBlocks.length > 0 lowestBound = catchBlocks[catchBlocks.length - 1].start if finallyBlock.start != NULL lowestBound = finallyBlock.start pc = startPc endPos = instructions.lastInstructionOffset while pc < endPos; do switch (code[pc]) case if* (targetPc) case goto (targetPc) if targetPc < endPos && targetPc > lowestBound endPos = targetPc pc++ end while
Algorithme de recherche du début des instructions du bloc finally à traduire. Pour illustrer le problème, un exemple va nous permettre de mieux comprendre. L'exemple ci-dessous comporte un bloc d'exception try-catch-finally, avec les gestionnaires mis en évidence:
Exemple 10. Bloc try-catch-finally
Code Java:
InputStream is = null;
int i = 0;
try {
i = is.read();
} catch (IOException e) {
e.printStackTrace();
} finally {
i++;
}
Bytecode JVM:
0 aconst_null
1 astore_1
2 iconst_0
3 istore_2
.finally {
.try {
4 aload_1
5 invokevirtual #17 <Method int read()>
8 istore_2
9 goto 17
} .catch java.io.IOException
12 astore_3
13 aload_3
14 invokevirtual #22 <Method void printStackTrace()>
17 jsr 31
}
20 goto 37
.finally_handler:
23 astore 4 <--\
25 jsr 31 <--| instructions à ne pas traduire.
28 aload 4 <--|
30 athrow <--/
// début du bloc finally à traduire
31 astore_3
32 iinc 2 1
35 ret 3
37 return
Sur cet exemple, on remarque que les instructions au début du bloc finally n'ont pas besoin d'être traduites, car elles servent au mécanisme du jsr. Il faut donc développer un algorithme qui nous permette de connaître le début des instructions du bloc finally à traduire.
Le principe de l'algorithme est de parcourir toutes les instructions à partir d'une certaine position, et de mémoriser la destination des instructions jsr.
Input startPc l'offset de l'instruction du début de la
recherche
Input lastPc offset maximum ou effectuer la recherche
Output finallyStart l'offset de l'instruction ou commencer à
traduire les instructions du bloc finally
finallyStart = -1 pc = startPc while pc < lastPc && pc != finallyStart; do switch code[pc] case JsrInstruction (target) finallySTart = target end switch end while return finallyStart
Les machines virtuelles JVM et CLR peuvent lancer des exceptions lorsque certaines conditions ne sont pas vérifiées, ou lors d'erreurs. Par exemple, une division par zéro va lancer une exception java.lang.ArithmeticException dans la JVM, et une exception System.DivideByZeroException dans la CLR. Une traduction directe des classes des gestionnaires d'exceptions ne fonctionnera dans tous les cas. En effet, si un programme dépend sur le fait de rattraper une exception Java de la machine virtuelle, il ne la verra pas si elle porte un autre nom lors de son lancement sur la CLR. Il faut donc implémenter un traitement supplémentaire pour ces exceptions, qui permet de fonctionner dans tous les cas.
La solution adoptée dans le compilateur J# est d'utiliser les mécanismes des filtres (gestionnaires filter). Si on remarque une instruction problématique lors de la traduction, il suffit d'ajouter un filtre qui va recréer une exception JVM correspondante, et va la relancer. De cette manière, le comportement sera le même que sur une plate-forme Java.
Le support des filtres n'étant pas encore implémenté dans la librairie MSIL, cette fonctionnalité n'est actuellement pas implémentée.
Instruction Java | Traduction IL possibles |
---|---|
✱add | add |
✱sub | sub |
✱mul | mul |
✱dev | div |
✱rem | rem |
✱neg | neg |
✱shl | shl.un |
✱shr | shr.un |
✱and | and |
✱or | or |
✱xor | xor |
Toutes les instructions arithmétiques de la JVM ont un équivalent pour la CLR. Il n'y a donc pas de problèmes particuliers au niveau de la traduction.
Instruction Java | Traduction IL possibles |
---|---|
✱2i | conv.i4 |
✱2l | conv.i8 |
✱2f | conv.r4 |
✱2d | conv.r8 |
i2b | conv.i1 |
i2c | conv.i1 |
i2s | conv.i2 |
De même que les instructions arithmétiques, les instructions de conversion ne posent pas de problèmes particuliers, car elles ont toutes un équivalent direct en CLR.
Instruction Java | Traduction IL possibles |
---|---|
✱aload✱ | ldelem✱ |
✱astore✱ | stelem✱ |
Les instructions d'accès aux tableaux sont très similaires sur les deux plates-formes. Elles nécessitent sur la pile une référence à un tableau, un index, et une valeur dans le cas d'un store. La valeur est ensuite stockée ou récupérée à l'index fourni.
Instruction Java | Traduction IL possibles |
---|---|
✱cmp✱ | stloc, ldloc, beq, br |
Les instructions de comparaison permettent de comparer deux nombres value1 et value2 sur la pile, et renvoient 1, 0 ou -1:
value1 > value1 ➠ 1
value1 == value1 ➠ 0
value1 < value1 ➠ -1
La CLR ne comporte pas d'équivalent pour ces instructions, c'est pourquoi il faut les traduire avec plusieurs comparaisons et un saut.
Ces instructions sont souvent utilisées dans des tests conditionnels qui testent seulement si la valeur est nulle ou non. Une possibilité d'optimisation serait de détecter comment le résultat est consommé, pour éviter des tests et instructions inutiles.
Voici un exemple de traduction:
Exemple 11. Traduction d'une instruction dcmpl
Instructions JVM:
8: dcmpl
Instructions CLR traduites::
IL_1f: stloc 3 // stockage dans la variable locale temporaire 3
IL_23: stloc 2 // stockage dans la variable locale temporaire 2
IL_27: ldloc 2
IL_2b: ldloc 3
IL_2f: bgt IL_4d
IL_34: ldloc 2
IL_38: ldloc 3
IL_3c: beq IL_47
IL_41: ldc.i4.m1
IL_42: br IL_4e
IL_47: ldc.i4.0
IL_48: br IL_4e
IL_4d: ldc.i4.1
Instruction Java | Traduction IL possibles |
---|---|
✱monitorenter✱ | appel de System.Threading.Monitor.Enter() |
✱monitorexit✱ | appel de System.Threading.Monitor.Exit() |
Les instructions de concurrence monitorenter et monitorexit se traduisent facilement, en appelant les méthodes statiques de la librairie de base correspondante.