Machen Sie sich mit Scala JVM Bytecode die Hände schmutzig
Veröffentlicht: 2022-03-11Die Scala-Sprache hat in den letzten Jahren dank ihrer hervorragenden Kombination aus funktionalen und objektorientierten Softwareentwicklungsprinzipien und ihrer Implementierung auf der bewährten Java Virtual Machine (JVM) weiter an Popularität gewonnen.
Obwohl Scala in Java-Bytecode kompiliert, wurde es entwickelt, um viele der wahrgenommenen Mängel der Java-Sprache zu verbessern. Die Kernsyntax von Scala bietet eine vollständige funktionale Programmierunterstützung und enthält viele implizite Strukturen, die explizit von Java-Programmierern erstellt werden müssen, wobei einige von ihnen eine beträchtliche Komplexität mit sich bringen.
Das Erstellen einer Sprache, die in Java-Bytecode kompiliert wird, erfordert ein tiefes Verständnis der inneren Funktionsweise der Java Virtual Machine. Um zu verstehen, was die Entwickler von Scala erreicht haben, ist es notwendig, unter die Haube zu gehen und zu untersuchen, wie der Quellcode von Scala vom Compiler interpretiert wird, um einen effizienten und effektiven JVM-Bytecode zu erzeugen.
Werfen wir einen Blick darauf, wie all diese Dinge implementiert werden.
Voraussetzungen
Das Lesen dieses Artikels erfordert ein grundlegendes Verständnis des Java Virtual Machine-Bytecodes. Die vollständige Spezifikation der virtuellen Maschine finden Sie in der offiziellen Dokumentation von Oracle. Das Lesen der gesamten Spezifikation ist für das Verständnis dieses Artikels nicht entscheidend, daher habe ich für eine schnelle Einführung in die Grundlagen eine kurze Anleitung am Ende des Artikels vorbereitet.
Ein Dienstprogramm ist erforderlich, um den Java-Bytecode zu disassemblieren, um die unten bereitgestellten Beispiele zu reproduzieren und mit weiteren Untersuchungen fortzufahren. Das Java Development Kit bietet ein eigenes Befehlszeilenprogramm, javap
, das wir hier verwenden werden. Eine kurze Demonstration der Funktionsweise von javap
ist in der Anleitung unten enthalten.
Und natürlich ist eine funktionierende Installation des Scala-Compilers für Leser erforderlich, die den Beispielen folgen möchten. Dieser Artikel wurde mit Scala 2.11.7 geschrieben. Unterschiedliche Versionen von Scala können leicht unterschiedliche Bytecodes erzeugen.
Standard-Getter und -Setter
Obwohl die Java-Konvention immer Getter- und Setter-Methoden für öffentliche Attribute bereitstellt, müssen Java-Programmierer diese selbst schreiben, obwohl sich das Muster für beide seit Jahrzehnten nicht geändert hat. Scala hingegen bietet Standard-Getter und -Setter.
Schauen wir uns das folgende Beispiel an:
class Person(val name:String) { }
Werfen wir einen Blick in die Klasse Person
. Wenn wir diese Datei mit scalac
kompilieren, erhalten wir beim Ausführen $ javap -p Person.class
:
Compiled from "Person.scala" public class Person { private final java.lang.String name; // field public java.lang.String name(); // getter method public Person(java.lang.String); // constructor }
Wir können sehen, dass für jedes Feld in der Scala-Klasse ein Feld und seine Getter-Methode generiert werden. Das Feld ist privat und final, während die Methode öffentlich ist.
Wenn wir in der Person
-Quelle val
durch var
ersetzen und neu kompilieren, wird der final
Modifikator des Felds gelöscht und die Setter-Methode wird ebenfalls hinzugefügt:
Compiled from "Person.scala" public class Person { private java.lang.String name; // field public java.lang.String name(); // getter method public void name_$eq(java.lang.String); // setter method public Person(java.lang.String); // constructor }
Wenn innerhalb des Klassenkörpers ein Wert oder eine val
var
ist, werden das entsprechende private Feld und die Zugriffsmethoden erstellt und bei der Instanzerstellung entsprechend initialisiert.
Beachten Sie, dass eine solche Implementierung von val
und var
-Feldern auf Klassenebene bedeutet, dass, wenn einige Variablen auf Klassenebene zum Speichern von Zwischenwerten verwendet werden und auf die der Programmierer nie direkt zugreift, die Initialisierung jedes dieser Felder ein bis zwei Methoden hinzufügt Klasse Fußabdruck. Das Hinzufügen eines private
Modifikators für solche Felder bedeutet nicht, dass die entsprechenden Accessoren gelöscht werden. Sie werden nur privat.
Variablen- und Funktionsdefinitionen
Nehmen wir an, wir haben eine Methode, m()
, und erstellen drei verschiedene Verweise im Scala-Stil auf diese Funktion:
class Person(val name:String) { def m(): Int = { // ... return 0 } val m1 = m var m2 = m def m3 = m }
Wie sind diese Verweise auf m
jeweils aufgebaut? Wann wird m
jeweils ausgeführt? Schauen wir uns den resultierenden Bytecode an. Die folgende Ausgabe zeigt die Ergebnisse von javap -v Person.class
(wobei viele überflüssige Ausgaben weggelassen werden):
Constant pool: #22 = Fieldref #2.#21 // Person.m1:I #24 = Fieldref #2.#23 // Person.m2:I #30 = Methodref #2.#29 // Person.m:()I #35 = Methodref #4.#34 // java/lang/Object."<init>":()V // ... public int m(); Code: // other methods refer to this method // ... public int m1(); Code: // get the value of field m1 and return it 0: aload_0 1: getfield #22 // Field m1:I 4: ireturn public int m2(); Code: // get the value of field m2 and return it 0: aload_0 1: getfield #24 // Field m2:I 4: ireturn public void m2_$eq(int); Code: // get the value of this method's input argument 0: aload_0 1: iload_1 // write it to the field m2 and return 2: putfield #24 // Field m2:I 5: return public int m3(); Code: // execute the instance method m(), and return 0: aload_0 1: invokevirtual #30 // Method m:()I 4: ireturn public Person(java.lang.String); Code: // instance constructor ... // execute the instance method m(), and write the result to field m1 9: aload_0 10: aload_0 11: invokevirtual #30 // Method m:()I 14: putfield #22 // Field m1:I // execute the instance method m(), and write the result to field m2 17: aload_0 18: aload_0 19: invokevirtual #30 // Method m:()I 22: putfield #24 // Field m2:I 25: return
Im Konstantenpool sehen wir, dass die Referenz auf die Methode m()
am Index #30
gespeichert ist. Im Konstruktorcode sehen wir, dass diese Methode während der Initialisierung zweimal aufgerufen wird, wobei die Anweisung invokevirtual #30
zuerst bei Byte-Offset 11 und dann bei Offset 19 erscheint. Dem ersten Aufruf folgt die Anweisung putfield #22
, die das Ergebnis von zuweist diese Methode auf das Feld m1
, auf das durch Index #22
im Konstantenpool verwiesen wird. Auf den zweiten Aufruf folgt das gleiche Muster, wobei dieses Mal der Wert dem Feld m2
wird, das im Konstantenpool bei #24
indiziert ist.
Mit anderen Worten, wenn Sie eine Methode einer mit val
oder var
definierten Variablen zuweisen, wird dieser Variablen nur das Ergebnis der Methode zugewiesen. Wir können sehen, dass die erstellten Methoden m1()
und m2()
einfach Getter für diese Variablen sind. Im Fall von var m2
sehen wir auch, dass der Setter m2_$eq(int)
erstellt wird, der sich wie jeder andere Setter verhält und den Wert im Feld überschreibt.
Die Verwendung des Schlüsselworts def
führt jedoch zu einem anderen Ergebnis. Anstatt einen zurückzugebenden Feldwert abzurufen, enthält die Methode m3()
auch die Anweisung invokevirtual #30
. Das heißt, jedes Mal, wenn diese Methode aufgerufen wird, ruft sie dann m()
auf und gibt das Ergebnis dieser Methode zurück.
Wie wir also sehen können, bietet Scala drei Möglichkeiten, mit Klassenfeldern zu arbeiten, und diese lassen sich einfach über die Schlüsselwörter val
, var
und def
angeben. In Java müssten wir die notwendigen Setter und Getter explizit implementieren, und ein solcher manuell geschriebener Boilerplate-Code wäre viel weniger ausdrucksstark und fehleranfälliger.
Faule Werte
Komplizierterer Code wird erzeugt, wenn ein fauler Wert deklariert wird. Angenommen, wir haben der zuvor definierten Klasse das folgende Feld hinzugefügt:
lazy val m4 = m
Beim Ausführen javap -p -v Person.class
wird nun Folgendes angezeigt:
Constant pool: #20 = Fieldref #2.#19 // Person.bitmap$0:Z #23 = Methodref #2.#22 // Person.m:()I #25 = Fieldref #2.#24 // Person.m4:I #31 = Fieldref #27.#30 // scala/runtime/BoxedUnit.UNIT:Lscala/runtime/BoxedUnit; #48 = Methodref #2.#47 // Person.m4$lzycompute:()I // ... private volatile boolean bitmap$0; private int m4$lzycompute(); Code: // lock the thread 0: aload_0 1: dup 2: astore_1 3: monitorenter // check the flag for whether this field has already been set 4: aload_0 5: getfield #20 // Field bitmap$0:Z // if it has, skip to position 24 (unlock the thread and return) 8: ifne 24 // if it hasn't, execute the method m() 11: aload_0 12: aload_0 13: invokevirtual #23 // Method m:()I // write the method to the field m4 16: putfield #25 // Field m4:I // set the flag indicating the field has been set 19: aload_0 20: iconst_1 21: putfield #20 // Field bitmap$0:Z // unlock the thread 24: getstatic #31 // Field scala/runtime/BoxedUnit.UNIT:Lscala/runtime/BoxedUnit; 27: pop 28: aload_1 29: monitorexit // get the value of field m4 and return it 30: aload_0 31: getfield #25 // Field m4:I 34: ireturn // ... public int m4(); Code: // check the flag for whether this field has already been set 0: aload_0 1: getfield #20 // Field bitmap$0:Z // if it hasn't, skip to position 14 (invoke lazy method and return) 4: ifeq 14 // if it has, get the value of field m4, then skip to position 18 (return) 7: aload_0 8: getfield #25 // Field m4:I 11: goto 18 // execute the method m4$lzycompute() to set the field 14: aload_0 15: invokespecial #48 // Method m4$lzycompute:()I // return 18: ireturn
In diesem Fall wird der Wert des Felds m4
erst berechnet, wenn er benötigt wird. Die spezielle, private Methode m4$lzycompute()
wird erzeugt, um den faulen Wert zu berechnen, und das Feld bitmap$0
, um seinen Zustand zu verfolgen. Die Methode m4()
prüft, ob der Wert dieses Felds 0 ist, was anzeigt, dass m4
noch nicht initialisiert wurde, in welchem Fall m4$lzycompute()
aufgerufen wird, m4
füllt und seinen Wert zurückgibt. Diese private Methode setzt auch den Wert von bitmap$0
auf 1, sodass sie beim nächsten Aufruf von m4()
den Aufruf der Initialisierungsmethode überspringt und stattdessen einfach den Wert von m4
.
Der Bytecode, den Scala hier produziert, ist so konzipiert, dass er sowohl Thread-sicher als auch effektiv ist. Um Thread-sicher zu sein, verwendet die Lazy-Compute-Methode das Anweisungspaar monitorenter
/ monitorexit
. Das Verfahren bleibt wirksam, da der Leistungsaufwand dieser Synchronisation nur beim ersten Lesen des faulen Werts auftritt.
Es wird nur ein Bit benötigt, um den Zustand des faulen Werts anzuzeigen. Wenn es also nicht mehr als 32 Lazy-Werte gibt, kann ein einziges int-Feld sie alle verfolgen. Wenn im Quellcode mehr als ein fauler Wert definiert ist, wird der obige Bytecode vom Compiler modifiziert, um zu diesem Zweck eine Bitmaske zu implementieren.
Auch hier ermöglicht uns Scala, auf einfache Weise eine bestimmte Art von Verhalten zu nutzen, die explizit in Java implementiert werden müsste, wodurch Aufwand gespart und das Risiko von Tippfehlern verringert wird.
Funktion als Wert
Schauen wir uns nun den folgenden Scala-Quellcode an:
class Printer(val output: String => Unit) { } object Hello { def main(arg: Array[String]) { val printer = new Printer( s => println(s) ); printer.output("Hello"); } }
Die Printer
-Klasse hat ein Feld, output
, mit dem Typ String => Unit
: eine Funktion, die einen String
nimmt und ein Objekt vom Typ Unit
zurückgibt (ähnlich void
in Java). In der Hauptmethode erstellen wir eines dieser Objekte und weisen diesem Feld eine anonyme Funktion zu, die eine bestimmte Zeichenfolge ausgibt.
Das Kompilieren dieses Codes generiert vier Klassendateien:
Hello.class
ist eine Wrapper-Klasse, deren Hauptmethode einfach Hello$.main()
:
public final class Hello // ... public static void main(java.lang.String[]); Code: 0: getstatic #16 // Field Hello$.MODULE$:LHello$; 3: aload_0 4: invokevirtual #18 // Method Hello$.main:([Ljava/lang/String;)V 7: return
Die versteckte Hello$.class
enthält die eigentliche Implementierung der Hauptmethode. Um einen Blick auf seinen Bytecode zu werfen, stellen Sie sicher, dass Sie $
gemäß den Regeln Ihrer Befehlsshell korrekt maskieren, um seine Interpretation als Sonderzeichen zu vermeiden:
public final class Hello$ // ... public void main(java.lang.String[]); Code: // initialize Printer and anonymous function 0: new #16 // class Printer 3: dup 4: new #18 // class Hello$$anonfun$1 7: dup 8: invokespecial #19 // Method Hello$$anonfun$1."<init>":()V 11: invokespecial #22 // Method Printer."<init>":(Lscala/Function1;)V 14: astore_2 // load the anonymous function onto the stack 15: aload_2 16: invokevirtual #26 // Method Printer.output:()Lscala/Function1; // execute the anonymous function, passing the string "Hello" 19: ldc #28 // String Hello 21: invokeinterface #34, 2 // InterfaceMethod scala/Function1.apply:(Ljava/lang/Object;)Ljava/lang/Object; // return 26: pop 27: return
Die Methode erstellt einen Printer
. Es erstellt dann ein Hello$$anonfun$1
, das unsere anonyme Funktion s => println(s)
enthält. Der Printer
wird mit diesem Objekt als output
initialisiert. Dieses Feld wird dann auf den Stack geladen und mit dem Operanden "Hello"
ausgeführt.
Sehen wir uns unten die anonyme Funktionsklasse Hello$$anonfun$1.class
an. Wir können sehen, dass es Scalas Function1
(als AbstractFunction1
) erweitert, indem es die apply()
-Methode implementiert. Tatsächlich erstellt es zwei apply()
-Methoden, von denen eine die andere umschließt, die zusammen eine Typprüfung durchführen (in diesem Fall, dass die Eingabe ein String
ist) und die anonyme Funktion ausführen (die Eingabe mit println()
).
public final class Hello$$anonfun$1 extends scala.runtime.AbstractFunction1<java.lang.String, scala.runtime.BoxedUnit> implements scala.Serializable // ... // Takes an argument of type String. Invoked second. public final void apply(java.lang.String); Code: // execute Scala's built-in method println(), passing the input argument 0: getstatic #25 // Field scala/Predef$.MODULE$:Lscala/Predef$; 3: aload_1 4: invokevirtual #29 // Method scala/Predef$.println:(Ljava/lang/Object;)V 7: return // Takes an argument of type Object. Invoked first. public final java.lang.Object apply(java.lang.Object); Code: 0: aload_0 // check that the input argument is a String (throws exception if not) 1: aload_1 2: checkcast #36 // class java/lang/String // invoke the method apply( String ), passing the input argument 5: invokevirtual #38 // Method apply:(Ljava/lang/String;)V // return the void type 8: getstatic #44 // Field scala/runtime/BoxedUnit.UNIT:Lscala/runtime/BoxedUnit; 11: areturn
Wenn wir uns die Methode Hello$.main()
oben noch einmal ansehen, können wir sehen, dass bei Offset 21 die Ausführung der anonymen Funktion durch einen Aufruf ihrer Methode apply( Object )
ausgelöst wird.
Schauen wir uns der Vollständigkeit halber den Bytecode für Printer.class
an:
public class Printer // ... // field private final scala.Function1<java.lang.String, scala.runtime.BoxedUnit> output; // field getter public scala.Function1<java.lang.String, scala.runtime.BoxedUnit> output(); Code: 0: aload_0 1: getfield #14 // Field output:Lscala/Function1; 4: areturn // constructor public Printer(scala.Function1<java.lang.String, scala.runtime.BoxedUnit>); Code: 0: aload_0 1: aload_1 2: putfield #14 // Field output:Lscala/Function1; 5: aload_0 6: invokespecial #21 // Method java/lang/Object."<init>":()V 9: return
Wir können sehen, dass die anonyme Funktion hier wie jede val
Variable behandelt wird. Es wird im Klassenfeld output
gespeichert und der Getter output()
erzeugt. Der einzige Unterschied besteht darin, dass diese Variable jetzt die Scala-Schnittstelle scala.Function1
implementieren muss (was AbstractFunction1
tut).
Die Kosten dieser eleganten Scala-Funktion sind also die zugrunde liegenden Hilfsklassen, die erstellt wurden, um eine einzelne anonyme Funktion darzustellen und auszuführen, die als Wert verwendet werden kann. Sie sollten die Anzahl solcher Funktionen sowie Details Ihrer VM-Implementierung berücksichtigen, um herauszufinden, was dies für Ihre spezielle Anwendung bedeutet.
Scala-Eigenschaften
Die Eigenschaften von Scala ähneln Schnittstellen in Java. Die folgende Eigenschaft definiert zwei Methodensignaturen und stellt eine Standardimplementierung der zweiten bereit. Mal sehen, wie es umgesetzt wird:
trait Similarity { def isSimilar(x: Any): Boolean def isNotSimilar(x: Any): Boolean = !isSimilar(x) }
Es werden zwei Entitäten erzeugt: Similarity.class
, die Schnittstelle, die beide Methoden deklariert, und die synthetische Klasse Similarity$class.class
, die die Standardimplementierung bereitstellt:
public interface Similarity { public abstract boolean isSimilar(java.lang.Object); public abstract boolean isNotSimilar(java.lang.Object); }
public abstract class Similarity$class public static boolean isNotSimilar(Similarity, java.lang.Object); Code: 0: aload_0 // execute the instance method isSimilar() 1: aload_1 2: invokeinterface #13, 2 // InterfaceMethod Similarity.isSimilar:(Ljava/lang/Object;)Z // if the returned value is 0, skip to position 14 (return with value 1) 7: ifeq 14 // otherwise, return with value 0 10: iconst_0 11: goto 15 // return the value 1 14: iconst_1 15: ireturn public static void $init$(Similarity); Code: 0: return
Wenn eine Klasse dieses Merkmal implementiert und die Methode isNotSimilar
, generiert der Scala-Compiler die Bytecode-Anweisung invokestatic
, um die statische Methode aufzurufen, die von der begleitenden Klasse bereitgestellt wird.
Aus Merkmalen können komplexe Polymorphismen und Vererbungsstrukturen erstellt werden. Beispielsweise können mehrere Merkmale sowie die implementierende Klasse alle eine Methode mit derselben Signatur überschreiben und super.methodName()
aufrufen, um die Kontrolle an das nächste Merkmal zu übergeben. Wenn der Scala-Compiler auf solche Aufrufe stößt, geschieht Folgendes:

- Legt fest, welches genaue Merkmal von diesem Aufruf angenommen wird.
- Bestimmt den Namen der begleitenden Klasse, die den für die Eigenschaft definierten statischen Methoden-Bytecode bereitstellt.
- Erzeugt die notwendige
invokestatic
Anweisung.
So können wir sehen, dass das leistungsstarke Traits-Konzept auf der JVM-Ebene so implementiert wird, dass es nicht zu erheblichem Overhead führt, und Scala-Programmierer können diese Funktion genießen, ohne sich Sorgen machen zu müssen, dass sie zur Laufzeit zu teuer wird.
Singles
Scala sieht die explizite Definition von Singleton-Klassen mit dem Schlüsselwort object
vor. Betrachten wir die folgende Singleton-Klasse:
object Config { val home_dir = "/home/user" }
Der Compiler erzeugt zwei Klassendateien:
Config.class
ist ziemlich einfach:
public final class Config public static java.lang.String home_dir(); Code: // execute the method Config$.home_dir() 0: getstatic #16 // Field Config$.MODULE$:LConfig$; 3: invokevirtual #18 // Method Config$.home_dir:()Ljava/lang/String; 6: areturn
Dies ist nur ein Decorator für die synthetische Config$
-Klasse, die die Singleton-Funktionalität einbettet. Die Untersuchung dieser Klasse mit javap -p -c
erzeugt den folgenden Bytecode:
public final class Config$ public static final Config$ MODULE$; // a public reference to the singleton object private final java.lang.String home_dir; // static initializer public static {}; Code: 0: new #2 // class Config$ 3: invokespecial #12 // Method "<init>":()V 6: return public java.lang.String home_dir(); Code: // get the value of field home_dir and return it 0: aload_0 1: getfield #17 // Field home_dir:Ljava/lang/String; 4: areturn private Config$(); Code: // initialize the object 0: aload_0 1: invokespecial #19 // Method java/lang/Object."<init>":()V // expose a public reference to this object in the synthetic variable MODULE$ 4: aload_0 5: putstatic #21 // Field MODULE$:LConfig$; // load the value "/home/user" and write it to the field home_dir 8: aload_0 9: ldc #23 // String /home/user 11: putfield #17 // Field home_dir:Ljava/lang/String; 14: return
Es besteht aus Folgendem:
- Die synthetische Variable
MODULE$
, über die andere Objekte auf dieses Singleton-Objekt zugreifen. - Der statische Initialisierer
{}
(auch bekannt als<clinit>
, der Klasseninitialisierer) und die private MethodeConfig$
, die zum InitialisierenMODULE$
und zum Festlegen seiner Felder auf Standardwerte verwendet werden - Eine Getter-Methode für das statische Feld
home_dir
. In diesem Fall ist es nur eine Methode. Wenn das Singleton mehr Felder hat, hat es mehr Getter sowie Setter für änderbare Felder.
Das Singleton ist ein beliebtes und nützliches Designmuster. Die Java-Sprache bietet keine direkte Möglichkeit, sie auf Sprachebene anzugeben; vielmehr liegt es in der Verantwortung des Entwicklers, es im Java-Quellcode zu implementieren. Scala hingegen bietet eine klare und bequeme Möglichkeit, ein Singleton explizit mit dem Schlüsselwort object
zu deklarieren. Wie wir bei einem Blick unter die Haube sehen können, ist es auf erschwingliche und natürliche Weise implementiert.
Fazit
Wir haben jetzt gesehen, wie Scala mehrere implizite und funktionale Programmierfunktionen in ausgeklügelte Java-Bytecode-Strukturen kompiliert. Mit diesem Einblick in das Innenleben von Scala können wir die Leistungsfähigkeit von Scala besser einschätzen und uns dabei helfen, das Beste aus dieser mächtigen Sprache herauszuholen.
Wir haben jetzt auch die Werkzeuge, um die Sprache selbst zu erforschen. Es gibt viele nützliche Funktionen der Scala-Syntax, die in diesem Artikel nicht behandelt werden, z. B. Fallklassen, Currying und List Comprehensions. Ich ermutige Sie, Scalas Implementierung dieser Strukturen selbst zu untersuchen, damit Sie lernen, wie man ein Scala-Ninja der nächsten Stufe wird!
Die Java Virtual Machine: Ein Crashkurs
Genau wie der Java-Compiler konvertiert der Scala-Compiler Quellcode in .class
-Dateien, die Java-Bytecode enthalten, der von der Java Virtual Machine ausgeführt werden soll. Um zu verstehen, wie sich die beiden Sprachen unter der Haube unterscheiden, ist es notwendig, das System zu verstehen, auf das sie beide abzielen. Hier geben wir einen kurzen Überblick über einige Hauptelemente der Java Virtual Machine-Architektur, Klassendateistruktur und Assembler-Grundlagen.
Beachten Sie, dass dieser Leitfaden nur das Minimum abdeckt, um dem obigen Artikel zu folgen. Obwohl viele Hauptkomponenten der JVM hier nicht behandelt werden, finden Sie vollständige Details in den offiziellen Dokumenten hier.
Klassendateien mit
javap
Konstanter Pool
Feld- und Methodentabellen
JVM-Bytecode
Methodenaufrufe und der Aufrufstapel
Ausführung auf dem Operanden-Stack
Lokale Variablen
zurück nach oben
Klassendateien mit javap
Java wird mit dem javap
, das .class
-Dateien in eine für Menschen lesbare Form dekompiliert. Da Scala- und Java-Klassendateien beide auf dieselbe JVM abzielen, kann javap
verwendet werden, um von Scala kompilierte Klassendateien zu untersuchen.
Lassen Sie uns den folgenden Quellcode kompilieren:
// RegularPolygon.scala class RegularPolygon( val numSides: Int ) { def getPerimeter( sideLength: Double ): Double = { println( "Calculating perimeter..." ) return sideLength * this.numSides } }
Wenn Sie dies mit scalac RegularPolygon.scala
RegularPolygon.scala kompilieren, wird RegularPolygon.class
. Wenn wir dann javap RegularPolygon.class
, sehen wir Folgendes:
$ javap RegularPolygon.class Compiled from "RegularPolygon.scala" public class RegularPolygon { public int numSides(); public double getPerimeter(double); public RegularPolygon(int); }
Dies ist eine sehr einfache Aufschlüsselung der Klassendatei, die einfach die Namen und Typen der öffentlichen Mitglieder der Klasse zeigt. Durch Hinzufügen der Option -p
werden private Mitglieder eingeschlossen:
$ javap -p RegularPolygon.class Compiled from "RegularPolygon.scala" public class RegularPolygon { private final int numSides; public int numSides(); public double getPerimeter(double); public RegularPolygon(int); }
Das sind noch nicht viele Informationen. Um zu sehen, wie die Methoden im Java-Bytecode implementiert werden, fügen wir die Option -c
hinzu:
$ javap -p -c RegularPolygon.class Compiled from "RegularPolygon.scala" public class RegularPolygon { private final int numSides; public int numSides(); Code: 0: aload_0 1: getfield #13 // Field numSides:I 4: ireturn public double getPerimeter(double); Code: 0: getstatic #23 // Field scala/Predef$.MODULE$:Lscala/Predef$; 3: ldc #25 // String Calculating perimeter... 5: invokevirtual #29 // Method scala/Predef$.println:(Ljava/lang/Object;)V 8: dload_1 9: aload_0 10: invokevirtual #31 // Method numSides:()I 13: i2d 14: dmul 15: dreturn public RegularPolygon(int); Code: 0: aload_0 1: iload_1 2: putfield #13 // Field numSides:I 5: aload_0 6: invokespecial #38 // Method java/lang/Object."<init>":()V 9: return }
Das ist etwas interessanter. Um jedoch wirklich die ganze Geschichte zu verstehen, sollten wir die Option -v
oder -verbose
, wie in javap -p -v RegularPolygon.class
:
Hier sehen wir endlich, was wirklich in der Klassendatei steht. Was bedeutet das alles? Werfen wir einen Blick auf einige der wichtigsten Teile.
Konstanter Pool
Der Entwicklungszyklus für C++-Anwendungen umfasst Kompilierungs- und Verknüpfungsphasen. Der Entwicklungszyklus für Java überspringt eine explizite Verknüpfungsphase, da die Verknüpfung zur Laufzeit erfolgt. Die Klassendatei muss diese Laufzeitverknüpfung unterstützen. Das bedeutet, dass, wenn der Quellcode auf ein beliebiges Feld oder eine Methode verweist, der resultierende Bytecode relevante Verweise in symbolischer Form enthalten muss, die bereit sind, dereferenziert zu werden, sobald die Anwendung in den Speicher geladen wurde, und tatsächliche Adressen können vom Laufzeitlinker aufgelöst werden. Dieses symbolische Formular muss enthalten:
- Klassenname
- Feld- oder Methodenname
- geben Sie Informationen ein
Die Spezifikation des Klassendateiformats enthält einen Abschnitt der Datei namens Constant Pool , eine Tabelle aller Referenzen, die vom Linker benötigt werden. Es enthält Einträge unterschiedlicher Art.
// ... Constant pool: #1 = Utf8 RegularPolygon #2 = Class #1 // RegularPolygon #3 = Utf8 java/lang/Object #4 = Class #3 // java/lang/Object // ...
Das erste Byte jedes Eintrags ist ein numerisches Tag, das die Art des Eintrags angibt. Die restlichen Bytes geben Auskunft über den Wert des Eintrags. Die Anzahl der Bytes und Regeln für ihre Interpretation hängen von dem durch das erste Byte angegebenen Typ ab.
Beispielsweise kann eine Java-Klasse, die eine konstante Ganzzahl 365
verwendet, einen konstanten Pooleintrag mit dem folgenden Bytecode haben:
x03 00 00 01 6D
Das erste Byte, x03
, identifiziert den Eintragstyp, CONSTANT_Integer
. Dadurch wird der Linker darüber informiert, dass die nächsten vier Bytes den Wert der Ganzzahl enthalten. (Beachten Sie, dass 365 im Hexadezimalformat x16D
ist). Wenn dies der 14. Eintrag im Konstantenpool ist, wird javap -v
es so darstellen:
#14 = Integer 365
Viele Konstantentypen bestehen aus Verweisen auf „primitivere“ Konstantentypen an anderer Stelle im Konstantenpool. Unser Beispielcode enthält beispielsweise die Anweisung:
println( "Calculating perimeter..." )
Die Verwendung einer Zeichenfolgenkonstante erzeugt zwei Einträge im Konstantenpool: einen Eintrag vom Typ CONSTANT_String
und einen weiteren Eintrag vom Typ CONSTANT_Utf8
. Der Eintrag vom Typ Constant_UTF8
enthält die eigentliche UTF8-Darstellung des String-Werts. Der Eintrag vom Typ CONSTANT_String
enthält eine Referenz auf den Eintrag CONSTANT_Utf8
:
#24 = Utf8 Calculating perimeter... #25 = String #24 // Calculating perimeter...
Eine solche Komplikation ist notwendig, da es andere Arten von konstanten Pooleinträgen gibt, die auf Einträge des Typs Utf8
und die keine Einträge des Typs String
sind. Beispielsweise erzeugt jede Referenz auf ein Klassenattribut einen CONSTANT_Fieldref
-Typ, der eine Reihe von Referenzen auf den Klassennamen, den Attributnamen und den Attributtyp enthält:
#1 = Utf8 RegularPolygon #2 = Class #1 // RegularPolygon #9 = Utf8 numSides #10 = Utf8 I #12 = NameAndType #9:#10 // numSides:I #13 = Fieldref #2.#12 // RegularPolygon.numSides:I
Weitere Einzelheiten zum Konstantenpool finden Sie in der JVM-Dokumentation.
Feld- und Methodentabellen
Eine Klassendatei enthält eine Feldtabelle , die Informationen über jedes in der Klasse definierte Feld (dh Attribut) enthält. Dies sind Verweise auf Konstanten-Pool-Einträge, die den Namen und Typ des Felds sowie Zugriffssteuerungs-Flags und andere relevante Daten beschreiben.
Eine ähnliche Methodentabelle ist in der Klassendatei vorhanden. Zusätzlich zu Namens- und Typinformationen enthält es jedoch für jede nicht abstrakte Methode die eigentlichen Bytecode-Anweisungen, die von der JVM ausgeführt werden sollen, sowie Datenstrukturen, die vom Stapelrahmen der Methode verwendet werden, wie unten beschrieben.
JVM-Bytecode
Die JVM verwendet ihren eigenen internen Befehlssatz, um kompilierten Code auszuführen. Das Ausführen javap
mit der Option -c
enthält die kompilierten Methodenimplementierungen in der Ausgabe. Wenn wir unsere Datei RegularPolygon.class
diese Weise untersuchen, sehen wir die folgende Ausgabe für unsere Methode getPerimeter()
:
public double getPerimeter(double); Code: 0: getstatic #23 // Field scala/Predef$.MODULE$:Lscala/Predef$; 3: ldc #25 // String Calculating perimeter... 5: invokevirtual #29 // Method scala/Predef$.println:(Ljava/lang/Object;)V 8: dload_1 9: aload_0 10: invokevirtual #31 // Method numSides:()I 13: i2d 14: dmul 15: dreturn
Der eigentliche Bytecode könnte etwa so aussehen:
xB2 00 17 x12 19 xB6 00 1D x27 ...
Jede Anweisung beginnt mit einem Ein-Byte- Opcode , der die JVM-Anweisung identifiziert, gefolgt von null oder mehr zu bearbeitenden Anweisungsoperanden, abhängig vom Format der spezifischen Anweisung. Dies sind normalerweise entweder konstante Werte oder Verweise auf den Konstantenpool. javap
übersetzt den Bytecode hilfreich in eine für Menschen lesbare Form, die Folgendes anzeigt:
- Der Offset oder die Position des ersten Bytes der Anweisung innerhalb des Codes.
- Der für Menschen lesbare Name oder Mnemonik der Anweisung.
- Der Wert des Operanden, falls vorhanden.
Operanden, die mit einem Nummernzeichen angezeigt werden, wie z. B. #23
, sind Verweise auf Einträge im Konstantenpool. Wie wir sehen können, erzeugt javap
auch hilfreiche Kommentare in der Ausgabe, die angeben, was genau aus dem Pool referenziert wird.
Wir werden einige der allgemeinen Anweisungen unten besprechen. Ausführliche Informationen zum vollständigen JVM-Befehlssatz finden Sie in der Dokumentation.
Methodenaufrufe und der Aufrufstapel
Jeder Methodenaufruf muss in seinem eigenen Kontext ausgeführt werden können, der Dinge wie lokal deklarierte Variablen oder Argumente enthält, die an die Methode übergeben wurden. Zusammen bilden diese einen Stapelrahmen . Beim Aufrufen einer Methode wird ein neuer Rahmen erstellt und oben auf dem Aufrufstapel platziert. Wenn die Methode zurückkehrt, wird der aktuelle Frame aus der Aufrufliste entfernt und verworfen, und der Frame, der vor dem Aufruf der Methode wirksam war, wird wiederhergestellt.
Ein Stapelrahmen enthält einige unterschiedliche Strukturen. Zwei wichtige sind der Operandenstapel und die lokale Variablentabelle , die als nächstes besprochen werden.
Ausführung auf dem Operanden-Stack
Viele JVM-Befehle arbeiten auf dem Operandenstapel ihres Rahmens. Anstatt einen konstanten Operanden explizit im Bytecode anzugeben, nehmen diese Anweisungen stattdessen die Werte oben auf dem Operandenstapel als Eingabe. Typischerweise werden diese Werte dabei vom Stack entfernt. Einige Anweisungen legen auch neue Werte oben auf den Stapel. Auf diese Weise können JVM-Anweisungen kombiniert werden, um komplexe Operationen auszuführen. Zum Beispiel der Ausdruck:
sideLength * this.numSides
wird in unserer Methode getPerimeter()
wie folgt kompiliert:
8: dload_1 9: aload_0 10: invokevirtual #31 // Method numSides:()I 13: i2d 14: dmul
- Die erste Anweisung,
dload_1
, schiebt die Objektreferenz von Slot 1 der lokalen Variablentabelle (wird als nächstes besprochen) auf den Operandenstapel. In diesem Fall ist dies das MethodenargumentsideLength
.- Die nächste Anweisung,aload_0
, schiebt die Objektreferenz an Slot 0 der lokalen Variablentabelle auf den Operandenstapel. In der Praxis ist dies fast immer der Verweis aufthis
, die aktuelle Klasse. - Dadurch wird der Stack für den nächsten Aufruf eingerichtet,
invokevirtual #31
, der die InstanzmethodenumSides()
ausführt.invokevirtual
den obersten Operanden (die Referenz aufthis
) vom Stack, um zu identifizieren, von welcher Klasse es die Methode aufrufen muss. Sobald die Methode zurückkehrt, wird ihr Ergebnis auf den Stapel geschoben. - In diesem Fall ist der zurückgegebene Wert (
numSides
) im ganzzahligen Format. Es muss in ein doppeltes Gleitkommaformat konvertiert werden, um es mit einem anderen doppelten Wert zu multiplizieren. Die Anweisungi2d
den ganzzahligen Wert vom Stack, wandelt ihn in das Fließkommaformat um und schiebt ihn zurück auf den Stack. - An diesem Punkt enthält der Stapel oben das Gleitkommaergebnis von
this.numSides
, gefolgt vom Wert dessideLength
Arguments, das an die Methode übergeben wurde.dmul
diese obersten zwei Werte aus dem Stack, führt eine Fließkommamultiplikation an ihnen durch und legt das Ergebnis auf dem Stack ab.
Wenn eine Methode aufgerufen wird, wird ein neuer Operandenstapel als Teil ihres Stapelrahmens erstellt, in dem Operationen ausgeführt werden. We must be careful with terminology here: the word “stack” may refer to the call stack , the stack of frames providing context for method execution, or to a particular frame's operand stack , upon which JVM instructions operate.
Local Variables
Each stack frame keeps a table of local variables . This typically includes a reference to this
object, any arguments that were passed when the method was called, and any local variables declared within the method body. Running javap
with the -v
option will include information about how each method's stack frame should be set up, including its local variable table:
public double getPerimeter(double); // ... Code: 0: getstatic #23 // Field scala/Predef$.MODULE$:Lscala/Predef$; 3: ldc #25 // String Calculating perimeter... // ... LocalVariableTable: Start Length Slot Name Signature 0 16 0 this LRegularPolygon; 0 16 1 sideLength D
In this example, there are two local variables. The variable in slot 0 is named this
, with the type RegularPolygon
. This is the reference to the method's own class. The variable in slot 1 is named sideLength
, with the type D
(indicating a double). This is the argument that is passed to our getPerimeter()
method.
Instructions such as iload_1
, fstore_2
, or aload [n]
, transfer different types of local variables between the operand stack and the local variable table. Since the first item in the table is usually the reference to this
, the instruction aload_0
is commonly seen in any method that operates on its own class.
This concludes our walkthrough of JVM basics.