Accueil Nos publications Blog JVM Hardcore – Part 3 – Bytecode – Constantes

JVM Hardcore – Part 3 – Bytecode – Constantes

academic_duke 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_nx 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

Source

Test unitaire :

@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 :

.class org/isk/jvmhardcore/bytecode/partthree/Iconst_m1
  .method get()I
    iconst_m1
    ireturn
  .methodend
.classend

Source

Test unitaire :

@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 :

.class org/isk/jvmhardcore/bytecode/partthree/Dconst_1
  .method get()D
    dconst_1
    dreturn
  .methodend
.classend

Source

Test unitaire :

@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 :

class org/isk/jvmhardcore/bytecode/partthree/WrongReturnType
  .method get()I
    dconst_1
    dreturn
  .methodend
.classend

Source

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
  }
}

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 :

.class org/isk/jvmhardcore/bytecode/partthree/WrongTypeReturned
  .method get()D
    dconst_1
    ireturn
  .methodend
.classend

Source

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
  }
}

Source

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

Source

Test unitaire :

@Test
public void bipush() {
  final byte num = Bipush.get();

  Assert.assertEquals(117, num);
}

Source

Utilisation de l’instruction sipush :

.class org/isk/jvmhardcore/bytecode/partthree/Sipush
  .method get()S
    sipush 14909
    ireturn
 .methodend
.classend

Source

Test unitaire :

@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 :

.class org/isk/jvmhardcore/bytecode/partthree/Bipush_int
  .method get()I
    bipush -117
    ireturn
 .methodend
.classend

Source

Test unitaire :

@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 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

Source

Test unitaire :

@Test
public void ldc_string() {
  final String result = Ldc_String.getString();

  Assert.assertEquals("Привет \\\" мир по-русски", result);
}

Source

L’instruction ldc avec un int :

.class org/isk/jvmhardcore/bytecode/partthree/Ldc_Integer
  .method getInteger()I
    ldc 1000
    ireturn
  .methodend
.classend

Source

Test unitaire :

  @Test
  public void ldc_integer() {
    final int result = Ldc_Integer.getInteger();

    Assert.assertEquals(1000, result);
  }

Source

L’instruction ldc avec un float :

.class org/isk/jvmhardcore/bytecode/partthree/Ldc_Float
  .method getFloat()F
    ldc -15.56e-12
    freturn
  .methodend
.classend

Source

Test unitaire :

@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 :

.class org/isk/jvmhardcore/bytecode/partthree/Ldc_Long
  .method getLong()J
    ldc2_w 1324
    lreturn
  .methodend
.classend

Source

Test unitaire :

@Test
public void ldc2_w_long() {
  final long result = Ldc_Long.getLong();

  Assert.assertEquals(1324l, result);
}

Source

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

Source

Test unitaire :

@Test
public void ldc2_w_double() {
  final double result = Ldc_Double.getDouble();

  Assert.assertEquals(-14.70e-43, result);
}

Source

What’s next ?

Dans l’article suivant, nous verrons des instructions permettant de manipuler les variables locales.