Kochbytecode in der JVM-Küche

Mein Name ist Alexander Kotsyuruba, ich leite die Entwicklung der internen Dienste bei DomKlik. Viele erfahrene Java-Entwickler verstehen die interne Struktur der JVM. Um diese Reise des Java Samurai zu erleichtern, habe ich mich entschlossen, die Grundlagen der Java Virtual Machine (JVM) zu erläutern und mit Bytecode in einer einfachen Sprache zu arbeiten.

Was ist ein mysteriöser Bytecode und wo lebt er?

Ich werde versuchen, diese Frage am Beispiel des Beizens zu beantworten.



Warum brauche ich eine JVM und einen Bytecode?


Die JVM entstand unter dem Motto Write Once Run Anywhere (WORA) bei Sun Microsystems. Im Gegensatz zu der Write - Once - Compile überall (WOCA) Konzept impliziert WORA eine virtuelle Maschine für jedes Betriebssystem , das ausgeführt werden einmal auf- kompilierten Code (Bytecode).


Einmal schreiben, überall ausführen (WORA)


Write Once Compile Anywhere (WOCA)

JVM und Bytecode bilden die Grundlage des WORA-Konzepts und ersparen uns die Nuancen und die Notwendigkeit, für jedes Betriebssystem zu kompilieren.

Bytecode


Um zu verstehen, was ein Bytecode ist, schauen wir uns ein Beispiel an. Natürlich macht dieser Code nichts Nützliches, er dient nur zur weiteren Analyse.

Quelle:

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 = {}
}

Mit den integrierten Intellij IDEA-Tools ( Tools -> Kotlin -> Kotlin-Bytecode anzeigen ) erhalten wir einen zerlegten Bytecode (im Beispiel wird nur ein Teil gezeigt):

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

Auf den ersten Blick - eine unverständliche Anleitung. Um zu verstehen, wie und womit sie arbeiten, müssen Sie in die innere Küche der JVM eintauchen.

JVM Küche


Schauen wir uns den JVM-Laufzeitspeicher an:



Wir können sagen, dass JVM unsere Küche ist. Betrachten Sie als nächstes die verbleibenden Teilnehmer:

Methodenbereich - Kochbuch



Im Methodenbereich wird der kompilierte Code für jede Funktion gespeichert. Wenn ein Thread beginnt, eine Funktion auszuführen, erhält er im Allgemeinen Anweisungen aus diesem Bereich. In der Tat ist es ein kulinarisches Rezeptbuch, das ausführlich beschreibt, wie man alles von Rührei bis katalanischer Zarzuela kocht.

Thread 1..N - Teamköche



Streams folgen genau den von ihnen vorgeschriebenen Anweisungen (Methodenbereich), dafür haben sie PC Register und JVM Stack. Sie können jeden Stream mit einem Koch vergleichen, der die ihm erteilte Aufgabe genau nach den Rezepten aus dem Kochbuch ausführt.

PC Register - Feldnotizen



Programmzählerregister - der Befehlszähler unseres Streams. Es speichert die Adresse des ausgeführten Befehls. In der Küche wären dies einige Notizen darüber, auf welcher Seite des Kochbuchs wir uns jetzt befinden.

Jvm-Stapel


Stapel von Rahmen. Für jede Funktion wird ein Frame zugewiesen, in dem der aktuelle Thread mit Variablen und Operanden arbeitet. Als Teil der Analogie zur Herstellung unserer Gurken könnte dies eine Reihe verschachtelter Operationen sein:

-> -> -> ...

Rahmen - Desktop



Der Rahmen fungiert als Desktop des Kochs, auf dem ein Schneidebrett und signierte Behälter liegen.

Lokale Variablen - Signierte Container



Dies ist ein Array lokaler Variablen (lokale Variablentabelle), in dem, wie der Name schon sagt, die Werte, der Typ und der Umfang lokaler Variablen gespeichert werden. Dies ähnelt signierten Containern, in denen Sie Zwischenergebnisse der beruflichen Tätigkeit hinzufügen können.

Operandenstapel - Schneidebrett



Der Operandenstapel speichert Argumente für JVM-Anweisungen. Zum Beispiel ganzzahlige Werte für die Additionsoperation, Verweise auf Heap-Objekte usw.

Das nächste Beispiel, das ich geben kann, ist ein Schneidebrett, auf dem sich eine Tomate und eine Gurke gleichzeitig in einen Salat verwandeln. Im Gegensatz zu lokalen Variablen setzen wir nur das auf die Tafel, womit wir den nächsten Befehl ausführen.

Heap - Verteilungstabelle



Im Rahmen der Arbeit mit dem Frame arbeiten wir mit Links zu Objekten, die Objekte selbst werden auf einem Haufen gespeichert. Ein wichtiger Unterschied besteht darin, dass der Frame nur zu einem Thread gehört und lokale Variablen "live" sind, während der Frame aktiv ist (die Funktion wird ausgeführt). Der Heap steht anderen Threads zur Verfügung und bleibt so lange bestehen, bis der Garbage Collector aktiviert ist. In Analogie zur Küche können wir ein Beispiel mit einer Verteilungstabelle geben, was allein üblich ist. Und ein separates Team von Reinigungskräften reinigt es.

JVM Küche. Ein Blick von innen. Arbeite mit Frame


Beginnen wir mit der Funktion warmUp:

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

Zerlegte Bytecode-Funktion:

  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

Rahmeninitialisierung - Arbeitsplatzvorbereitung


Um diese Funktion auszuführen, wird im JVM-Stack-Stream ein Frame erstellt. Ich möchte Sie daran erinnern, dass der Stapel aus einem Array lokaler Variablen und einem Operandenstapel besteht.

  1. Damit wir verstehen, wie viel Speicher für diesen Frame reserviert werden muss, hat der Compiler Metainformationen zu dieser Funktion bereitgestellt (Erläuterung im Codekommentar):

        MAXSTACK = 2 //    2*32bit
        MAXLOCALS = 6 //    6*32bit
    
  2. Wir haben auch Informationen zu einigen Elementen des lokalen Variablenarrays:

        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. Die Argumente der Funktion beim Initialisieren des Frames fallen in lokale Variablen. In diesem Beispiel wird der Dauerwert mit Index 1 in das Array geschrieben.

So sieht der Rahmen zunächst so aus:


Starten Sie die Ausführung von Anweisungen


Um zu verstehen, wie der Frame funktioniert, bewaffnen Sie sich einfach mit einer Liste von JVM-Anweisungen ( Java-Bytecode-Anweisungslisten ) und gehen Sie die Beschriftung durch 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) im Operandenstapel:



ISTORE 2 - Pull-Wert (Typ Int) des Operandenstapels und Schreiben in lokale Variablen mit dem Index 2:



Diese beiden Operationen können in einem Java-Code interpretiert werden : int x = 1.

ILOAD 1 - Laden des Werts lokaler Variablen mit Index 1 in den Operandenstapel:



ISTORE 3 - Pull-Wert (Typ Int) des Operandenstapels und Schreiben in lokale Variablen mit einem Index von 3:



Diese beiden Operationen können in einem Java-Code interpretiert werden : int var3 = duration.

ILOAD 2 - Laden Sie einen Wert aus lokalen Variablen mit dem Index 2 in den Operandenstapel.

ILOAD 3 - Ladewert von lokalen Variablen mit Index 3 im Operandenstapel:



IF_ICMPGT L1- Anweisung zum Vergleichen von zwei ganzzahligen Werten aus dem Stapel. Wenn der "untere" Wert größer als der "obere" ist, gehen Sie zum Etikett L1. Nach Ausführung dieser Anweisung wird der Stapel leer.

So würden diese Java-Bytecode-Zeilen aussehen:

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

Wir dekompilieren den Code mit Intellij IDEA entlang des Kotlin -> Java- Pfads :

   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;
         }
      }
   }

Hier sehen Sie nicht verwendete Variablen ( var5) und das Fehlen eines Funktionsaufrufs println(). Keine Sorge, dies liegt an den Besonderheiten beim Kompilieren von Inline-Funktionen ( println()) und Lambda-Ausdrücken. Es gibt praktisch keinen Aufwand für die Ausführung dieser Anweisungen, außerdem wird toter Code dank JIT gelöscht. Dies ist ein interessantes Thema, das einem separaten Artikel gewidmet sein sollte.

In Anlehnung an eine Küche kann diese Funktion als Aufgabe eines Kochs beschrieben werden, „10 Minuten lang Wasser zu kochen“. Weiter unser Fachmann auf seinem Gebiet:

  1. öffnet ein Kochbuch (Methodenbereich);
  2. findet Anweisungen zum Kochen von Wasser ( warmUp());
  3. bereitet den Arbeitsplatz vor und weist eine Heizplatte (Operandenstapel) und Behälter (lokale Variablen) für die vorübergehende Lagerung von Produkten zu.

JVM Küche. Ein Blick von innen. Arbeite mit Heap


Betrachten Sie den Code:

val pickles = Any()

Zerlegter Bytecode:

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

NEU java / lang / Object - Speicherzuordnung für ein Klassenobjekt Objectaus dem Heap. Das Objekt selbst wird nicht auf dem Stapel abgelegt, sondern im Heap verlinkt:


DUP - Duplizieren des "obersten" Elements des Stapels. Ein Link wird benötigt, um das Objekt zu initialisieren, der zweite, um es in lokalen Variablen zu speichern:


INVOKESPECIAL java / lang / Object. <Init> () V - Initialisierung des Objekts der entsprechenden Klasse ( Object) durch die Verknüpfung vom Stapel:


ASTORE 3 ist der letzte Schritt, bei dem der Verweis auf das Objekt in lokalen Variablen mit Index 3 gespeichert wird.

In Analogie zur Küche würde ich die Erstellung eines Klassenobjekts mit dem Kochen auf einem gemeinsam genutzten Tisch (Heap) vergleichen. Dazu müssen Sie genügend Platz für sich selbst in der Verteilungstabelle reservieren, zum Arbeitsplatz zurückkehren und eine Notiz mit der Adresse (Referenz) in den entsprechenden Container (lokale Variablen) werfen. Und erst danach erstellen Sie ein Objekt der Klasse.

JVM Küche. Ein Blick von innen. Multithreading


Betrachten Sie nun dieses Beispiel:

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

Dies ist ein klassisches Beispiel für das Threading-Problem. Wir haben eine Anzahl von Zutaten ingredientsCount. Eine Funktion addführt zusätzlich zum Hinzufügen einer Zutat ein Inkrement durch ingredientsCount.

Ein zerlegter Bytecode sieht folgendermaßen aus:

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

Der Status unseres Operandenstapels, während die Anweisungen ausgeführt werden:


Wenn Sie in einem Thread arbeiten, wird alles korrekt ausgeführt. Wenn mehrere Threads vorhanden sind, kann das folgende Problem auftreten. Stellen Sie sich vor, beide Threads haben gleichzeitig den Feldwert erhalten ingredientsCountund auf den Stapel geschrieben. Dann könnte der Status des Operandenstapels und des Felds folgendermaßen ingredientsCountaussehen:


Die Funktion wurde zweimal ausgeführt (einmal von jedem Thread) und der Wert ingredientsCountsollte gleich 2 sein. Tatsächlich arbeitete jedoch einer der Threads mit einem veralteten Wert ingredientsCount, und daher ist das tatsächliche Ergebnis 1 (Problem mit verlorenem Update).

Die Situation ähnelt der parallelen Arbeit eines Teams von Köchen, die dem Gericht Gewürze hinzufügen. Vorstellen:

  1. Es gibt eine Verteilungstabelle, auf der das Gericht (Haufen) liegt.
  2. Es gibt zwei Köche in der Küche (Thread * 2).
  3. Jeder Koch hat einen eigenen Schneidetisch, an dem er eine Gewürzmischung zubereitet (JVM Stack * 2).
  4. Aufgabe: Fügen Sie dem Gericht zwei Portionen Gewürze hinzu.
  5. Auf dem Verteilungstisch liegt ein Stück Papier, mit dem sie lesen und schreiben, welcher Teil hinzugefügt wurde ( ingredientsCount). Und um Gewürze zu sparen:
    • Bevor mit der Zubereitung von Gewürzen begonnen wird, muss der Koch auf einem Blatt Papier lesen, dass die Anzahl der hinzugefügten Gewürze nicht ausreicht.
    • Nach dem Hinzufügen von Gewürzen kann der Koch schreiben, wie viele Gewürze seiner Meinung nach dem Gericht hinzugefügt werden.

Unter solchen Bedingungen kann eine Situation entstehen:

  1. Koch Nr. 1 las, dass 3 Portionen Gewürze hinzugefügt wurden.
  2. Koch Nr. 2 las, dass 3 Portionen Gewürze hinzugefügt wurden.
  3. Beide gehen zu ihren Schreibtischen und bereiten eine Gewürzmischung zu.
  4. Beide Köche geben dem Gericht Gewürze (3 + 2).
  5. Koch Nr. 1 schreibt, dass 4 Portionen Gewürze hinzugefügt wurden.
  6. Koch Nr. 2 schreibt, dass 4 Portionen Gewürze hinzugefügt wurden.

Fazit: Die Produkte fehlten, das Gericht war scharf usw.

Um solche Situationen zu vermeiden, gibt es verschiedene Werkzeuge wie Schlösser, Gewindesicherheitsfunktionen usw.

Zusammenfassen


Es ist äußerst selten, dass ein Entwickler in Bytecode kriechen muss, es sei denn, dies ist spezifisch für seine Arbeit. Gleichzeitig hilft das Verständnis der Arbeit mit Bytecode, das Multithreading und die Vorteile einer bestimmten Sprache besser zu verstehen und professionell zu wachsen.

Es ist erwähnenswert, dass diese weit von allen Teilen der JVM entfernt sind. Es gibt viele weitere interessante „Dinge“, zum Beispiel einen konstanten Pool, einen Bytecode-Verifizierer, eine JIT, einen Code-Cache usw. Um den Artikel nicht zu überladen, habe ich mich nur auf die Elemente konzentriert, die für ein gemeinsames Verständnis notwendig sind.

Nützliche Links:


All Articles