JVM Hardcore – Part 17 – Bytecode – Comparaisons et contrôle – 2/3

academic_duke
Au cours de l’article précédent nous avons vu les instructions nous permettant de comparer des valeurs de type primitif. Aujourd’hui, nous allons nous intéresser aux instructions nous permettant de comparer des objets, mais aussi nous permettant de gérer des boucles de type for et while, ainsi que de comparaisons de type switch/case.

Le code est disponible sur Github (tag et branche)

Tous les articles déjà publiés de la série portent le tag jvmhardcore.

Représentation de la pile (Rappel)

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 les 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.

Comparer des objets

Les premières instructions que nous allons voir aujourd’hui permettent :

  • de comparer deux objets entre eux
  • de comparer un objet à null

Comparaison de deux objets

État de la pile avant → après exécution : ..., v1, v2 → ..., où v1 et v2 sont des objets.

Hex Mnémonique Argument Description
0xa5 if_acmpeq offset Va à l’offset si v1 == v2
0xa6 if_acmpne offset Va à l’offset si v1 != v2

Deux objets sont-ils égaux ?

Dans cet exemple, nous allons comparer deux objets (a == b, où les valeurs a et b sont respectivement stockées aux index 0 et 1 des variables locales).

.method public static if_acmpeq(Ljava/lang/Object;Ljava/lang/Object;)Z
  aload_0
  aload_1
  if_acmpeq ok
  iconst_0
  ireturn
  ok:
  iconst_1
  ireturn
.methodend

Source

Le test nous permet de vérifier si deux objets sont égaux ou non.

@Test
public void if_acmpeq() {
  final Object o1 = new Object();
  final Object o2 = new Object();

  final boolean b1 = TestObject.if_acmpeq(o1, o1);
  Assert.assertTrue(b1);

  final boolean b2 = TestObject.if_acmpeq(o2, o1);
  Assert.assertFalse(b2);
}

Source

Deux objets sont-ils différents ?

Dans cet exemple, nous allons comparer deux objets (a != b, où les valeurs a et b sont respectivement stockées aux index 0 et 1 des variables locales).

.method public static if_acmpne(Ljava/lang/Object;Ljava/lang/Object;)Z
  aload_0
  aload_1
  if_acmpne ok
  iconst_0
  ireturn
  ok:
  iconst_1
  ireturn
.methodend

Source

Le test suivant suit la même logique que le test précédent. La méthode if_acmpne() retourne vraie si deux objets sont différents (comparaison de deux pointeurs).

@Test
public void if_acmpne() {
  final Object o1 = new Object();
  final Object o2 = new Object();

  final boolean b1 = TestObject.if_acmpne(o1, o1);
  Assert.assertFalse(b1);

  final boolean b2 = TestObject.if_acmpne(o1, o2);
  Assert.assertTrue(b2);
}

Source

Comparaison d’un objet à null

État de la pile avant → après exécution : ..., v1 → ..., où v1 est un objet.

Hex Mnémonique Argument Description
0xc6 ifnull offset Va à l’offset si v1 == null
0xc7 ifnonnull offset Va à l’offset si v1 != null

L’objet en paramètre est-il null ?

.method public static ifnull(Ljava/lang/Object;)Z
  aload_0
  ifnull ok
  iconst_0
  ireturn
  ok:
  iconst_1
  ireturn
.methodend

Source

@Test
public void ifnull() {
  final boolean b1 = TestObject.ifnull(null);
  Assert.assertTrue(b1);

  final boolean b2 = TestObject.ifnull(new Object());
  Assert.assertFalse(b2);

  final boolean b3 = TestObject.ifnull(new LinkedList<>());
  Assert.assertFalse(b3);
}

Source

L’objet en paramètre est-il non-null ?

.method public static ifnonnull(Ljava/lang/Object;)Z
  aload_0
  ifnonnull ok
  iconst_0
  ireturn
  ok:
  iconst_1
  ireturn
.methodend

Source

@Test
public void ifnonnull() {
  final boolean b1 = TestObject.ifnonnull(null);
  Assert.assertFalse(b1);

  final boolean b2 = TestObject.ifnonnull(new Object());
  Assert.assertTrue(b2);

  final boolean b3 = TestObject.ifnonnull(new LinkedList<>());
  Assert.assertTrue(b3);
}

Source

Boucles

Jusqu’à présent, nous avons vu uniquement comment transformer la structure do/while en bytecode, puisque les instructions de comparaisons sont suffisantes pour traiter ce cas. En revanche pour les structures de type for ou while, il est nécessaire d’utiliser une autre instruction, goto (0xa7).

État de la pile avant → après exécution : ... → ... (aucune modification)

Une seule instruction return.

Tout comme la majorité des instructions de comparaison que nous avons déjà vu, l’instruction goto prend en argument un nombre signé de deux octets dont la valeur est comprise dans l’intervalle [-32768 ; 32767], représentant l’offset de l’instruction suivante qui sera exécutée.

Par exemple, tous les exemples précédents pourraient être modifiés pour ne contenir qu’une seule instruction return à la fin.

public boolean goto_after(int i1, int i2) {
  if (i1 < i2) {
    return true;
  } else {
    return false;
  }
}

Au lieu d’utiliser deux fois l’instruction return, chaque branche – ok (implicite) et ko – nous allons empiler true ou false (0 ou 1) et débrancher une fois encore juste avant l’instruction ireturn.

.method public static goto_after(II)Z
  iload_0
  iload_1
  if_icmpge ko
  iconst_1
  goto return
  ko:
  iconst_0
  return:
  ireturn
.methodend

Source

Le test nous permet de valider le code PJB précédent.

@Test
public void goto_after() {
  final boolean b1 = TestGoto.goto_after(10, 100);
  Assert.assertTrue(b1);

  final boolean b2 = TestGoto.goto_after(100, 10);
  Assert.assertFalse(b2);
}

Source

while et for

Simuler les structures for et while est semblable à ce que nous avons avec la structure do/while.

Comme à l’accoutumée partons d’un exemple en Java :

public void goto_before() {
  int sum = 0;

  for (int counter = -10; counter < 0; counter++) {
    sum += counter;
  }

  return sum;
}

Ceci peut aussi être écrit à l’aide d’une structure while qui se rapproche plus du bytecode :

public void goto_before() {
  int counter = -10;
  int sum = 0;

  while (counter < 0) {
    sum += counter;
    counter++;
  }

  return sum;
}

Voyons à présent le code PJB :

.method public static goto_before()I
  bipush -10
  istore_0      @ counter
  iconst_0
  istore_1      @ sum
  loop:
  iload_0
  ifge outLoop  @ counter < 0
  iload_0
  iload_1
  iadd          @ sum += counter
  istore_1
  iinc 0 1      @ counter++
  goto loop
  outLoop:
  iload_1
  ireturn       @ return sum
.methodend

Source

Le test est beaucoup plus simple que le code PJB puisqu’il vérifie la valeur de la variable sum à la fin de l’exécution de la méthode.

@Test
public void goto_before() {
  final int i = TestGoto.goto_before();
  Assert.assertEquals(-55, i);
}

Source

Comme nous l’avons vu, la taille maximum de l’attribut Code d’une méthode est de 65536 octets. Or les arguments des instructions de comparaison et de l’instruction goto est un nombre signé de type short. De fait, si nous souhaitons avoir des if/else ou des boucles dont le contenu est supérieur à 32767/32768 octets, il nous faut utiliser l’instruction goto_w (0xc8) prenant en argument un nombre signé de quatre octets (un int), ce qui nous permet d’accéder à des offsets dans l’intervalle théorique [-65536 ; 65536].

Néanmoins, n’oublions pas que 65536 octets correspond à plusieurs milliers de lignes en Java, c’est-à-dire un nombre excessif pour tout développeur Java censé.

Quoi qu’il en soit, ce cas étant possible, voyons un exemple en utilisant PJBA (créer à la main un fichier .pjb dont une méthode contiendrait 40000 octets serait fastidieux).

Pour le code Java suivant :

public void goto_before() {
  int sum = 0;
  int counter = 0;

  while (counter < 10) {
    // Plus de 40 000 octets
    sum += counter;
    counter++;
  }

  return sum;
}

Nous pouvons écrire le code PJBA suivant :

private void buildGotoWBefore(ClassFileBuilder builder) {
  final MethodBuilder mb = builder.newMethod(Method.MODIFIER_PUBLIC
                    | Method.MODIFIER_STATIC, "goto_w_before", "()I")
    .iconst_0()
    .istore_0()
    .iconst_0()
    .istore_1()
    .label("loop")
    .iload_1()
    .bipush((byte) 10)
    .if_icmplt("ok")
    .goto_("outLoop")
    .label("ok");

  // Génération de 40 000 octets à l'intérieur de la boucle
  // La boucle commence à 2 pour éviter d'écraser les variables
  // sum et counter.
  // Ecraser la variable sum induirait un résultat incorrect
  // et écraser la variable counter créerait une boucle infinie
  for (int i = 2; i < 10_002; i++) {
    mb.iconst_0();          // x = 0 où
    mb.istore((short) i);   // x change de position dans les variables
                            // locales à chaque itération
  }

  mb.iload_0()
    .iload_1()
    .iadd()
    .istore_0()
    .iinc((short) 1, (short) 1)
    .goto_("loop")
    .label("outLoop")
    .iload_0()
    .ireturn();
}

Source

Le test étant similaire à celui nécessitant un simple goto :

@Test
public void goto_w_before() {
  final int i = AllInstructionsWithoutDummiesInCP.goto_w_before();
  Assert.assertEquals(45, i);
}

Source

Tout comme à notre habitude, nous pouvons noter que nous laissons à la charge de la classes MethodBuilder de déterminer s’il faut utiliser l’instruction goto ou goto_w. De fait, dans le cas où l’instruction a pour argument un offset positif (le label est déclaré après l’instruction) ceci implique que nous devons connaître l’ensemble des instructions (plus précisément leur taille) d’une méthode pour savoir si nous devons utiliser l’instruction goto ou goto_w.

Le cas d’un if ou else de plus de 32767 octets

if (a < b) {
    // plus de 32767 octets
}

implique que le compilateur connaisse toutes les instructions utilisées dans la méthode avant de pouvoir créer l’objet que nous avant appelé Code dans PJBA.

iload_0
iload_1
if_icmplt ok
goto ko @ tout comme les instructions de type "wide"
        @ en pjb nous ne les écrivons pas.
        @ Le builder est en charge d'utiliser les
        @ instructions étendues lorsqu'il le faut.
ok:
@ plus de 32767 octets
ko:     @ à l'extérieur du bloc if

Switch/Case

La structure switch/case que l’on a en Java (et dans de nombreux langage) peut être traduite en bytecode par deux instructions : tableswitch et lookupswitch.

  • tableswitch nécessite que toutes les valeurs des case se suivent ; alors que
  • lookupswitch permet d’utiliser n’importe quelle valeur à condition qu’elle soit de type int (tout comme tableswitch).

tableswitch

tableswitch (0xaa) est une instruction de taille variable, dont les arguments sont les suivants :

  • 0 à 3 octets de remplissage ayant chacun pour valeur zéro. L’objectif étant d’aligner l’argument suivant de manière à ce que son adresse (dans la méthode) soit un multiple de 4.
  • l’offset du case par défaut (4 octets)
  • la valeur minimale (4 octets)
  • la valeur maximale (4 octets)
  • une liste d’offsets de 4 octets chacun correspondant aux différents débranchement entre la valeur minimale et la valeur maximale.

État de la pile avant → après exécution : ..., v → ...v représente la valeur comparée à chaque case.

Partons à nouveau d’un exemple en Java :

public static int tableswitch(int i) {
  switch(i) {
    case -1:
      return 14;
    case 0:
      return 10;
    case 1:
      return 12;
    default:
      return 100;
  }
}

L’instruction tableswitch est légèrement plus compliquée à utiliser que toutes les instructions que nous avons vues jusqu’à présent puisqu’elle a besoin de nombreux arguments. Dans le code PJB nous n’aurons pas à préciser le nombre d’octets de remplissage, en revanche, tous les autres arguments ont besoin d’être indiqués. A la suite de l’instruction les arguments suivants doivent être indiqués dans l’ordre et séparés par au moins un espace (Les différents retours à la ligne sont présents uniquement pour des raisons de lisibilité) :

  • La label du case par défaut
  • La valeur minimale
  • La valeur maximale
  • La liste des labels correspondant à tous les case. Les labels doivent être indiqués dans l’ordre de leur valeur correspondante, c’est-à-dire si les valeurs minimales et maximales sont respectivement -1 et 1, le premier label sera utilisé si la valeur à comparer est -1, le second si la valeur à comparer est 0 et le troisième si la valeur à comparer est 1.

Voyons ceci dans du code PJB. Pour l’exemple nous avons inverser les blocs de déclaration des labels (et de leur code associé) pour démontrer que leur ordre n’a pas d’importance, contrairement aux labels en argument de l’instruction tableswitch :

.method public static tableswitch_signed(I)I
  iload_0

  tableswitch default -1 1
    label_m1
    label_0
    label_1

  label_0:      @ case 1:
  bipush 10
  goto return

  label_1:      @ case 0:
  bipush 12
  goto return

  label_m1:     @ case -1:
  bipush 14
  goto return

  default:      @ default:
  bipush 100

  return:
  ireturn       @ return
.methodend

Source

En Java/PJBA, nous pouvons écrire l’instruction tableswitch de la manière suivante :

.tableswitch("default", -1, 1)
  .offset("label_m1")
  .offset("label_0")
  .offset("label_1")
.end()

Le test vérifie que nous retournons les bonnes valeurs en fonction de la valeur en paramètre de la méthode.

@Test
public void tableswitch_signed() {
  final int i1 = TestSwitch.tableswitch_signed(-1);
  Assert.assertEquals(14, i1);

  final int i2 = TestSwitch.tableswitch_signed(0);
  Assert.assertEquals(10, i2);

  final int i3 = TestSwitch.tableswitch_signed(1);
  Assert.assertEquals(12, i3);

  final int i4 = TestSwitch.tableswitch_signed(10);
  Assert.assertEquals(100, i4);
}

Source

lookupswitch

lookupswitch (0xab) est une instruction de taille variable, dont les arguments sont les suivants :

  • 0 à 3 octets de remplissage ayant chacun pour valeur zéro. L’objectif étant d’aligner l’argument suivant de manière à ce que son adresse (dans la méthode) soit un multiple de 4.
  • l’offset du case par défaut (4 octets)
  • le nombre de case (4 octets)
  • une liste de paires valeur/offset de 4 octets chacun. Si la valeur à comparer est égale à la valeur de la paire (la valeur pouvant être comparée à la clé d’une map), le débranchement se fera à l’offset indiqué dans cet même paire.

État de la pile avant → après exécution : ..., v → ...v représente la valeur comparée à chaque case.

Voyons un exemple :

public static int lookupswitch(int i) {
  switch(i) {
    case -10:
      return 20;
    case -5:
      return 10;
    case 15:
      return 30;
    default:
      return 100;
  }
}

Le code PJB est peu différent de celui que nous avons vu précédemment :

.method public static lookupswitch_signed(I)I
  iload_0

  lookupswitch default 3
    -10  label_m10      @ valeur à comparer / label (offset)
    -5 label_m5
    15 label_15

  label_m5:     @ case -5:
  bipush 10
  goto return

  default:      @ default:
  bipush 100
  goto return

  label_m10:    @ case -10:
  bipush 20
  goto return

  label_15:     @ case 15:
  bipush 30

  return:       @ return
  ireturn
.methodend

Source

Le code Java/PJBA de l’instruction lookupswitch est la suivant :

.lookupswitch("default", 3)
  .matchOffset(-5, "label_m5")
  .matchOffset(-10, "label_m10")
  .matchOffset(15, "label_15")
.end()

Le test vérifie que nous retournons les bonnes valeurs en fonction de la valeur en paramètre de la méthode.

@Test
public void lookupswitch_signed() {
  final int i1 = TestSwitch.lookupswitch_signed(-5);
  Assert.assertEquals(10, i1);

  final int i2 = TestSwitch.lookupswitch_signed(-10);
  Assert.assertEquals(20, i2);

  final int i3 = TestSwitch.lookupswitch_signed(15);
  Assert.assertEquals(30, i3);

  final int i4 = TestSwitch.lookupswitch_signed(1);
  Assert.assertEquals(100, i4);
}

Source

Notons trois choses importantes :

  • Si nous souhaitons faire suivre deux case, il suffit d’utiliser deux fois le même offset.
    switch(i) {
    	case 1:
    	case 2:
    	  // Faire quelque chose
    	  break;
    }
  • Si nous souhaitons faire suivre deux case tout en exécutant du code pour le premier case, il suffit que la dernière instruction du premier case ne soit pas une instruction de débranchement (typiquement goto).
    switch(i) {
    	case 1:
    	  // Faire quelque chose
    	  // sans break
    	case 2:
    	  // Faire quelque chose
    	  break;
    }
  • En Java il est possible d’utiliser une structure switch/case sans avoir de case par défaut. Or comme nous venons de le voir en bytecode ce n’est pas possible. Pour résoudre simplement ce problème, l’offset du case par défaut correspondra à la première instruction suivant le switch/case.
    switch(i) {
    	case 1:
    	  // Faire quelque chose
    	  break;
    	case 2:
    	  // Faire quelque chose
    	  break;
    }
    // Si i n'est pas égale à 1 ou 2, nous débranchons ici
    // Faire quelque chose
  • Les offsets des instructions tableswitch et lookupswitch pouvant être négatif, il est en théorie possible d’effectuer des boucles. Néanmoins, dans la réalité il n’existe aucun cas pratique nécessitant un tel mécanisme.

L’ensemble des différents cas d’utilisation des if et switch/case peuvent être retrouvés dans la classe AssemblingInstructionsAndLabels et les tests associés dans la classe InstructionsAndLabelsTest

What’s next ?

Au cours de cet article et du précédent nous avons étudié 25 instructions de comparaisons et de contrôle. Il en existe trois supplémentaires (ret, jsr/jsr_w) qui ont été dépréciées depuis Java 6. Néanmoins, puisque nous souhaitons être exhaustif, nous les traiterons dans l’article consacré aux exceptions, puisqu’il s’agit de leur ancien contexte d’utilisation.

Dans l’article suivant, nous verrons comment convertir une expression logique, par exemple a < 10 || b >= a && (c + d | e) > f, en bytecode.

Nombre de vue : 24

COMMENTAIRES 1 commentaire

  1. […] cours des parties 1/3 et 2/3, nous avons étudié en détail le fonctionnement de 25 instructions de comparaisons et de […]

AJOUTER UN COMMENTAIRE