Die Architektur der Java VM

SeminarthemenDie Architektur der Java VMI.II.III.• IV. Die Java VM zur Laufzeit • V.VI.VII.

IV. Die Java VM zur Laufzeit

In diesem Kapitel werden die Befehle der JVM in Beispielen verwendet, um einzelne Abläufe aufzuzeigen. Sofern eizelne Befehle unbekannt sind, sollten sie im Kapitel V. Befehlssatz nachgeschlagen werden. Dieses ist als Nachschlagewerk gedacht und deshalb diesem Kapitel nachgestellt. Der in den Beispielen angegebenen Bytecode-Assembler wurde mit dem Disassembler javap erzeugt. Kapitel VI. enthält weiter Hinweise zu diesem Werkzeug. Die Beispiele basieren auf einigen Java-Klassen, die dazu erstellt wurden. Diese können im Abschnitt Beispieldateien auch heruntergeladen werden.

↑ oben

IV. 1 Der Runtime Constant Pool (RCP)

Der Runtime Constant Pool ist eine Laufzeit-Datenstruktur in der Mehtode-Area, die beim Laden einer Klasse von der JVM erzeugt wird. Der RCP konnte im Kapitel II. nicht detailiert betrachtet werden, da der Aufbau des Constant Pools, aus dem der RCP abgeleitet wird, erst im Kapitel III. betrachtet wurde.

Bei der schematischen Betrachtung des Dateiformats hat sich bereits gezeigt, dass der Dateiaufbau ungeeignet ist, um bestimmte Felder performant aufzufinden und auszulesen. Der RCP ist jedoch ein essentieller Bestandteil des dynamischen Linkens von Methoden, Feldern und Klassen, und muss daher möglichst performant ein bestimmten Eintrag im RCP auffinden. Der RCP übernimmt dabei die Nummerierung der Einträge des CP, da Befehle, die als Operanden Einträge des (R)CP verwenden, diese über den numerischen Index referenzieren.

Funktionsprinzip des RCP

Die Funktionsweise des RCPs wird anhand einer Beispielimplementierung gut verdeutlicht. Die performanteste Datenstruktur, um mit einem numerischen Index ein bestimmtes Element aufzufinden, ist zweifellos ein Array, welches an dieser Stelle auch von den allermeisten Implementierung verwendet werden dürfte. Die Implementierungen unterscheiden sich wohl nur im verwendeten Elementtyp des Arrays. Als Beispiel wird dieser so definiert, dass er denen der Arrays gleicht, die für die lokalen Variabeln oder den Operand-Stack des JVM-Stacks verwendet werden. Das RCP ist

Bei der Erzeugung des RCP werden die Konstanten aus dem CP typabhängig (tag) in eine 32-Bit breite Laufzeitdarstellung umgewandelt:

CONSTANT_Integer, CONSTANT_Long, CONSTANT_Float, CONSTANT_Double
Wird in eine Konstante des entsprechenden JVM-Typs (int, long, float, double) umgewandelt, und direkt im Array abgelegt. Wie bei LV und OS auch, müssen hier für long und double zwei aufeinanderfolgende Elemente verwendet werden.
CONSTANT_String
Es wird ein entsprechende Instanz vom Typ String erzeugt, und eine reference darauf im Array abgelegt.
CONSTANT_Class
Aus dem CP-Eintrag wird eine symbolischen Referenz erzeugt. Dies ist der internen Name der Klasse als String (entspricht Class.getName()). Eine reference darauf wird im Array abgelegt. Beim dynmaischen Linken, wird die symbolische Referenz mit einer reference auf die Class-Instanz der beannten Klasse ersetzt. Kann der Name nicht aufgelöst werden, wird die symbolische Referenz mit einer reference auf die ausgelöste Exception ersetzt.
CONSTANT_Methodref, CONSTANT_Fieldref, CONSTANT_InterfaceMethodref
Aus dem Eintrag im CP wird eine Instanz der Klasse SymbolicRef erzeugt, und eine reference darauf im Array abgelegt.
class SymbolicRef { // Klasse ist nur ein Beispiel, existiert nicht wirklich!
  final String class;       // interner Name der definierenden Klasse
  final String name;        // Name des Felds, der Methode
  final String descriptor;  // Descriptor des Felds, der Methode
}
Beim dynamischen Linken wird zunächst die Klasse analog zum CONSTANT_Class-Eintrag aufgeöst. Die Referenz auf die SymbolicRef-Instanz wird dann mit einer reference auf das Feld bzw. die Methode ersetzt. Kann Klasse, Feld oder Methode nicht aufgelöst werden, oder ist der Zugriff aus der Klasse nicht erlaubt, wird eine entsprechende Exception ausgelöst und eine reference darauf anstelle der SymbolicRef im Array abgelegt.

Die CONSTANT_Utf8 und CONSTANT_NameAndType Einträge im CP werden nur indirekt bei der Erzeugung des RCP verwendet. Wenn der Compiler diese Konstanten immer ans Ende des CPs legt, kann der RCP auch weniger Elemente enthalten, da alle zur Laufzeit verwendeten Konstanten immernoch an ihrem ursprünglichen Index liegen. Natürlich ist der beschiebene Aufbau des RCP nur eine mögliche Implementierung, die aber gut zeigt, wie der RCP funktioniert, und auch recht praxisnah ist. Im Beispiel ist es nicht nötig, zusätzliche Typ- oder Zustandsinformationen über die im RCP enthaltenen Werte anzulegen. Grundsätzlich ist die Typsicherheit und Typkompatibilität bereits durch die Verifikation sichergestellt, und muss zur Laufzeit nicht wiederholt überprüft werden. Die Execution Engine kann daher davon ausgehen, dass:

Bei Klassen, Feldern und Methoden werden zunächst symbolische (namentliche) Referenzen hinterlegt. Das es sich um einen solches Element handeln muss, ergibt sich aus dem Befehl, der auf den RCP zugreift. Für diese Elemente kann die Execution Engine die richtige Aktion abhängig von der Klasse des (durch die enthaltene reference) referenzierten Objektes bestimmen:

Auch dies ist natürlich nur eine Möglichkeit der Verarbeitung, die wieder gut zeigt, wie die JVM arbeitet. Spezifiziert ist dabei, dass

In der Beispielimplementierung ist dieses Verhalten impliziet enthalten.

↑ oben

IV. 2 Dynamisches Auflösen (Linken) von symbolischen Referenzen

Das Beispiel zum Runtime Constant Pool hat die Vorgänge des dynamischen Linkens mit Fokus auf den RCP beschrieben. Dieser Abschnitt betrachtet den Ablauf des dynamsichen Auflösens von symbolischen Referenzen detailierter.

Beispiele symbolischer Referenzen:

Eine symbolische Referenz referenziert ein Feld, eine Klasse oder Methode durch Benennung ihres Namens als Text eines String-Objektes:

Klasse
Entspricht dem internen Namen einer Klasse: java/lang/String, de/fhw/MyClass usw.
Feld
Besteht aus einem Namens-Tripel (3 zusammengehörigen String-Objekten, hier für Länge einer Liste: int length;)
Name (des Feldes) length
Descriptor (= Typ des Feldes) I
Klasse (die das Feld definiert) java.util.LinkedList (wie symb. Klassenreferenz)
Methode
Besteht aus einem Namens-Tripel (3 zusammengehörigen String-Objekten, hier für die Object.equals-Methode):
Name (der Methode): equals
Descriptor (Parameter, Rückgabewert der Methode): (Ljava/lang/Object;)Z
Klasse (welche die Methode definiert): java/lang/Object (wie symb. Klassenreferenz)

Herkunft und Verarbeitung von symbolischen Referenzen

Die symbolischen Referenzen werden beim Laden einer Klasse aus dem CP der class-Datei erzeugt. Die JVM läd jedoch keine Klasse bevor es nicht notwenig wird. Was das bedeutet, wird beim Betrachten der Auflösung der symbolischen Referenzen deutlich. Jedes Java-Programm wird durch Aufruf einer Klasse, die eine main-Methode enthält, gestartet. Dies ist (abgesehen von den System-Klassen wie Class, Object, String usw.) die erste Klasse, die von der JVM geladen wird. Sie refernziet in CP weitere Klasse die jedoch nicht umgehend geladen werden. Erst wenn während der Ausführung des Programms ein Zugriff in den RCP einer bereits geladenen Klasse erfolgt, der dort eine symbolische Referenz enthält, wird versucht, diese zu einer Laufzeit-Referenz auf das gesuchte Objekt (Class, Field, Method) aufzulösen. Dazu kann das Laden einer symbolisch referenzierten Klasse nötig sein. Evt. wurde diese auch schon geladen, um eine andere erforderliche Klasse zu verifizieren. Findet die Execution Engine bei der Programmausführung eine symbolische Referenz im RCP vor, werden (abhängig von der Art der Referenz) folgende Schritte durchgeführt:

Klasse
  1. Prüfen, ob die refernzierte Klasse bereits geladen ist (Laufzeit-Beschreibung in Methode-Area vorhanden), dann direkt 6.
  2. Prüfen, ob das Laden der refernzierten Klasse bereits erfolglos versucht wurde, dann erneut Exception.
  3. Versuchen die benannte Klasse über einen ClassLoader zu laden. Schlägt dies fehl, merken und Exception auslösen.
  4. Anlegen einer internen Darstellung der Klasse anhand des vom ClassLoader gelieferten Bytearrays.
  5. Initialisieren der Klasse (Aufruf von <clinit>, Initalwerte der Felder setzen...)
  6. Symbolische Referenz mit reference auf die Class-Instanz der benannten Klasse ersetzen (und diese zur weiteren Verarbeitung nutzen)
Das Laden einer Klasse kann dabei immer auch zum Laden weiter Klassen führen, um die Klasse zu verifizieren, oder weil für die Initalisierung bereits Methoden oder Felder anderer Klassen gelesen bzw. ausgeführt werden.
Feld, Methode
  1. Versuchen die Klasse wie oben beschrieben aufzulösen (kann ebenso Exceptions auslösen)
  2. Prüfen, ob das Feld oder die Methode bereits erfolglos gesucht wurde, dann erneut Exception auslösen.
  3. Prüfen, ob die aufgelöste Klasse ein Feld bzw. eine Methode mit dem gegebenen Namen und Descriptor besitzt. Wenn nicht Exception auslösen.
  4. Symbolische Referenz durch referenz auf die Field- bzw. Method-Instanz ersetzen (und diese zur weiteren Verarbeitung nutzen)
Da zum Auflösen von Feldern und Methoden auch das Auflösen einer Klasse gehört, kann es dadurch ebenso zum Laden weiterer Klassen kommen.

Ein Feld oder eine Methode ist damit noch nicht vollständigt bestimmt, denn eine symbolische Referenz benennt jeweils die definierende Klasse eines Feldes oder einer Methode. Beide lassen sich jedoch bekanntlich in abgeleiteten Klassen überschreiben oder im Falle eines Interfaces überhaupt erst implementieren. Daher wird bei der Verarbeitung des Programms immer abhängig von der konkreten Instanz (this) oder bei statischen Methoden und Feldern der aktuellen Klasse des Frames gebunden. Der dabei verwendeten Algorithmus wird für Methoden im Abschnitt Methodenaufruf und Rückgabe betrachtet.

↑ oben

IV. 3 Erzeugen von Klassen-Instanzen

Für die Erzeugung von Objekten, also Instanzen von reference-Typen, existiert analog zur Java-Sprache der Befehl new. Dieser alloziert jedoch lediglich einen ausreichend großen Speicherblock auf dem Heap und legt eine reference darauf auf dem OS ab.

void createInstance() {
	new String("No hay banda! There is no band. It is all an illusion... ");
}

Anschließend muss noch die Initalisierungsmethode (analog: Konstruktor) aufgerufen werden, um die neue Instanz zu initalisieren. Die Java-Methode createInstance() sieht deassembliert so aus:

void createInstance();
   0:	new	#20; // Speicher für Instanz von java/lang/String allozieren
   3:	ldc	#22; // String aus CP:"No hay banda! There is no band. It is all an illusion..."
   5:	invokespecial	#24; // Methode java/lang/String.<init>:(Ljava/lang/String;)V aufrufen
   8:	return       // Frame von createInstance ohne Rückgabewert beenden

Grundsätzlich läuft die Erzeugung neuer Insatnzen nach diesem Schema ab:

  1. new alloziert neuen Speicher für die Instanz
  2. (meist: Mit dup wird nun die reference auf dem OS verdoppelt)
  3. Nacheinander werden die Parameter der Initialierungsmethode <init> auf den OS gebracht.
  4. Die Initialisierungsmethode wird mit invokespecial aufgeruen.
  5. (meist: Zuweisen der verdoppelten Referenz auf ein Feld)

Die Verdopplung der Instanzreferenz wird gewöhnlich verwendet, damit nach dem Aufruf der Initialisierungsmethode, die eine Objektreferenz verbraucht, noch eine Referenz auf das neue Objekt auf dem OS liegt, mit welcher dies etwa einem Feld zugewiesen werden kann.

Erzeugen von Arrays

Arrays werden nicht über den new-Befehl erzeugt. Es gibt eine Reihe unterschielicher Befehle zur Erzeugung von Arrays, je nachdem welchen Elementtyp und Dimentsionen des Arrays verwendet werden:

BefehlBeschreibung
newarrayErzeugt ein eindimentsionales Array eines primitiven Datentyps.
anewarrayErzeugt ein eindimentsionales Array eines reference-Typs.
multianewarrayErzeugt ein mehrdimentsionales Array eines reference-Typs.
↑ oben

IV. 4 Lesen und Schreiben von Feldern

Es gibt jeweils ein get- und ein put-Befehl für statische (getstatic, putstatic) und nicht statische Felder (getfield, putfield), da der Wert nicht statischer Felder von einer konkreten Instanz (z.B this) abhängig ist (dynamisches binden).

public final static int ANSWER = 42;
private int question;
void getAndPutFields() {
	this.question = Example.ANSWER;
}

Die Java-Methode getAndPutFields() entspricht deassembliert:

void getAndPutFields();
   0:	aload_0              // this-Referenz aus LV 0 auf OS (für putfield-Befehl)
   1:	getstatic	#8;  // Konstante 42 auf OS (Compiler-Optimierung: bipush 42)
   3:	putfield	#28; // Wert vom OS in Feld question:I der this-Instanz ablegen
   6:	return

Zuerst wird die this-Referenz, die bei nicht statischen Methoden immer in der lokalen Variable 0 übergeben wird, auf den OS gebracht. Danach wird der Wert der Konstanten ANSWER auf den OS abgelegt. Da diese innerhalb der gleichen Klasse liegt, wie die getAndPutFields-Methode, hat der Compiler diese Operation durch bipush 42 ersetzt, welche schneller ausgeführt werden kann, da kein Zugriff auf den RCP nötig ist. Bei nicht final-Feldern oder dem Zugriff aus anderen Klasse wird das Feld (wie im Beispiel) mit dem getstatic-Befehl ausgelesen. Anschließend wird der Wert im referenzierten Feld der this-Instanz abgelegt.

↑ oben

IV. 5 Konstanten

Es gibt eine ganze Reihe verschiedener Befehle um Konstanten auf den OS abzulegen. Der Wert kann dabei:

Alle Befehle sind zudem Typabhängig. Das heißt, für jeden Typ existieren seperate Befehle. Außerdem gibt es einige Sonderbefehle, die mit konstanten numerischen Werten versehen werden.

void accessConstants() {
	int i = 1;
	i += 100;
	i *= 10;
	i *= 300;
	float f = 1.0f;
	f += 100.0f;
	double d = 1.0;
	d += 100.0d;
}

Die Java-Methode accessConstants() führt einige (sinnlose) Berechnungen mit Konstanten durch, die jedoch mit unterschiedlichen Befehlen auf den OS gebracht werden, wie die deassemblierte Form zeigt (zur Orientierung mit LocalVariableTable):

void accessConstants();
   0:	iconst_1       // int 1 auf den OS (Wert im Befehl)
   1:	istore_1       // vom OS in LV1(i) ablegen
   2:	iinc	1, 100 // LV1(i) direkt (ohne OS) um 100 erhöhen
   5:	iload_1        // Wert von LV1(i) auf OS
   6:	bipush	10     // int 10 (im Bytecode als 1 Byte nach Befehl) auf OS
   8:	imul           // i * 10 auf OS
   9:	istore_1       // und wieder vom OS in LV1(i) ablegen
   10:	iload_1        // erneut LV1(i) auf OS
   11:	sipush	300    // int 300 (im Bytecode als 2 Bytes nach Befehl) auf OS
   14:	imul           // i * 300 auf OS
   15:	istore_1       // und nocheinmal vom OS in LV1(i) ablegen
   16:	fconst_1       // float 1.0 auf OS (Wert im Befehl)
   17:	fstore_2       // vom OS in LV2(f) ablegen
   18:	fload_2        // Wert von LV2(f) auf OS
   19:	ldc	#31;   // float 100.0f aus (R)CP #33 auf OS 
   21:	fadd           // f * 100.0 auf OS
   22:	fstore_2       // und wieder in LV2(f) ablegen
   23:	dconst_1       // double 1.0 auf PS (Wert im Befehl)
   24:	dstore_3       // vom OS in LV3(d) ablegen
   25:	dload_3        // Wert von LV3(d) auf OS
   26:	ldc2_w	#32;   // double 100.0d aus (R)CP #34 auf OS (2 Elemente)
   29:	dadd           // d + 100.0 auf OS
   30:	dstore_3       // wieder vom OS in LV3(d) ablegen
   31:	return
  LocalVariableTable:
   Start  Length  Slot  Name   Signature
   0      32      0     this   LExample;
   2      30      1     i      I
   18     14      2     f      F
   25     7       3     d      D

Der Ablauf ist durch die Kommentare sicherlich ausreichend beschreiben. Im Fokus stehen die verschiedene Befehle, die genutzt werden, um eine Konstante auf den OS zu bringen. Für kleine Konstanten existieren für jeden primitiven Datentyp einige Befehle in denen der Wert, der auf den OS gebracht wird, impliziet enthalten ist (hier: iconst_1, fconst_1, dconst_1). Ganzzahlige Konstanten im byte- oder short-Wertebereich können mit dem bipush bzw. sipush direkt als Wert im Bytecode kodiert und auf den OS gebracht werden. Für größere Ganzzahlwerte oder Fließkommakonstanten werden entsprechende Einträge im (R)CP angelegt. Konstanten vom Typ byte, short, int, char, float, String werden dann mit ldc (Index 0-255) oder ldc_w (Index 0-65536) auf den OS gebracht. Für die 64-Bit breiten long- und double-Konstanten wird ldc2_w (Index 0-65536) verwendet. Zudem kann über aconst_null die null-Referenz auf den OS abgelegt werden. Da für den Vergleich gegen null die Befehle ifnull und ifnonnull existieren, musste der Wert der null-Referenz nicht spezifiziert werden, und kann somit von jeder Implementierung selbst bestimmt werden.

↑ oben

IV. 6 Methodenaufruf und Rückgabe

Die JVM unterscheidet vier verschiedene Befehle zum Aufruf einer Methode. Welcher Befehl verwendet werden muss, hängt von der Art der Methode und den damit verbundenen Schritten der JVM ab. Die Beispiele stammen zunächst alle aus der Klasse Example.

Statische Methoden (static)

Die einfachste Form bilden statische Methoden, da sie wie der Name sagt, keine Dynamik beinhalten, müssen sie nicht zur Laufzeit an eine bestimmte Instanz gebunden werden, sondern sind immer Teil einer zur Compile-Zeit bekannten Klasse, die bereits durch den CONSTANT_Methodref-Eintrag im CP der aufrufenden Klasse eindeutig und endgültig bestimmt und nicht instanzabhängig ist.

void invokeStatic() {
	Example.mul(6, 7);
}
public static int mul(int i1, int i2) {
	return i1 * i2;
}

Der Aufruf der statischen Methode mul in der Methode invokeStatic() zeigt sich deassembliert so:

void invokeStatic();
   0:	bipush	6            // int 6 auf OS
   2:	bipush	7            // int 7 auf OS
   4:	invokestatic	#40; // Methode mul:(II)I aufrufen
   7:	pop                  // Ergebnis vom OS, da dieser bei Verlassen leer sein muss
   8:	return               // und das Ergebnis hier nicht weiter verarbeitete wird
  LocalVariableTable:
   Start  Length  Slot  Name   Signature
   0      9       0     this   LExample;

Eine statischen Methode wird Aufgerufen, indem

  1. alle Parameter der Methode in der Reihenfolge der Parameter auf den OS gebracht werden.
  2. die aufzurufende Methode mit invokestatic im CP referenziert wird.

Die JVM verarbeitet den Aufruf, indem dann

  1. ein neuer Frame im aktuellen Thread für die aufgerufene Methode erzeugt wird.
  2. die Operanden vom OS der aufrufenden Methode entnommen und in die LV des neuen Frames abgelegt werden (ab Index 0).
  3. die Kontrolle an den neuen Frame übergeben wird, sodass die Ausführung beim PC der aufgerufenen Methode (mit 0 initialisiert) fortgesetzt wird.

public static int mul(int, int);
   Stack=2, Locals=2, Args_size=2
   0:	iload_0  // int 6 aus LV0 auf OS
   1:	iload_1  // int 7 aus LV1 auf OS
   2:	imul     // Ergebnis von 6 * 7 auf den OS
   3:	ireturn  // Ergebnis vom OS dieses Frames auf OS des aufrufenden Frames
  LocalVariableTable: 
   Start  Length  Slot  Name   Signature
   0      4       0     i1     I	// wird hier mit int 6 initialisiert
   0      4       1     i2     I	// wird hier mit int 7 initialisiert

Nachdem die Methode ausgeführt wurde, wird die Kontrolle zurück zum aufrufenden Frame transferiert, indem

  1. das Ergebnis vom OS des aktuellen Frames entnommen wird,
  2. und es auf den OS des aufrufenden Frames gebracht wird.
  3. der aktuelle Frame vom JVM-Stack entfernt wird, und
  4. die Ausführung beim PC des aufrufenden Frames fortgesetzt wird.

Die statische Initialisierungsmethode von Klassen <clinit> wird nicht über invokestatic aufgerufen. Die JVM ruft diese Methode implizit bei der Initialisierung einer Klasse auf, wenn deren interne Darstellung in der Methode-Area angelegt wird.

Nicht statische Methoden (public, protected, default)

Die zweite Form eines Methodenaufrufs betrifft alle Methoden, die nicht zu einem Interface gehören, und von außerhalb der Klasse selbst aufgerufen oder auch überschrieben werden können. Die jeweils richtige Implementierung muss hier immer abhängig von einer konkreten aktuellen Objekt-Instanz bestimmt werden.

void invokePublic() {
	sub(2048, 2006);
}
public int sub(int i1, int i2) {
	return i1 - i2;
}

Der Aufruf der nicht statischen Methode sub in der Methode invokePublic() entspricht deassembliert:

void invokePublic();
   0:	aload_0              // Instanz der Klasse (für welche die Methode aufgerufen wird) auf OS
   1:	sipush	2048         // Parameter auf OS
   4:	sipush	2006
   7:	invokevirtual	#56; // Methode sub:(II)I aufrufen
   10:	pop                  // Ergebnis weiterverarbeiten
   11:	return

public int sub(int, int);
   Stack=2, Locals=3, Args_size=3
   0:	iload_1
   1:	iload_2
   2:	isub
   3:	ireturn
  LocalVariableTable: 
   Start  Length  Slot  Name   Signature
   0      4       0     this   LExample;
   0      4       1     i1     I
   0      4       2     i2     I

Der Kontrolltransfer und das Erzeugen und Beenden von Frames geschieht analog zu statischen Methoden, abgesehen davon, dass

Die durch invokevirtual referenzierte Methode im (R)CP ergibt erst zusammen mit der beim Aufruf angegebenen Objekt-Instanz eine eindeutige Methodenimplementierung, die durch ein Lookup bestimmt werden muss. Der Algorithmus wird später noch betrachtet.

Interface-Methoden (immer public abstract)

Für den Aufruf einer Interface-Methode wird die dritte Form des Methodenaufrufs verwendet. Diese unterscheidet sich in der Form leicht von der invokevirtual-Methode, da hier als weiterer Operand die Anzahl der Methodenparameter angegeben wird. Diese ließe sich ebensogut (wie bei anderen Methodenaufrufen auch) aus dem Descriptor bestimmen. Die Angabe war einst von Sun als Geschwindigkeitsoptimierung gedacht, und muss daher auch weiterhin angegeben werden. Dem Befehls-Opcode folgen daher hier immer 4 Byte, die zur Laufzeit z.B. auch durch eine zuvor bestimmte Methodenreferenz ersetzt werden könnten, ohne dass sich dadurch die Länge und Offsets der Befehle einer Methode änderte.

int invokeInterface(IExample obj) {
	return obj.doSomething(42);
}

Der Aufruf der Methode doSomething der Instanz obj in der Methode Example.invokeInterface zeigt sich dementsprechend deassembliert so (Example implementiert IExample nicht selbst):

int invokeInterface(IExample);
   Stack=2, Locals=2, Args_size=2
   0:	aload_1                  // obj-Referenz aus LV 1 auf OS
   1:	bipush	42               // Methodenparameter auf OS
   3:	invokeinterface	#61,  2; // Interface-Methode IExample.doSomething:(I)I aufrufen
   8:	ireturn
  LocalVariableTable:
   Start  Length  Slot  Name   Signature
   0      9       0     this   LExample;
   0      9       1     obj    LIExample;

Natürlich muss auch für Interface-Methoden ein Lookup ausgeführt werden, da die Methoden-Implementierung von der konkreten Instanz (hier: obj) abhängt. Dieses unterscheidet sich vom Lookup beim invokevirtual-Befehl nur durch die Sonderbedingung, dass für alle Interface-Methoden bereits bekannt ist, dass die Methode überall aufgerufen werden kann.

Methoden mit besonderer Behandlung (private, super, Konstruktoren)

Die letzte Form des Methodenaufrufs betrifft Methoden, die zwar auch auf Instanzen angewendet werden, für welche jedoch bekannt ist, dass die Implementierung direkt in der benannten Klasse bzw. einer Superklasse dieser Klasse enthalten ist. Die folgenden Beispiele verwenden die Klassen Near und Far.

class Near {
	float value;
	...
	private float getValue() { 
		return this.value; 
	}
	public float getValueNear() { 
		return getValue(); 
	}
}

Der Aufruf der privaten Methode getValue() in der öffentlichen Methode getValueNear() sieht deassembliert so aus:

public float getValueNear();
   0:	aload_0              // this aus LV 0 auf OS
   1:	invokespecial	#22; // Methode this.getValue:()F aufrufen
   4:	freturn              // Ergebnis zurückgeben

Für private Methoden ist bereits bekannt, dass die Implementierung in der Klasse von this enthalten ist. Ein Lookup entfällt daher, weshalb der invokespecial-Berfehl verwendet wird. Dieser wird auch genutzt, wenn eine Initialisierungsmethode <init> aufgerufen wird, von welcher ebenso bekannt ist, dass die Implementierung in der durch die beim Methodenaufruf verwendeten Instanz eindeutig und endgültig bestimmt ist. Außerdem wird der Befehl verwendet, um Methoden der Superklasse aufzurufen. Hier muss zwar ein Lookup stattfinden, das jedoch bei der direkten Superklasse der Insatnz-Klasse begonnen werden kann.

class Far extends Near
	...
	float getValueFar() { 
		return super.getValueNear(); 
	}
	Near getNear(float v) { 
		return new Near(v); 
	}
}

Der Aufruf der Methode der Superklasse Near.getValueNear() in der Methode Far.getValueFar() und die Objekterzeugung in der Methode Far.getNear sind deassembliert beschrieben durch:

float getValueFar();
   0:	aload_0
   1:	invokespecial	#18; // Methode Near.getValueNear:()F aufrufen
   4:	freturn

Near getNear(float);
   0:	new	#3;          // neue Instanz von Near allozieren
   3:	dup                  // Referenz darauf verdoppeln
   4:	fload_1              // Methodenparameter auf OS
   5:	invokespecial	#8;  // Methode Near.<init>:(F)V aufrufen
   8:	areturn              // verbliebene Referenz zurückgeben
  LocalVariableTable:
   Start  Length  Slot  Name   Signature
   0      9       0     this   LFar;
   0      9       1     v      F

Für getValueFar() kann das Lookup mit der Superklasse von Far (also Near) beginnen. Deshlab wird statt invokevirtual der invokespecial-Befehl verwendet. Beim Aufruf der Initilisierungsmethode <init> in der Methode Far.getNear ist die Klasse, welche den Konstruktor implementiert durch die Klasse der Instanz, die durch new erzeugt wurde, bestimmt. Das Lookup entfällt somit.

Lookup-Algorithmen zum dynamischen Binden von Methoden-Implementierungen

Beim Aufruf von nicht statischen Methoden muss die Klasse, welche die Implementierung der refernzierten Methode bestimmt, durch eine Suche ausgewählt werden, die von der Klasse der beim Aufruf verwendeten Objekt-Instanz abhängt. Dieser Vorgang ist auch als dynamsiches Binden von Methoden bekannt.

Definition
Die Klasse der zum Aufruf verwendeten Objekt-Instanz ist CI
Vorbedingung
Die durch den Befehl im CP referenzierte Methode MCP kann aufgelöst werden.
Initalisierung
Suche
  1. Enthält C Instanz-Methode MMCP, wird M aufgerufen, sonst 2.
    (nur invokevirtual: M muss für C zugreifbar sein)
  2. Hat C eine Superklasse S, wird das Lookup rekursive für S ausgeführt, sonst AbstractMethodError.
↑ oben

IV. 7 Auslösen, Kontrolltransfer und Fangen von Exceptions

Der Befehlssatz enthält letztlich nur einen Befehl der direkt und ausschließlich für den Exception-Meschanismus genutzt wird: athrow. Damit wird eine auf dem OS befindende Exception-Instanz als Exception ausgelößt.

void nestedCatch() {
	try {
		try {
			tryThis();
		} catch (Exc1 e) {
			handle1();
		}
	} catch (Exc2 e) {
		handle2();
	}
}

Die Java-Methode nestedCatch() lößt selbst keine Exception aus. Dies geschieht irgendwo in der abstrakten Methode tryThis(), die im deassemblierten Beispielcode entsprechend schematisch hinzugefügt wurde:

void nestedCatch();
   0:	aload_0              // this auf OS
   1:	invokevirtual	#70; // Methode tryThis:()V aufrufen
   4:	goto	20
   7:	astore_1             // Exception-Instanz in LV1 sichern
   8:	aload_0              // this auf OS
   9:	invokevirtual	#73; // Methode handle1:()V aufrufen
   12:	goto	20
   15:	astore_1             // Exception-Instanz in LV1 sichern
   16:	aload_0              // this auf OS
   17:	invokevirtual	#76; // Methode handle2:()V aufrufen
   20:	return
  Exception table:
   from   to  target type
     0     4     7   Class Example$Exc1
     0    12    15   Class Example$Exc2
     
void tryThis();
	...
	aload_?  // Referenz auf eine Exception-Instanz aus LV? auf OS bringen
        athrow   // Auslösen der Exception 
	...     

Tritt beim Aufruf von tryThis keine Exception auf, wird die Verarbeitung direkt bei Offset 20 fortgesetzt. Der Kontrollfluss nach dem Auslösen einer Exception wird durch die Exception-Tabelle der Methoden bestimmt. Die Execution-Engine sucht darin nach einem passenden Exception-Handler (type)

  1. für die ausgelöste Exception.
  2. der den Offest-Bereich (from, to) aktiv ist, in den der PC gerade verweist.

Wird die Exception auch beim untersten Frame nicht gefangen, wird der aktuelle Thread beendet. Die Schachtelung von try-catch-Blöcken wird in der Tabelle durch sich überschneidende aktive Bereiche deutlich. Für nicht geschachtelte Blöcke überschneiden sich die Bereiche einzelner Einträge nicht.

↑ oben

IV. 8 Kontrollfluss von Unterprogrammen

Unterprogramme gruppieren mehrere Befehle innerhalb des Bytecodes einer Methode, damit diese von mehreren alternativen Pfaden aus aufgerufen werden könne, bevor die Ausführung beim nächsten Befehl des Pfades fortgesetzt wird. Dies wird typisch für den finally-Block der Java-Sprache eingesetzt, für welche die JVM keine speziellen Befehle oder Mechanismen kennt.

	...
   10:  jsr_w	140 // um 140 byte springen, Rücksprung-Adresse (13) auf OS
   13:  ...	    // irgendein Befehle nach dem Unterprogramm
	...
   55:  jsr	95  // um 95 byte springen, Rücksprung-Adresse (58) auf OS
   58:	...	    // irgendein Befehl nach dem Unterprogramm
   	...
  150:  astore_3    // Rücksprung-Adresse (13 oder 58) vom OS in LV3 sichern
   	...	    // die Unterprogramm-Anweisungen
  ???:  ret	3   // PC auf Rücksprung-Adresse in LV3 setzen
  	...         // erster Befehl nach dem Unterprogramm, etwa durch goto angesprungen

Die Befehle ab Offset 150 bilden das eigentliche Unterprogramm. Dies sichert zunächst die Rücksprungadresse, die vom jsr- bzw. jsr_w-Befehl auf den OS gebracht wurde, in einer lokalen Variable (hier 3). Nachdem das Unterprogramm abgearbeitet ist, wird vom ret-Befehl die Rücksprungadresse aus der lokalen Variable in den PC kopiert, und die Ausführung bei diesem Offset fortgesetzt. Diese Befehle sind für diese Art von Kontrolltransfern notwendig, da es aus Sicherheitsgründen keine expliziete Möglichkeit gibt, den PC zu beeinflussen. jsr verwendet einen 1-byte-Offset (-128 bis +127 zum PC), jsr_w einen 2-byte-Offset (-32768 bis +32767 zum PC).

→ weiter…

SeminarthemenDie Architektur der Java VMI.II.III.• IV. Die Java VM zur Laufzeit • V.VI.VII.