JVM Hardcore – Part 5 – Bytecode – Mathématiques et Conversions
Au cours de cet article nous allons nous intéresser aux instructions bytecode dédiées aux opérations sur les nombres. Ces opérations sont divisées en deux catégories :
- Les opérations arithmétiques (addition, soustraction, multiplication, division)
- Les opérations bit à bit (or, xor, not, and, etc.)
En mémoire, les entiers et les nombres à virgules sont représentés différemment. Pour cette raison, comme l’avons déjà vu, il y a plusieurs instructions pour une même opération. Les int
et les long
sont représentés en utilisant le complément à deux. Les float
et les double
sont quant à eux représentés en utilisant le standard IEEE 754.
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
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.
Opérations arithmétiques
Addition
Les quatre instructions suivantes permettent d’additionner deux nombres.
Les deux nombres sont dépilés et le résultat est empilé.
Pour une opération du type x + y
, où le signe +
peut être remplacé par -
, *
, /
ou %
(modulo), y
est au sommet de la pile et x
au dessous. L’addition et la multiplication étant commutatives cet ordre n’a pas importance. En revanche, pour les autres opérations il faut en tenir compte.
État de la pile avant → après exécution : ..., v1, v2 → ..., résultat
.
Hex | Mnémonique | Description |
---|---|---|
0x60 |
iadd |
v1 + v2 , où v1 et v2 doivent être de type int |
0x61 |
ladd |
v1 + v2 , où v1 et v2 doivent être de type long |
0x62 |
fadd |
v1 + v2 , où v1 et v2 doivent être de type float |
0x63 |
dadd |
v1 + v2 , où v1 et v2 doivent être de type double |
Note : En Java l’opérateur +
permet aussi de concaténer des chaînes de caractères. La JVM n’a pas une telle instruction. En réalité le compilateur Java remplace toutes les concaténations par des StringBuilder pour les versions de Java supérieures ou égales à Java 5 et des StringBuffer pour les versions inférieures ou égales à Java 1.4. La différence entre les deux est que toutes les opérations d’un StringBuffer
sont synchronisées, contrairement à celles d’un StringBuilder
Pour changer un peu, nous allons voir une addition avec trois termes :
.class org/isk/jvmhardcore/bytecode/partfive/Addition
.method add(III)I;
iload_0 # [empty] -> idx0
iload_1 # idx0 -> idx0, idx1
iadd # idx0, idx1 -> résultatPartiel
iload_2 # résultatPartiel -> résultatPartiel, idx2
iadd # résultatPartiel, idx2 -> résultat
ireturn
.methodend
.classend
Test unitaire :
public void add() {
final int result = Addition.add(1, 2, 3);
Assert.assertEquals(6, result);
}
Soustraction
Les quatre instructions suivantes permettent de soustraire deux nombres.
État de la pile avant → après exécution : ..., v1, v2 → ..., résultat
.
Hex | Mnémonique | Description |
---|---|---|
0x64 |
isub |
v1 - v2 , où v1 et v2 doivent être de type int |
0x65 |
lsub |
v1 - v2 , où v1 et v2 doivent être de type long |
0x66 |
fsub |
v1 - v2 , où v1 et v2 doivent être de type float |
0x67 |
dsub |
v1 - v2 , où v1 et v2 doivent être de type double |
Exemple :
.class org/isk/jvmhardcore/bytecode/partfive/Subtraction
.method subtract(DDD)D;
dload_0 # [empty] -> idx0
dload_2 # idx0 -> idx0, idx2
dsub # idx0, idx2 -> résultatPartiel
dload 4 # résultatPartiel -> résultatPartiel, idx4
dsub # résultat
dreturn
.methodend
.classend
Test unitaire :
public void subtract() {
final double result = Subtraction.subtract(1, 2, 3);
Assert.assertEquals(-4d, result, 0.0001);
}
Multiplication
Les quatre instructions suivantes permettent de multiplier deux nombres.
État de la pile avant → après exécution : ..., v1, v2 → ..., résultat
.
Hex | Mnémonique | Description |
---|---|---|
0x68 |
imul |
v1 * v2 , où v1 et v2 doivent être de type int |
0x69 |
lmul |
v1 * v2 , où v1 et v2 doivent être de type long |
0x6a |
fmul |
v1 * v2 , où v1 et v2 doivent être de type float |
0x6b |
dmul |
v1 * v2 , où v1 et v2 doivent être de type double |
Exemple :
.class org/isk/jvmhardcore/bytecode/partfive/Multiplication
.method multiply(FF)F;
fload_0 # [empty] -> idx0
fload_1 # idx0 -> idx0, idx1
fmul # idx0, idx1 -> résultat
freturn
.methodend
.classend
Test unitaire :
public void multiply() {
final float result = Multiplication.multiply(3, 2);
Assert.assertEquals(6, result, 0.0001);
}
Division
Les quatre instructions suivantes permettent de diviser deux nombres.
État de la pile avant → après exécution : ..., v1, v2 → ..., résultat
.
Hex | Mnémonique | Description |
---|---|---|
0x6c |
idiv |
v1 / v2 , où v1 et v2 doivent être de type int |
0x6d |
ldiv |
v1 / v2 , où v1 et v2 doivent être de type long |
0x6e |
fdiv |
v1 / v2 , où v1 et v2 doivent être de type float |
0x6f |
ddiv |
v1 / v2 , où v1 et v2 doivent être de type double |
Exemple :
.class org/isk/jvmhardcore/bytecode/partfive/Division
.method divide(JJ)J
lload_0 # [empty] -> idx0
lload_2 # idx0 -> idx0, idx2
ldiv # idx0, idx2 -> résultat
lreturn
.methodend
.classend
Test unitaire :
public void divide() {
final long result = Division.divide(3, 2);
Assert.assertEquals(1, result);
}
Reste
Les quatre instructions suivantes permettent d’obtenir le reste de la division de deux nombres.
État de la pile avant → après exécution : ..., v1, v2 → ..., résultat
.
Hex | Mnémonique | Description |
---|---|---|
0x70 |
irem |
v1 % v2 , où v1 et v2 doivent être de type int |
0x71 |
lrem |
v1 % v2 , où v1 et v2 doivent être de type long |
0x72 |
frem |
v1 % v2 , où v1 et v2 doivent être de type float |
0x73 |
drem |
v1 % v2 , où v1 et v2 doivent être de type double |
Exemple :
.class org/isk/jvmhardcore/bytecode/partfive/Remainder
.method getRemainder(II)I
iload_0 # [empty] -> idx0
iload_1 # idx0 -> idx0, idx1
irem # idx0, idx1 -> result
ireturn
.methodend
.classend
Test unitaire :
public void getRemainder() {
final int result = Remainder.getRemainder(40, 7);
Assert.assertEquals(5, result);
}
Négation
Les quatre instructions suivantes permettent de changer le signe du nombre au sommet de la pile.
État de la pile avant → après exécution : ..., valeur → ..., résultat
.
Hex | Mnémonique | Description |
---|---|---|
0x74 |
ineg |
Inverse le signe d’un int |
0x75 |
lneg |
Inverse le signe d’un long |
0x76 |
fneg |
Inverse le signe d’un float |
0x77 |
dneg |
Inverse le signe d’un double |
Exemple
.class org/isk/jvmhardcore/bytecode/partfive/Negation
.method negate(I)I
iload_0 # [empty] -> idx0
ineg # idx0 -> résultat
ireturn
.methodend
.classend
Test unitaire
public void negate() {
final int result = Negation.negate(-10);
Assert.assertEquals(10, result);
}
Opérations bit à bit
Opérations de décalage de bits
Les instructions suivantes permettent d’effectuer des opérations de décalage de bits.
Etat de la pile avant → après exécution : ..., v1, v2 → ..., r
.
Hex | Mnémonique | Description | |
---|---|---|---|
0x78 |
ishl |
v1 << v2 , où v1 et v2 doivent être de type int . Seulement les 5 bits les plus faibles de v1 sont pris en compte. |
|
0x79 |
lshl |
v1 << v2 , où v1 doit être de type long et v2 de type int . Seulement les 5 bits les plus faibles de v2 sont pris en compte. |
|
0x7a |
ishr |
v1 >> v2 , où v1 et v2 doivent être de type int . Seulement les 5 bits les plus faibles de v1 sont pris en compte. |
|
0x7b |
lshr |
v1 >> v2 , où v1 doit être de type long et v2 de type long . Seulement les 6 bits les plus faibles de v1 sont pris en compte. |
|
0x7c |
iushr |
v1 >>> v2 , où v1 et v2 doivent être de type int . Seulement les 5 bits les plus faibles de v1 sont pris en compte. |
|
0x7b |
lushr |
v1 >>> v2 , v1 doit être de type long et v2 de type long . Seulement les 6 bits les plus faibles de v1 sont pris en compte. |
Le fait que seulement 5 ou 6 bits de v2
soient utilisés n’a rien de surprenant, puisque la valeur de v2
correspond au nombre de bits déplacés. Or 5 bits permettent de représenter des nombres de 0 à 31 et 6 bits des nombres de 0 à 63. Au-delà, le résultat serait toujours 0 ou -1 :
// Si x est un int
x << 32 = |x| >> 32 = x >>> 32 = 0
-|x| >> 32 = -1
Si x
était un long
et le décalage de 64 bits, nous aurions les mêmes résultats.
Il est aussi important de noter que les opérations de décalage de bits, et les opérations bit à bit d’une manière générale, ne s’effectuent que sur des int
et des long
. La représentation des float
et des double
en mémoire fait qu’une opération bit à bit n’aurait pas de sens.
Décalage de bit vers la gauche :
.class org/isk/jvmhardcore/bytecode/partfive/BitShifting_Left
.method shift()I
bipush 9 # [empty] -> 9 (1001)
iconst_1 # 9 -> 9, 1
ishl # 9, 1 -> 18
ireturn
.methodend
.classend
Test unitaire :
public void shiftLeft() {
final int result = BitShifting_Left.shift();
Assert.assertEquals(18, result);
}
Décalage de bit vers la droite :
.class org/isk/jvmhardcore/bytecode/partfive/BitShifting_Right
.method shift()I
bipush 9 # [empty] -> 9 (1001)
iconst_1 # 9 -> 9, 1
ishr # 9, 1 -> 4
ireturn
.methodend
.classend
Test unitaire :
public void shiftRight() {
final int result = BitShifting_Right.shift();
Assert.assertEquals(4, result);
}
Opérations logiques
Les instructions suivantes permettent d’effectuer des opérations logiques.
Etat de la pile avant → après exécution ..., v1, v2 → ..., r
.
Hex | Mnémonique | Description |
---|---|---|
0x7e |
iand |
v1 & v2 , où v1 et v2 doivent être de type int |
0x7f |
land |
v1 & v2 , où v1 et v2 doivent être de type long |
0x80 |
ior |
v1 | v2 , où v1 et v2 doivent être de type int |
0x81 |
lor |
v1 | v2 , où v1 et v2 doivent être de type long |
0x82 |
ixor |
v1 ^ v2 , où v1 et v2 doivent être de type int |
0x83 |
lxor |
v1 ^ v2 , où v1 et v2 doivent être de type long |
Nous pouvons noter que le signe tilde (~) permettant d’inverser les bits d’un nombre n’a pas d’instruction dédiée. L’opération ~x
est convertit par le compilateur de la manière suivante :
@ Considérons que la variable x est à l'index 0 des variables locales
@ et qu'elle est de type int
iload_0
iconst_m1
ixor @ x | -1 = ~x
A présent voyons quelques instructions, en commençant par iand
:
.class org/isk/jvmhardcore/bytecode/partfive/Iand
.method iand()I
bipush 10 # [empty] -> 10 (1010)
bipush 7 # 10 -> 10, 7 (0111)
iand # 10, 5 -> 2 (0010)
ireturn
.methodend
.classend
Test unitaire :
public void iand() {
final int result = Iand.iand();
Assert.assertEquals(2, result);
}
ior
:
.class org/isk/jvmhardcore/bytecode/partfive/Ior
.method ior()I
bipush 10 # [empty] -> 10 (1010)
bipush 7 # 10 -> 10, 5 (0111)
ior # 10, 5 -> 15 (1111)
ireturn
.methodend
.classend
Test unitaire :
public void ior() {
final int result = Ior.ior();
Assert.assertEquals(15, result);
}
ixor
:
.class org/isk/jvmhardcore/bytecode/partfive/Ixor
.method ixor()I
bipush 10 # [empty] -> 10 (1010)
bipush 7 # 10 -> 10, 5 (0111)
ixor # 10, 5 -> 13 (1101)
ireturn
.methodend
.classend
Test unitaire :
public void ixor() {
final int result = Ixor.ixor();
Assert.assertEquals(13, result);
}
Conversions
Étant donné que la plupart des opérations que nous venons de voir nécessitent deux opérandes du même type, il faut parfois effectuer des conversions d’un type à un autre.
Les opérations suivantes permettent de changer le type d’un nombre.
État de la pile avant → après exécution : ..., valeur → ..., résultat
.
Hex | Mnémonique | Description |
---|---|---|
0x85 |
i2l |
Convertit un int en long |
0x86 |
i2f |
Convertit un int en float |
0x87 |
i2d |
Convertit un int en double |
0x88 |
l2i |
Convertit un long en int |
0x89 |
l2f |
Convertit un long en float |
0x8a |
l2d |
Convertit un long en double |
0x8b |
f2i |
Convertit un float en int |
0x8c |
f2l |
Convertit un float en long |
0x8d |
f2d |
Convertit un float en double |
0x8e |
d2i |
Convertit un double en int |
0x8f |
d2l |
Convertit un double en long |
0x90 |
d2f |
Convertit un double en float |
0x91 |
i2b |
Convertit un int en byte |
0x92 |
i2c |
Convertit un int en byte |
0x93 |
i2s |
Convertit un int en byte |
Avant de voir un exemple, il semble nécessaire d’effectuer quelques rappels :
- La JVM interprète les types
byte
,short
etchar
comme desint
. Par conséquent les variables identifiées par les typesB
,S
etC
dans le descripteur d’une méthode doivent être associés à des instructions ayant pour préfixei
. - En Java, les littérales numériques sont de type
int
pour les entiers etdouble
pour les nombres à virgule :int i = 138_009; double d = 354.89;
- En Java, pour pouvoir utiliser des littérales de types
long
oufloat
il est nécessaire de les faire suivre respectivement des lettresl
etf
:long l = 786_987_789_273_456l; float f = 1.32f;
- En Java lorsque l’on souhaite utiliser des littérales de type
byte
,short
etchar
il est nécessaire d’effectuer des conversions :addBytes((byte)10, (byte)5)
L’utilisation d’instructions manipulant des valeurs numériques est régie par les règles suivantes :
- Une assignation utilisant
- des littérales – où les littérales sont dans l’intervalle des valeurs possibles pour le type de la variable à laquelle est assignée la valeur
- ou des variables de même type,
n’implique aucune conversion :
byte a = -128; byte b = a;
- Si les deux opérandes sont de même type
- et que le type (ou le type du résultat) est directement utilisé par la JVM (
int
,long
,float
oudouble
) aucune conversion (explicite) est nécessaire - sinon une conversion est nécessaire
int i = 25, j = 42; int z = i + j; byte a = -128; byte b = 15; int c = a + b; byte d = (byte)(a + b)
- et que le type (ou le type du résultat) est directement utilisé par la JVM (
- Si les opérandes sont de types différents mais sans perte de précision – ou tout du moins dans les limites de la JVM – les conversions sont implicites (l’opération de conversion est bien présente au niveau bytecode, mais c’est à la charge du compilateur de la rajouter) :
long a = 13_342_099l; float b = 3e-17f; float c = a * b;
- Toutes les autres conversions doivent être explicitement effectuées.
Un exemple simple de conversion :
.class org/isk/jvmhardcore/bytecode/partfive/D2i
.method d2i(D)I
dload_0
d2i
ireturn
.methodend
.classend
Test unitaire :
public void d2i() {
final int result = D2i.d2i(14.98);
Assert.assertEquals(14, result);
}
Avant de conclure, notons que les trois dernières instructions (i2b
, i2s
et i2c
) sont utilisées lorsque des conversions explicites sont effectuées entre un int
et un byte
ou un short
ou un char
.
What’s next ?
Dans l’article suivant nous verrons les instructions permettant de manipuler la pile.