JVM Hardcore – Part 3 – Bytecode – Constantes
Après trois articles d’introduction les choses sérieuses vont enfin pouvoir commencer. Pendant plusieurs articles nous allons étudier les quelques 200 instructions de la JVM, qui comme nous le verrons sont extrêmement simples à comprendre et à utiliser – à l’exception de quelques unes. A l’issue de cet arc dédié au bytecode, les différents éléments constituant un fichier .class n’auront plus aucun secret pour nous.
Depuis, la toute première version de Java les modifications de la JVMS ont été minimes. Rien n’a été supprimé, toujours pour des questions de rétrocompatibilité et seulement deux instructions ont été dépréciées.
Pour que le matériel présenté reste simple, nous nous limiterons dans un premier temps à la spécification de la JVM pour Java 1.4. Les ajouts effectués pour Java 5, 6, 7 et 8 feront l’objet d’un (ou plus) article par version.
Le code est disponible sur Github (tag et branche)
Tous les articles déjà publiés de la série portent le tag jvmhardcore.
Vocabulaire lié à la pile
- Stack = Pile = Liste Last In First Out (LIFO)
- Empiler (push) : Ajouter un élément en tête de liste
- Dépiler (pop) : Retirer l’élément en tête de liste
Les types de la JVM
La JVM supporte seulement cinq types de données :
int
long
float
double
reference
Les quatre premiers types sont identiques aux types Java du même nom. Les types Java boolean
, byte
, short
et char
sont tous traités comme des int
par la JVM.
En revanche, il est possible d’avoir des tableaux de byte
, short
et char
comme nous le verrons plus tard.
Les types long
et double
prennent deux entrées dans la pile, alors que tous les autres n’en prennent qu’une.
Une reference
est une référence à un objet Java. Une référence pointe vers un objet présent dans le tas (heap). Contrairement aux types numériques, un objet a des propriétés indépendantes de la référence à l’objet.
Contrairement au C (une référence pouvant être comparée à un pointeur) une fois qu’une référence pointe vers un objet, elle pointe toujours vers le même objet et par conséquent, il n’est pas possible d’effectuer une quelconque opération sur une référence.
Convention de nommage des mnémoniques
Une mnémonique est une forme textuelle (simplifiant la lecture/écrire du code pour un être humain) représentant une opération (additionne, charge, stocke, etc.). Chaque mnémonique correspond à un nombre entre 0 et 255 dans un fichier .class. Ce nombre est appelé le code d’opération (opcode).
Les mnémoniques utilisés par PJBA sont ceux définis dans la JVMS. Généralement, le premier caractère d’une mnémonique indique le type sur lesquels il opère. Par exemple, iadd
permet d’ajouter des int
et rien d’autre. Néanmoins, il n’existe pas forcément une mnémonique pour chaque type. Certaines opérations sont possibles uniquement sur un nombre réduit de types, comme par exemple les opérations bit à bit qui ne fonctionnent qu’avec des int
ou des long
.
Pour rappel 1 byte = 1 octet = 8 bits et 1 word = 2 bytes = 16 bits.
Lettre | Type | Taille (en bit) |
---|---|---|
b |
byte |
8 |
s |
short |
16 |
c |
char |
16 |
i |
int |
32 |
l |
long |
64 |
f |
float |
32 |
d |
double |
64 |
a |
référence |
32/64* |
- l’espace mémoire utilisé pour une référence d’un objet est de 32 bits ou 64-bits en fonction de la JVM utilisée.
Écrire un programme, c’est exécuter deux différentes tâches : définir des structures de données et définir des opérations à effectuer sur ces données. Dans la JVM, l’unité fondamentale d’opération est l’instruction.
En bytecode, tout comme en assembleur, une instruction effectue des opérations atomiques. En d’autres termes, il est généralement nécessaire d’avoir plusieurs instructions pour effectuer une opération prenant une ligne en Java (au tout autre langage de haut niveau).
En bytecode, les instructions sont présentes uniquement dans des méthodes.
Dans cette partie et les suivantes nous étudierons les quelques 203 instructions de la JVM.
Nous commencerons par voir les instructions permettant de retourner une valeur à une méthode appelante, de manipuler des données des variables locales et de la pile.
Arguments et opérandes
Une mnémonique peut avoir des arguments qui le suivent tels que :
ldc "Hello World"
Le nombre d’arguments dépend du mnémonique. Certains en prennent aucun et d’autre plusieurs. Ce nombre est fixe pour chaque mnémonique.
En revanche, les opérandes sont récupérées de la pile :
iadd
l’opération iadd prend les deux premiers éléments de la pile est les additionne. Les opérandes ne sont donc pas passés en arguments.
Représentation de la pile
La JVM étant basée sur le modèle de la pile, il est essentiel de connaître quel est l’impact des instructions. Pour représenter l’état avant/après l’exécution d’une instruction, nous allons reprendre le format utiliser par la JVMS et qui est le suivant :
..., valeur1, valeur2 → ..., résultat
, où les valeurs les plus à droite sont au sommet de la pile. valeur1
et valeur2
étant les deux valeurs utilisées pour le calcul et résultat
le résultat.
Il est important de noter que dans cette représentation les long
et le double
sont considérés comme une seule valeur. Par conséquent, lorsque nécessaire nous présenterons les différents cas d’utilisation d’une instruction en utilisant plusieurs formes.
Retourner une valeur
Quand une méthode a terminé de s’exécuter, elle doit rendre le contrôle à la méthode appelante. L’appelant attend généralement une valeur/référence de la méthode appelée. Après qu’une instruction xreturn
(où x
est un type) soit exécutée, le contrôle est transféré à l’instruction suivant l’une des instructions invoke
(utilisées pour appeler un méthode, nous étudierons les instructions invoke
en détail dans une prochaine partie). La valeur au sommet de la pile avant l’exécution d’un instruction xreturn
est retournée à l’appelant et est placée au sommet de la pile du cadre contenant la méthode appelante comme nous l’avons vu dans la partie JVM Hardcore – Part 1 – Introduction à la JVM.
La valeur retournée doit être du type indiqué dans le descripteur de la méthode.
Les instructions suivantes retournent la valeur/référence présente au sommet de la pile à l’appelant :
État de la pile avant → après exécution : ..., valeur → [vide]
Hex | Mnémonique | Description |
---|---|---|
0xac |
ireturn |
Retourne à l’appelant et ajoute un int au sommet de la pile |
0xad |
lreturn |
Retourne à l’appelant et ajoute un long au sommet de la pile |
0xae |
freturn |
Retourne à l’appelant et ajoute un float au sommet de la pile |
0xaf |
dreturn |
Retourne à l’appelant et ajoute un double au sommet de la pile |
0xb0 |
areturn |
Retourne à l’appelant et ajoute une reference au sommet de la pile |
0xb1 |
return |
Retourne à l’appelant sans changer le sommet de la pile (void ) |
Constantes
Les instructions suivantes ajoutent une constante au sommet de la pile :
État de la pile avant → après exécution : ... → ..., constante
Hex | Mnémonique | Argument | Description |
---|---|---|---|
0x01 |
aconst_null |
Empile une reference de la valeur null |
|
0x02 |
iconst_m1 |
Empile la valeur -1 de type int |
|
0x03 |
iconst_0 |
Empile la valeur 0 de type int |
|
0x04 |
iconst_1 |
Empile la valeur 1 de type int |
|
0x05 |
iconst_2 |
Empile la valeur 2 de type int |
|
0x06 |
iconst_3 |
Empile la valeur 3 de type int |
|
0x07 |
iconst_4 |
Empile la valeur 4 de type int |
|
0x08 |
iconst_5 |
Empile la valeur 5 de type int |
|
0x09 |
lconst_0 |
Empile la valeur 0 de type long |
|
0x0a |
lconst_1 |
Empile la valeur 1 de type long |
|
0x0b |
fconst_0 |
Empile la valeur 0 de type float |
|
0x0c |
fconst_1 |
Empile la valeur 1 de type float |
|
0x0d |
fconst_2 |
Empile la valeur 2 de type float |
|
0x0e |
dconst_0 |
Empile la valeur 0 de type double |
|
0x0f |
dconst_1 |
Empile la valeur 1 de type double |
|
0x10 |
bipush |
n |
Empile n , où n appartient à l’intervalle [-128; 127] |
0x11 |
sipush |
n |
Empile n , où n appartient à l’intervalle [-32 768; 32 767] |
0x12 |
ldc |
n |
Empile n , où n peut être de type String , int ou float |
0x13 |
ldc_w |
n |
Empile n , où n peut être de type String , int ou float |
0x14 |
ldc2_w |
n |
Empile n , où n peut être de type long ou double |
La JVM supporte des constantes de type int
, float
, long
, double
et String
.
La JVM est optimisée pour utiliser de petites constantes en fournissant des instructions spéciales pour les charger. Ceci évite de rajouter des entrées supplémentaires dans le pool de constantes.
Pour les nombres les plus communs, il y a les instructions xconst_n
où x
est le type et n
la valeur à empiler.
Pour ajouter une référence nulle au sommet de la pile, nous pouvons utiliser l’instruction aconstant_null
:
.class org/isk/jvmhardcore/bytecode/partthree/Aconst_null
.method get()Ljava/lang/Object;
aconst_null
areturn
.methodend
.classend
Test unitaire :
@Test
public void aconst_null() {
final Object obj = Aconst_null.get();
Assert.assertNull(obj);
}
La méthode suivante retourne un int
ayant pour valeur -1 :
.class org/isk/jvmhardcore/bytecode/partthree/Iconst_m1
.method get()I
iconst_m1
ireturn
.methodend
.classend
Test unitaire :
@Test
public void iconst_m1() {
final int num = Iconst_m1.get();
Assert.assertEquals(-1, num);
}
La méthode suivante retourne un double
ayant pour valeur 1 :
.class org/isk/jvmhardcore/bytecode/partthree/Dconst_1
.method get()D
dconst_1
dreturn
.methodend
.classend
Test unitaire :
@Test
public void dconst_1() {
final double num = Dconst_1.get();
Assert.assertEquals(1.0, num);
}
Dans les trois exemples précédents, les éléments à prendre en compte sont les suivants :
- Le descripteur des méthodes et notamment leur type de retour
- Les préfixes des instructions
Essayons de retourner un type différent de ce qui est défini dans le descripteur :
class org/isk/jvmhardcore/bytecode/partthree/WrongReturnType
.method get()I
dconst_1
dreturn
.methodend
.classend
Test unitaire :
@Test
public void wrongReturnType() {
try {
WrongReturnType.get();
Assert.fail();
} catch (VerifyError e) {
// Assertion
// Message retourné par la JVM:
// (class: org/isk/jvmhardcore/bytecode/partthree/WrongReturnType,
// "method: get signature: ()I) Wrong return type in function
}
}
Lors de l’étape de vérification de la JVM une exception de type VerifyError est levée, nous indiquant que le type de retour de la méthode get()
est incorrect. Il s’agit d’un entier alors que l’on utilise l’instruction dreturn
retournant un double
.
De même, si l’instruction xreturn
ne correspond pas au type de l’élément au sommet de la pile, une exception est levée :
.class org/isk/jvmhardcore/bytecode/partthree/WrongTypeReturned
.method get()D
dconst_1
ireturn
.methodend
.classend
Test unitaire :
@Test
public void wrongTypeReturned() {
try {
WrongTypeReturned.get();
Assert.fail();
} catch (VerifyError e) {
// Assertion
// Message retourné par la JVM:
// (class: org/isk/jvmhardcore/bytecode/partthree/WrongTypeReturned,
// method: get signature: ()D) Expecting to find integer on stack
}
}
Nous verrons l’étape de vérification effectuée par la JVM dans quelques articles.
Pour de petits entiers n’étant pas disponible sous la forme xconst_n
il y a les instructions bipush
et sipush
. Ces deux instructions prennent les valeurs en arguments comme les instructions ldc
. En bytecode bipush
et sipush
prennent en argument les valeurs à empiler (sur 1 byte pour bipush
et 2 bytes sur sipush
) et non des index contrairement aux instructions ldc
. Par conséquent, si une constante est inclue dans l’intervalle [-32 768; 32 767] il est préférable de ne pas utiliser l’instruction ldc
.
Utilisation de l’instruction bipush
:
.class org/isk/jvmhardcore/bytecode/partthree/Bipush
.method get()B
bipush 117
ireturn
.methodend
.classend
Test unitaire :
@Test
public void bipush() {
final byte num = Bipush.get();
Assert.assertEquals(117, num);
}
Utilisation de l’instruction sipush
:
.class org/isk/jvmhardcore/bytecode/partthree/Sipush
.method get()S
sipush 14909
ireturn
.methodend
.classend
Test unitaire :
@Test
public void sipush() {
final short num = Sipush.get();
Assert.assertEquals(14909, num);
}
Dans les deux exemples précédents, l’instruction de retour utilisée est ireturn
et le type de retour des méthodes est respectivement B
et S
, puisque comme nous l’avons vu la JVM ne manipule pas ces types. Par conséquent, nous pouvons aussi utiliser I
comme type de retour :
.class org/isk/jvmhardcore/bytecode/partthree/Bipush_int
.method get()I
bipush -117
ireturn
.methodend
.classend
Test unitaire :
@Test
public void bipush_int() {
final int num = Bipush_int.get();
Assert.assertEquals(-117, num);
}
Pour les instructions de type ldc
, en PJBA, n représente une valeur littérale alors qu’en bytecode, il s’agit d’un index dans le pool de constantes de la classe (que nous verrons un peu plus tard.)
La partie _w
des instructions ldc_w
et ldc2_w
signifie “wide” (large). Concrètement, l’index sur lequel pointent ces instructions prend 2 bytes, au lieu d’un.
Les instructions ldc
et ldc_w
sont équivalentes, à l’exception que ldc_w
pointent sur un index prenant 2 bytes, au lieu d’un pour ldc
. PJBA générant le pool de constantes il choisit l’instruction convenant à la situation. Dans un fichier .pjb, il est possible d’utiliser uniquement l’instruction ldc
.
Dans l’instruction ldc2_w
, le 2
signifie que la constante prend 2 entrées dans la pile.
Voyons quelques exemples.
L’instruction ldc
peut prendre toute chaîne de caractères UTF-8 entre guillemets. Pour pouvoir insérer un guillemet au milieu d’une chaîne de caractère, il faut l’échapper (\”).
.class org/isk/jvmhardcore/bytecode/partthree/Ldc_String
.method getString()Ljava/lang/String;
ldc "Привет \\\" мир по-русски" @ Hello world " en russe
areturn @ Une chaîne de caractères est de type java/lang/String
.methodend
.classend
Test unitaire :
@Test
public void ldc_string() {
final String result = Ldc_String.getString();
Assert.assertEquals("Привет \\\" мир по-русски", result);
}
L’instruction ldc
avec un int
:
.class org/isk/jvmhardcore/bytecode/partthree/Ldc_Integer
.method getInteger()I
ldc 1000
ireturn
.methodend
.classend
Test unitaire :
@Test
public void ldc_integer() {
final int result = Ldc_Integer.getInteger();
Assert.assertEquals(1000, result);
}
L’instruction ldc
avec un float
:
.class org/isk/jvmhardcore/bytecode/partthree/Ldc_Float
.method getFloat()F
ldc -15.56e-12
freturn
.methodend
.classend
Test unitaire :
@Test
public void ldc_float() {
final float result = Ldc_Float.getFloat();
Assert.assertEquals(-15.56e-12f, result);
}
L’instruction ldc2_w
avec un long
:
.class org/isk/jvmhardcore/bytecode/partthree/Ldc_Long
.method getLong()J
ldc2_w 1324
lreturn
.methodend
.classend
Test unitaire :
@Test
public void ldc2_w_long() {
final long result = Ldc_Long.getLong();
Assert.assertEquals(1324l, result);
}
L’instruction ldc2_w
avec un double
:
.class org/isk/jvmhardcore/bytecode/partthree/Ldc_Double
.method getDouble()D
ldc2_w -14.70e-43
dreturn
.methodend
.classend
Test unitaire :
@Test
public void ldc2_w_double() {
final double result = Ldc_Double.getDouble();
Assert.assertEquals(-14.70e-43, result);
}
What’s next ?
Dans l’article suivant, nous verrons des instructions permettant de manipuler les variables locales.