Bytecode de cuisson dans la cuisine JVM

Je m'appelle Alexander Kotsyuruba, je dirige le développement des services internes chez DomKlik. De nombreux développeurs Java expérimentés en viennent à comprendre la structure interne de la JVM. Pour faciliter ce voyage du Java Samurai, j'ai décidé de présenter les bases de la machine virtuelle Java (JVM) et de travailler avec le bytecode dans un langage simple.

Qu'est-ce qu'un mystérieux bytecode et où vit-il?

Je vais essayer de répondre à cette question en utilisant l'exemple du décapage.



Pourquoi ai-je besoin d'une JVM et d'un bytecode?


La JVM est née sous le slogan Write Once Run Anywhere (WORA) chez Sun Microsystems. Contrairement au concept de Write Once Compile Anywhere (WOCA) , WORA implique la présence d'une machine virtuelle pour chaque OS qui exécute du code une fois compilé (bytecode).


Écrire une fois n'importe où (WORA)


La

JVM et le bytecode Write Once Compile Anywhere (WOCA) sont à la base du concept WORA et nous épargnent les nuances et la nécessité de compiler pour chaque système d'exploitation.

Bytecode


Pour comprendre ce qu'est un bytecode, regardons un exemple. Bien sûr, ce code ne fait rien d'utile, il ne servira qu'à une analyse plus approfondie.

La source:

class Solenya(val jarForPickles: Any? = Any(), var ingredientsCount: Int = 0) {


    /**
     *   
     *  @param ingredient -  
     */
    fun add(ingredient: Any) {
        ingredientsCount = ingredientsCount.inc()
        //- 
    }

    /**
     *   
     *  @param duration -   
     */
    fun warmUp(duration: Int) {
        for (x in 1..duration)
            println("Warming")
    }

    init {
        //   
        val jarForPickles = takeJarForPickles()
        // 
        val pickles = Any()
        // 
        val water = Any()

        //
        add(pickles)
        add(water)

        //
        warmUp(10)
    }

    /**
     *   
     */
    private fun takeJarForPickles(): Any = openLocker()

    /**
     *   
     */
    private fun openLocker(): Any = takeKeyForLocker()

    /**
     *     
     */
    private fun takeKeyForLocker(): Any = {}
}

En utilisant les outils intégrés Intellij IDEA ( Tools -> Kotlin -> Show Kotlin Bytecode ) nous obtenons un bytecode démonté (seule une partie est montrée dans l'exemple):

...
   INVOKEVIRTUAL java/io/PrintStream.println (Ljava/lang/Object;)V
   L5
   L6
    LINENUMBER 12 L6
    RETURN
   L7
    LOCALVARIABLE this Lcom/company/Solenya; L0 L7 0
    LOCALVARIABLE ingredient Ljava/lang/Object; L0 L7 1
    LOCALVARIABLE $i$f$add I L1 L7 2
    MAXSTACK = 2
    MAXLOCALS = 5

  // access flags 0x11
  public final warmUp(I)V
    // annotable parameter count: 1 (visible)
    // annotable parameter count: 1 (invisible)
   L0
    LINENUMBER 19 L0
    ICONST_1
    ISTORE 2
...

À première vue - un ensemble d'instructions incompréhensible. Pour comprendre comment et avec quoi ils fonctionnent, vous devrez plonger dans la cuisine intérieure de la JVM.

Cuisine JVM


Regardons la mémoire d'exécution JVM:



Nous pouvons dire que JVM est notre cuisine. Ensuite, considérez les autres participants:

Zone de méthode - Cookbook



La zone Méthode stocke le code compilé pour chaque fonction. Lorsqu'un thread commence à exécuter une fonction, en général, il reçoit des instructions de cette zone. En fait, c'est un livre de recettes culinaires qui détaille comment cuisiner tout, des œufs brouillés à la zarzuela catalane.

Sujet 1..N - Team Cooks



Les flux suivent strictement les instructions qu'ils ont prescrites (zone de méthode), pour cela, ils ont PC Register et JVM Stack. Vous pouvez comparer chaque flux avec un cuisinier qui effectue la tâche qui lui est confiée, en suivant exactement les recettes du livre de cuisine.

Registre PC - Remarques sur le terrain



Programme Counter Register - le compteur de commandes de notre flux. Il stocke l'adresse de l'instruction en cours d'exécution. Dans la cuisine, ce serait quelques notes sur quelle page du livre de cuisine nous sommes maintenant.

Pile Jvm


Pile de cadres. Un cadre est alloué pour chaque fonction, dans lequel le thread actuel travaille avec des variables et des opérandes. Dans le cadre de l'analogie avec la préparation de nos cornichons, cela pourrait être un ensemble d'opérations imbriquées:

-> -> -> ...

Cadre - Bureau



Le cadre fait office de bureau du cuisinier, sur lequel repose une planche à découper et des contenants signés.

Variables locales - Conteneurs signés



Il s'agit d'un tableau de variables locales (table de variables locales) qui, comme son nom l'indique, stocke les valeurs, le type et la portée des variables locales. Ceci est similaire aux conteneurs signés, où vous pouvez ajouter des résultats intermédiaires d'activité professionnelle.

Pile d'opérandes - planche à découper



La pile d'opérandes stocke les arguments des instructions JVM. Par exemple, des valeurs entières pour l'opération d'addition, des références à des objets en tas, etc.

L'exemple le plus proche que je peux donner est une planche à découper sur laquelle une tomate et un concombre se transforment en salade à un moment donné. Contrairement aux variables locales, nous ne mettons au tableau que ce avec quoi nous allons exécuter l'instruction suivante.

Heap - Tableau de distribution



Dans le cadre de l'utilisation du cadre, nous opérons sur des liens vers des objets; les objets eux-mêmes sont stockés en tas. Une différence importante est que le cadre n'appartient qu'à un seul thread, et les variables locales "vivent" pendant que le cadre est vivant (la fonction est exécutée). Et le tas est accessible à d'autres flux et vit jusqu'à ce que le garbage collector soit activé. Par analogie avec la cuisine, nous pouvons donner un exemple avec une table de distribution, qui seule est courante. Et il est nettoyé par une équipe distincte de nettoyeurs.

Cuisine JVM. Un regard de l'intérieur. Travailler avec Frame


Commençons par la fonction warmUp:

    /**
     *   
     *  @param duration -   
     */
    fun warmUp(duration: Int) {
        for (x in 1..duration)
            println("Warming...")
    }

Fonction bytecode démontée:

  public final warmUp(I)V
    // annotable parameter count: 1 (visible)
    // annotable parameter count: 1 (invisible)
   L0
    LINENUMBER 19 L0
    ICONST_1
    ISTORE 2
    ILOAD 1
    ISTORE 3
    ILOAD 2
    ILOAD 3
    IF_ICMPGT L1
   L2
    LINENUMBER 20 L2
    LDC "Warming..."
    ASTORE 4
   L3
    ICONST_0
    ISTORE 5
   L4
    GETSTATIC java/lang/System.out : Ljava/io/PrintStream;
    ALOAD 4
    INVOKEVIRTUAL java/io/PrintStream.println (Ljava/lang/Object;)V
   L5
   L6
    LINENUMBER 19 L6
    ILOAD 2
    ILOAD 3
    IF_ICMPEQ L1
    IINC 2 1
   L7
    GOTO L2
   L1
    LINENUMBER 21 L1
    RETURN
   L8
    LOCALVARIABLE x I L2 L7 2
    LOCALVARIABLE this Lcom/company/Solenya; L0 L8 0
    LOCALVARIABLE duration I L0 L8 1
    MAXSTACK = 2
    MAXLOCALS = 6

Initialisation du cadre - Préparation du lieu de travail


Pour exécuter cette fonction, une trame sera créée dans le flux de pile JVM. Permettez-moi de vous rappeler que la pile se compose d'un tableau de variables locales et d'une pile d'opérandes.

  1. Afin que nous puissions comprendre la quantité de mémoire à allouer pour cette trame, le compilateur a fourni des méta-informations sur cette fonction (explication dans le commentaire de code):

        MAXSTACK = 2 //    2*32bit
        MAXLOCALS = 6 //    6*32bit
    
  2. Nous avons également des informations sur certains éléments du tableau de variables local:

        LOCALVARIABLE x I L2 L7 2 //  x  Int(I),      L2-L7   2
        LOCALVARIABLE this Lcom/company/Solenya; L0 L8 0
        LOCALVARIABLE duration I L0 L8 1
    
  3. Les arguments de la fonction lors de l'initialisation du cadre tombent dans des variables locales. Dans cet exemple, la valeur de durée sera écrite dans le tableau avec l'index 1.

Ainsi, initialement, le cadre ressemblera à ceci:


Lancer l'exécution des instructions


Pour comprendre le fonctionnement du cadre, armez-vous simplement d'une liste d'instructions JVM ( listes d'instructions de bytecode Java ) et parcourez l'étiquette L0:

   L0
    LINENUMBER 19 L0 //     
    ICONST_1
    ISTORE 2
    ILOAD 1
    ISTORE 3
    ILOAD 2
    ILOAD 3
    IF_ICMPGT L1

ICONST_1 - add 1(Int) dans la pile d'opérandes:



iStore 2 - valeur de traction (le type Int) de la pile d'opérandes et d' écriture à des variables locales avec l'indice 2:



Ces deux opérations peuvent être interprétées dans un Java code: int x = 1.

ILOAD 1 - charger la valeur des variables locales d'indice 1 dans la pile d'opérandes:



iStore 3 - Valeur de traction (le type Int) de la pile d'opérandes et écrit à des variables locales avec un indice de 3:



Ces deux opérations peuvent être interprétées dans un Java-code: int var3 = duration.

ILOAD 2 - charge une valeur à partir de variables locales avec l'index 2 dans la pile d'opérandes.

ILOAD 3 - charge la valeur des variables locales avec l'index 3 dans la pile d'opérandes:



IF_ICMPGT L1- instruction pour comparer deux valeurs entières de la pile. Si la valeur "inférieure" est supérieure à la valeur "supérieure", passez à l'étiquette L1. Après avoir exécuté cette instruction, la pile deviendra vide.

Voici à quoi ressembleraient ces lignes de bytecode Java:

      int x = 1;
      int var3 = duration;
      if (x > var3) {
         ....L1...

Nous décompilons le code en utilisant Intellij IDEA le long du chemin Kotlin -> Java :

   public final void warmUp(int duration) {
      int x = 1;
      int var3 = duration;
      if (x <= duration) {
         while(true) {
            String var4 = "Warming";
            boolean var5 = false;
            System.out.println(var4);
            if (x == var3) {
               break;
            }
            ++x;
         }
      }
   }

Ici, vous pouvez voir les variables inutilisées ( var5) et l'absence d'appel de fonction println(). Ne vous inquiétez pas, cela est dû aux spécificités de la compilation des fonctions inline ( println()) et des expressions lambda. Il n'y aura pratiquement pas de frais généraux pour l'exécution de ces instructions, de plus, le code mort sera supprimé grâce à JIT. Il s'agit d'un sujet intéressant, qui devrait être consacré à un article séparé.

Tirant une analogie avec une cuisine, cette fonction peut être décrite comme une tâche pour un cuisinier de «faire bouillir de l'eau pendant 10 minutes». De plus, notre professionnel dans son domaine:

  1. ouvre un livre de cuisine (zone de méthode);
  2. trouve des instructions sur la façon de faire bouillir l'eau ( warmUp());
  3. prépare le lieu de travail, alloue une plaque chauffante (pile d'opérandes) et des conteneurs (variables locales) pour le stockage temporaire des produits.

Cuisine JVM. Un regard de l'intérieur. Travailler avec Heap


Considérez le code:

val pickles = Any()

Bytecode démonté:

    NEW java/lang/Object
    DUP
    INVOKESPECIAL java/lang/Object.<init> ()V
    ASTORE 3

NOUVEAU java / lang / Object - allocation de mémoire pour un objet Objectde classe à partir du tas. L'objet lui-même ne sera pas placé sur la pile, mais un lien vers celui-ci dans le tas:


DUP - duplication de l'élément "supérieur" de la pile. Un lien est nécessaire pour initialiser l'objet, le second pour l'enregistrer dans des variables locales:


INVOKESPECIAL java / lang / Object. <init> () V - initialisation de l'objet de la classe correspondante ( Object) par le lien de la pile:


ASTORE 3 est la dernière étape, enregistrer la référence à l'objet dans les variables locales avec l'index 3.

En faisant une analogie avec la cuisine, je comparerais la création d'un objet de classe avec la cuisson sur une table partagée (tas). Pour ce faire, vous devez vous allouer suffisamment d'espace sur la table de distribution, retourner sur le lieu de travail et envoyer une note avec l'adresse (référence) dans le conteneur approprié (variables locales). Et seulement après cela, commencez à créer un objet de la classe.

Cuisine JVM. Un regard de l'intérieur. Multithreading


Considérez maintenant cet exemple:

    fun add(ingredient: Any) {
        ingredientsCount = ingredientsCount.inc()
        //- 
    }

Ceci est un exemple classique du problème de threading. Nous avons un nombre d'ingrédients ingredientsCount. Une fonction add, en plus d'ajouter un ingrédient, effectue un incrément ingredientsCount.

Un bytecode démonté ressemble à ceci:

    ALOAD 0
    ALOAD 0
    GETFIELD com/company/Solenya.ingredientsCount : I
    ICONST_1
    IADD
    PUTFIELD com/company/Solenya.ingredientsCount : I

L'état de notre pile d'opérandes lors de l'exécution des instructions:


Lorsque vous travaillez dans un seul thread, tout sera exécuté correctement. S'il y a plusieurs threads, le problème suivant peut se produire. Imaginez que les deux threads obtiennent simultanément la valeur du champ ingredientsCountet l'écrivent sur la pile. Ensuite, l'état de la pile d'opérandes et du champ ingredientsCountpourrait ressembler à ceci:


La fonction a été exécutée deux fois (une fois par chaque thread) et la valeur ingredientsCountdoit être égale à 2. Mais en fait, l'un des threads fonctionnait avec une valeur obsolète ingredientsCount, et donc le résultat réel est 1 (problème de perte de mise à jour).

La situation est similaire au travail parallèle d'une équipe de chefs qui ajoutent des épices au plat. Imaginer:

  1. Il y a une table de distribution sur laquelle repose le plat (tas).
  2. Il y a deux cuisiniers dans la cuisine (Thread * 2).
  3. Chaque cuisinier a sa propre table de découpe, où il prépare un mélange d'épices (pile JVM * 2).
  4. Tâche: ajoutez deux portions d'épices au plat.
  5. Sur la table de distribution se trouve un morceau de papier avec lequel ils lisent et sur lequel ils écrivent quelle portion a été ajoutée ( ingredientsCount). Et pour économiser les épices:
    • Avant de commencer la préparation des épices, le cuisinier doit lire sur une feuille de papier que le nombre d'épices ajoutées n'est pas suffisant;
    • après avoir ajouté des épices, le cuisinier peut écrire combien, à son avis, des épices sont ajoutées au plat.

Dans ces conditions, une situation peut survenir:

  1. Le cuisinier n ° 1 a lu que 3 portions d'épices avaient été ajoutées.
  2. Le cuisinier n ° 2 a lu que 3 portions d'épices avaient été ajoutées.
  3. Les deux vont à leur bureau et préparent un mélange d'épices.
  4. Les deux chefs ajoutent des épices (3 + 2) au plat.
  5. Le cuisinier n ° 1 écrit que 4 portions d'épices ont été ajoutées.
  6. Le cuisinier n ° 2 écrit que 4 portions d'épices ont été ajoutées.

Conclusion: les produits manquaient, le plat était épicé, etc.

Pour éviter de telles situations, il existe différents outils comme les verrous, les fonctions de sécurité des threads, etc.

Résumer


Il est extrêmement rare qu'un développeur ait besoin d'explorer le bytecode, sauf si cela est spécifique à son travail. Dans le même temps, la compréhension du travail du bytecode aide à mieux comprendre le multithreading et les avantages d'une langue particulière, et contribue également à se développer professionnellement.

Il convient de noter que ceux-ci sont loin de toutes les parties de la JVM. Il y a beaucoup plus de «choses» intéressantes, par exemple, pool constant, vérificateur de bytecode, JIT, cache de code, etc. Mais pour ne pas surcharger l'article, je me suis concentré uniquement sur les éléments nécessaires à une compréhension commune.

Liens utiles:


All Articles