Tutoriel sur la compréhension de la machine virtuelle Java

Image non disponible


précédentsommairesuivant

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 :

 
Sélectionnez
1.
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 :

 
Sélectionnez
1.
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 :

 
Sélectionnez
1.
2.
3.
4.
5.
6.
.class org/isk/jvmhardcore/bytecode/partthree/Aconst_null
  .method get()Ljava/lang/Object;
    aconst_null
    areturn
  .methodend
.classend

Source

Test unitaire :

 
Sélectionnez
1.
2.
3.
4.
5.
6.
@Test
public void aconst_null() {
  final Object obj = Aconst_null.get();
 
  Assert.assertNull(obj);
}

Source

La méthode suivante retourne un int ayant pour valeur -1 :

 
Sélectionnez
1.
2.
3.
4.
5.
6.
.class org/isk/jvmhardcore/bytecode/partthree/Iconst_m1
  .method get()I
    iconst_m1
    ireturn
  .methodend
.classend

Source

Test unitaire :

 
Sélectionnez
1.
2.
3.
4.
5.
6.
@Test
public void iconst_m1() {
  final int num = Iconst_m1.get();
 
  Assert.assertEquals(-1, num);
}

Source

La méthode suivante retourne un double ayant pour valeur 1 :

 
Sélectionnez
1.
2.
3.
4.
5.
6.
.class org/isk/jvmhardcore/bytecode/partthree/Dconst_1
  .method get()D
    dconst_1
    dreturn
  .methodend
.classend

Source

Test unitaire :

 
Sélectionnez
1.
2.
3.
4.
5.
6.
@Test
public void dconst_1() {
  final double num = Dconst_1.get();
 
  Assert.assertEquals(1.0, num);
}

Source

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 :

 
Sélectionnez
1.
2.
3.
4.
5.
6.
class org/isk/jvmhardcore/bytecode/partthree/WrongReturnType
  .method get()I
    dconst_1
    dreturn
  .methodend
.classend

Source

Test unitaire :

 
Sélectionnez
1.
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
  }
}

Source

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 :

 
Sélectionnez
1.
2.
3.
4.
5.
6.
.class org/isk/jvmhardcore/bytecode/partthree/WrongTypeReturned
  .method get()D
    dconst_1
    ireturn
  .methodend
.classend

Source

Test unitaire :

 
Sélectionnez
1.
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
  }
}

Source

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 :

 
Sélectionnez
1.
2.
3.
4.
5.
6.
.class org/isk/jvmhardcore/bytecode/partthree/Bipush
  .method get()B
    bipush 117
    ireturn
 .methodend
.classend

Source

Test unitaire :

 
Sélectionnez
1.
2.
3.
4.
5.
6.
@Test
public void bipush() {
  final byte num = Bipush.get();
 
  Assert.assertEquals(117, num);
}

Source

Utilisation de l'instruction sipush :

 
Sélectionnez
1.
2.
3.
4.
5.
6.
.class org/isk/jvmhardcore/bytecode/partthree/Sipush
  .method get()S
    sipush 14909
    ireturn
 .methodend
.classend

Source

Test unitaire :

 
Sélectionnez
1.
2.
3.
4.
5.
6.
@Test
public void sipush() {
  final short num = Sipush.get();
 
  Assert.assertEquals(14909, num);
}

Source

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 :

 
Sélectionnez
1.
2.
3.
4.
5.
6.
.class org/isk/jvmhardcore/bytecode/partthree/Bipush_int
  .method get()I
    bipush -117
    ireturn
 .methodend
.classend

Source

Test unitaire :

 
Sélectionnez
1.
2.
3.
4.
5.
6.
@Test
public void bipush_int() {
  final int num = Bipush_int.get();
 
  Assert.assertEquals(-117, num);
}

Source

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 (\”).

 
Sélectionnez
1.
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

Source

Test unitaire :

 
Sélectionnez
1.
2.
3.
4.
5.
6.
@Test
public void ldc_string() {
  final String result = Ldc_String.getString();
 
  Assert.assertEquals("Привет \\\" мир по-русски", result);
}

Source

L'instruction ldc avec un int :

 
Sélectionnez
1.
2.
3.
4.
5.
6.
.class org/isk/jvmhardcore/bytecode/partthree/Ldc_Integer
  .method getInteger()I
    ldc 1000
    ireturn
  .methodend
.classend

Source

Test unitaire :

 
Sélectionnez
1.
2.
3.
4.
5.
6.
@Test
public void ldc_integer() {
  final int result = Ldc_Integer.getInteger();
 
  Assert.assertEquals(1000, result);
}

Source

L'instruction ldc avec un float :

 
Sélectionnez
1.
2.
3.
4.
5.
6.
.class org/isk/jvmhardcore/bytecode/partthree/Ldc_Float
  .method getFloat()F
    ldc -15.56e-12
    freturn
  .methodend
.classend

Source

Test unitaire :

 
Sélectionnez
1.
2.
3.
4.
5.
6.
@Test
public void ldc_float() {
  final float result = Ldc_Float.getFloat();
 
  Assert.assertEquals(-15.56e-12f, result);
}

Source

L'instruction ldc2_w avec un long :

 
Sélectionnez
1.
2.
3.
4.
5.
6.
.class org/isk/jvmhardcore/bytecode/partthree/Ldc_Long
  .method getLong()J
    ldc2_w 1324
    lreturn
  .methodend
.classend

Source

Test unitaire :

 
Sélectionnez
1.
2.
3.
4.
5.
6.
@Test
public void ldc2_w_long() {
  final long result = Ldc_Long.getLong();
 
  Assert.assertEquals(1324l, result);
}

Source

L'instruction ldc2_w avec un double :

 
Sélectionnez
1.
2.
3.
4.
5.
6.
.class org/isk/jvmhardcore/bytecode/partthree/Ldc_Double
  .method getDouble()D
    ldc2_w -14.70e-43
    dreturn
  .methodend
.classend

Source

Test unitaire :

 
Sélectionnez
1.
2.
3.
4.
5.
6.
@Test
public void ldc2_w_double() {
  final double result = Ldc_Double.getDouble();
 
  Assert.assertEquals(-14.70e-43, result);
}

Source

IV-H. What's next ?

Dans la partie suivante, nous verrons des instructions permettant de manipuler les variables locales.


précédentsommairesuivant
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.

Vous avez aimé ce tutoriel ? Alors partagez-le en cliquant sur les boutons suivants : Viadeo Twitter Facebook Share on Google+   

  

Copyright © 2016 SOAT. Aucune reproduction, même partielle, ne peut être faite de ce site et de l'ensemble de son contenu : textes, documents, images, etc. sans l'autorisation expresse de l'auteur. Sinon vous encourez selon la loi jusqu'à trois ans de prison et jusqu'à 300 000 € de dommages et intérêts.