IV. Constantes▲
Après trois parties d'introduction, les choses sérieuses vont enfin pouvoir commencer. Pendant plusieurs parties, nous allons étudier les quelque 200 instructions de la JVM, qui comme nous le verrons sont extrêmement simples à comprendre et à utiliser - à l'exception de quelques-unes. À l'issue de cette partie dédiée 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'une (ou plus) partie par version.
Le code est disponible sur Github (taget branche).
IV-A. 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.
IV-B. 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 bytes, 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.
IV-C. 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ées par PJBA sont celles définies dans la JVMS. Généralement, le premier caractère d'une mnémonique indique le type sur lequel 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, 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 (1) |
* 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 quelque 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.
IV-D. 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 de la mnémonique. Certains n'en prennent aucun et d'autres plusieurs. Ce nombre est fixe pour chaque mnémonique.
En revanche, les opérandes sont récupérés 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.
IV-E. 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 utilisé par la JVMS et qui est le suivant :
…, valeur1, valeur2 → …, résultat, où les valeurs le 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.
IV-F. 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'une 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 - 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) |
IV-G. 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 |
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. Cela é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 :
2.
3.
4.
5.
6.
.class org/isk/jvmhardcore/bytecode/partthree/Aconst_null
.method get()Ljava/lang/Object;
aconst_null
areturn
.methodend
.classend
Test unitaire :
2.
3.
4.
5.
6.
@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 :
2.
3.
4.
5.
6.
.class org/isk/jvmhardcore/bytecode/partthree/Iconst_m1
.method get()I
iconst_m1
ireturn
.methodend
.classend
Test unitaire :
2.
3.
4.
5.
6.
@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 :
2.
3.
4.
5.
6.
.class org/isk/jvmhardcore/bytecode/partthree/Dconst_1
.method get()D
dconst_1
dreturn
.methodend
.classend
Test unitaire :
2.
3.
4.
5.
6.
@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 :
2.
3.
4.
5.
6.
class
org/
isk/
jvmhardcore/
bytecode/
partthree/
WrongReturnType
.method get
(
)I
dconst_1
dreturn
.methodend
.classend
Test unitaire :
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
@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 :
2.
3.
4.
5.
6.
.class org/isk/jvmhardcore/bytecode/partthree/WrongTypeReturned
.method get()D
dconst_1
ireturn
.methodend
.classend
Test unitaire :
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
@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 parties.
Pour de petits entiers n'étant pas disponibles 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 arguments 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 incluse dans l'intervalle [-32 768; 32 767] il est préférable de ne pas utiliser l'instruction ldc.
Utilisation de l'instruction bipush :
2.
3.
4.
5.
6.
.class org/isk/jvmhardcore/bytecode/partthree/Bipush
.method get()B
bipush 117
ireturn
.methodend
.classend
Test unitaire :
2.
3.
4.
5.
6.
@Test
public
void
bipush
(
) {
final
byte
num =
Bipush.get
(
);
Assert.assertEquals
(
117
, num);
}
Utilisation de l'instruction sipush :
2.
3.
4.
5.
6.
.class org/isk/jvmhardcore/bytecode/partthree/Sipush
.method get()S
sipush 14909
ireturn
.methodend
.classend
Test unitaire :
2.
3.
4.
5.
6.
@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 :
2.
3.
4.
5.
6.
.class org/isk/jvmhardcore/bytecode/partthree/Bipush_int
.method get()I
bipush -117
ireturn
.methodend
.classend
Test unitaire :
2.
3.
4.
5.
6.
@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 pointe 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 deux 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 (\”).
2.
3.
4.
5.
6.
.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 :
2.
3.
4.
5.
6.
@Test
public
void
ldc_string
(
) {
final
String result =
Ldc_String.getString
(
);
Assert.assertEquals
(
"Привет
\\\"
мир по-русски"
, result);
}
L'instruction ldc avec un int :
2.
3.
4.
5.
6.
.class org/isk/jvmhardcore/bytecode/partthree/Ldc_Integer
.method getInteger()I
ldc 1000
ireturn
.methodend
.classend
Test unitaire :
2.
3.
4.
5.
6.
@Test
public
void
ldc_integer
(
) {
final
int
result =
Ldc_Integer.getInteger
(
);
Assert.assertEquals
(
1000
, result);
}
L'instruction ldc avec un float :
2.
3.
4.
5.
6.
.class org/isk/jvmhardcore/bytecode/partthree/Ldc_Float
.method getFloat()F
ldc -15.56e-12
freturn
.methodend
.classend
Test unitaire :
2.
3.
4.
5.
6.
@Test
public
void
ldc_float
(
) {
final
float
result =
Ldc_Float.getFloat
(
);
Assert.assertEquals
(-
15.56e-12
f, result);
}
L'instruction ldc2_w avec un long :
2.
3.
4.
5.
6.
.class org/isk/jvmhardcore/bytecode/partthree/Ldc_Long
.method getLong()J
ldc2_w 1324
lreturn
.methodend
.classend
Test unitaire :
2.
3.
4.
5.
6.
@Test
public
void
ldc2_w_long
(
) {
final
long
result =
Ldc_Long.getLong
(
);
Assert.assertEquals
(
1324
l, result);
}
L'instruction ldc2_w avec un double :
2.
3.
4.
5.
6.
.class org/isk/jvmhardcore/bytecode/partthree/Ldc_Double
.method getDouble()D
ldc2_w -14.70e-43
dreturn
.methodend
.classend
Test unitaire :
2.
3.
4.
5.
6.
@Test
public
void
ldc2_w_double
(
) {
final
double
result =
Ldc_Double.getDouble
(
);
Assert.assertEquals
(-
14.70e-43
, result);
}
IV-H. What's next ?▲
Dans la partie suivante, nous verrons des instructions permettant de manipuler les variables locales.