JVM Hardcore – Part 17 – Bytecode – Comparaisons et contrôle – 2/3
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
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);
}
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
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);
}
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
@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);
}
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
@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);
}
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
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);
}
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
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);
}
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();
}
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);
}
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 descase
se suivent ; alors quelookupswitch
permet d’utiliser n’importe quelle valeur à condition qu’elle soit de typeint
(tout commetableswitch
).
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 → ...
où 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
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);
}
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 → ...
où 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
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);
}
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 premiercase
, il suffit que la dernière instruction du premiercase
ne soit pas une instruction de débranchement (typiquementgoto
).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 decase
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 leswitch/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
etlookupswitch
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.