Accueil Nos publications Blog JVM Hardcore – Part 5 – Bytecode – Mathématiques et Conversions

JVM Hardcore – Part 5 – Bytecode – Mathématiques et Conversions

academic_duke 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

Source

Test unitaire :


public void add() {
  final int result = Addition.add(1, 2, 3);

  Assert.assertEquals(6, result);
}

Source

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

Source

Test unitaire :


public void subtract() {
  final double result = Subtraction.subtract(1, 2, 3);

  Assert.assertEquals(-4d, result, 0.0001);
}

Source

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

Source

Test unitaire :


public void multiply() {
  final float result = Multiplication.multiply(3, 2);

  Assert.assertEquals(6, result, 0.0001);
}

Source

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

Source

Test unitaire :


public void divide() {
  final long result = Division.divide(3, 2);

  Assert.assertEquals(1, result);
}

Source

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

Source

Test unitaire :


public void getRemainder() {
  final int result = Remainder.getRemainder(40, 7);

  Assert.assertEquals(5, result);
}

Source

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

Source

Test unitaire


public void negate() {
  final int result = Negation.negate(-10);

  Assert.assertEquals(10, result);
}

Source

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

Source

Test unitaire :


public void shiftLeft() {
  final int result = BitShifting_Left.shift();

  Assert.assertEquals(18, result);
}

Source

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

Source

Test unitaire :


public void shiftRight() {
  final int result = BitShifting_Right.shift();

  Assert.assertEquals(4, result);
}

Source

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

Source

Test unitaire :


public void iand() {
  final int result = Iand.iand();

  Assert.assertEquals(2, result);
}

Source

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

Source

Test unitaire :


public void ior() {
  final int result = Ior.ior();

  Assert.assertEquals(15, result);
}

Source

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

Source

Test unitaire :


public void ixor() {
  final int result = Ixor.ixor();

  Assert.assertEquals(13, result);
}

Source

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 et char comme des int. Par conséquent les variables identifiées par les types B, S et C dans le descripteur d’une méthode doivent être associés à des instructions ayant pour préfixe i.
  • En Java, les littérales numériques sont de type int pour les entiers et double pour les nombres à virgule :
    int i = 138_009;
    double d = 354.89;
    
  • En Java, pour pouvoir utiliser des littérales de types long ou float il est nécessaire de les faire suivre respectivement des lettres l et f :
    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 et char 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 :

  1. 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;
    
  2. 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 ou double) 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)
    
  3. 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;
    
  4. 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

Source

Test unitaire :


public void d2i() {
  final int result = D2i.d2i(14.98);

  Assert.assertEquals(14, result);
}

Source

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.