Bytecode-Grundlagen

Willkommen zu einer weiteren Folge von "Under The Hood". Diese Spalte gibt Java-Entwicklern einen Einblick in die Vorgänge unter ihren laufenden Java-Programmen. Der Artikel dieses Monats wirft einen ersten Blick auf den Bytecode-Befehlssatz der Java Virtual Machine (JVM). Der Artikel behandelt primitive Typen, die von Bytecodes verarbeitet werden, Bytecodes, die zwischen Typen konvertiert werden, und Bytecodes, die auf dem Stapel ausgeführt werden. In den folgenden Artikeln werden andere Mitglieder der Bytecode-Familie behandelt.

Das Bytecode-Format

Bytecodes sind die Maschinensprache der Java Virtual Machine. Wenn eine JVM eine Klassendatei lädt, erhält sie einen Stream von Bytecodes für jede Methode in der Klasse. Die Bytecode-Streams werden im Methodenbereich der JVM gespeichert. Die Bytecodes für eine Methode werden ausgeführt, wenn diese Methode während der Ausführung des Programms aufgerufen wird. Sie können durch Interpretation, Just-in-Time-Kompilierung oder eine andere Technik ausgeführt werden, die vom Designer einer bestimmten JVM ausgewählt wurde.

Der Bytecode-Stream einer Methode ist eine Folge von Anweisungen für die Java Virtual Machine. Jeder Befehl besteht aus einem Ein-Byte- Opcode, gefolgt von null oder mehr Operanden . Der Opcode gibt die auszuführende Aktion an. Wenn weitere Informationen erforderlich sind, bevor die JVM die Aktion ausführen kann, werden diese Informationen in einen oder mehrere Operanden codiert, die unmittelbar auf den Opcode folgen.

Jeder Opcode-Typ hat eine Mnemonik. Im typischen Assembler-Stil können Streams von Java-Bytecodes durch ihre Mnemonik gefolgt von beliebigen Operandenwerten dargestellt werden. Beispielsweise kann der folgende Strom von Bytecodes in Mnemonics zerlegt werden:

// Bytecode-Stream: 03 3b 84 00 01 1a 05 68 3b a7 ff f9 // Demontage: iconst_0 // 03 istore_0 // 3b iinc 0, 1 // 84 00 01 iload_0 // 1a iconst_2 // 05 imul // 68 istore_0 // 3b gehe zu -7 // a7 ff f9 

Der Bytecode-Befehlssatz wurde kompakt gestaltet. Alle Anweisungen, mit Ausnahme von zwei, die sich mit Tabellenspringen befassen, sind an Bytegrenzen ausgerichtet. Die Gesamtzahl der Opcodes ist klein genug, sodass Opcodes nur ein Byte belegen. Dies hilft, die Größe von Klassendateien zu minimieren, die möglicherweise über Netzwerke übertragen werden, bevor sie von einer JVM geladen werden. Es hilft auch, die Größe der JVM-Implementierung klein zu halten.

Alle Berechnungen in der JVM konzentrieren sich auf den Stapel. Da die JVM keine Register zum Speichern von Abitrary-Werten hat, muss alles auf den Stapel verschoben werden, bevor es für eine Berechnung verwendet werden kann. Bytecode-Anweisungen arbeiten daher hauptsächlich auf dem Stapel. Beispielsweise wird in der obigen Bytecode-Sequenz eine lokale Variable mit zwei multipliziert, indem zuerst die lokale Variable mit der iload_0Anweisung auf den Stapel und dann mit zwei auf den Stapel gedrückt wird iconst_2. Nachdem beide Ganzzahlen auf den Stapel imulverschoben wurden, werden die beiden Ganzzahlen durch den Befehl effektiv vom Stapel entfernt, multipliziert und das Ergebnis wieder auf den Stapel verschoben. Das Ergebnis wird oben im Stapel abgelegt und von der in der lokalen Variablen gespeichertistore_0Anweisung. Die JVM wurde als stapelbasierte Maschine und nicht als registergestützte Maschine konzipiert, um eine effiziente Implementierung auf Architekturen ohne Register wie Intel 486 zu ermöglichen.

Primitive Typen

Die JVM unterstützt sieben primitive Datentypen. Java-Programmierer können Variablen dieser Datentypen deklarieren und verwenden, und Java-Bytecodes verarbeiten diese Datentypen. Die sieben primitiven Typen sind in der folgenden Tabelle aufgeführt:

Art Definition
byte Ein-Byte-Zwei-Komplement-Ganzzahl
short Zwei-Byte-Zweierkomplement-Ganzzahl
int 4-Byte-Ganzzahl mit zwei Vorzeichen
long 8-Byte-Zweierkomplement-Ganzzahl
float 4-Byte-IEEE 754-Float mit einfacher Genauigkeit
double 8-Byte-IEEE 754-Float mit doppelter Genauigkeit
char 2-Byte-Unicode-Zeichen ohne Vorzeichen

Die primitiven Typen erscheinen als Operanden in Bytecode-Streams. Alle primitiven Typen, die mehr als 1 Byte belegen, werden in Big-Endian-Reihenfolge im Bytecode-Stream gespeichert. Dies bedeutet, dass Bytes höherer Ordnung vor Bytes niedrigerer Ordnung stehen. Um beispielsweise den konstanten Wert 256 (hex 0100) auf den Stapel zu verschieben, verwenden Sie den sipushOpcode, gefolgt von einem kurzen Operanden. Der Kurzschluss wird im unten gezeigten Bytecode-Stream als "01 00" angezeigt, da die JVM Big-Endian ist. Wenn die JVM Little-Endian wäre, würde der Kurzfilm als "00 01" erscheinen.

// Bytecode-Stream: 17 01 00 // Demontage: sipush 256; // 17 01 00

Java-Opcodes geben im Allgemeinen den Typ ihrer Operanden an. Auf diese Weise können Operanden nur sie selbst sein, ohne ihren Typ gegenüber der JVM identifizieren zu müssen. Anstatt nur einen Opcode zu haben, der eine lokale Variable auf den Stapel schiebt, verfügt die JVM beispielsweise über mehrere. Opcodes iload, lload, fload, und dloadschieben lokalen Variablen des Typs int, lang, float, double und jeweils auf den Stapel.

Konstanten auf den Stapel schieben

Viele Opcodes schieben Konstanten auf den Stapel. Opcodes geben den konstanten Wert an, der auf drei verschiedene Arten gedrückt werden soll. Der konstante Wert ist entweder im Opcode selbst impliziert, folgt dem Opcode im Bytecode-Stream als Operand oder wird aus dem konstanten Pool entnommen.

Einige Opcodes geben selbst einen Typ und einen konstanten Wert an, der gedrückt werden soll. Beispielsweise weist der iconst_1Opcode die JVM an, den ganzzahligen Wert eins zu drücken. Solche Bytecodes werden für einige häufig übertragene Nummern verschiedener Typen definiert. Diese Anweisungen belegen nur 1 Byte im Bytecode-Stream. Sie erhöhen die Effizienz der Bytecode-Ausführung und reduzieren die Größe von Bytecode-Streams. Die Opcodes, die Ints und Floats drücken, sind in der folgenden Tabelle aufgeführt:

Opcode Operand (en) Beschreibung
iconst_m1 (keiner) schiebt int -1 auf den Stapel
iconst_0 (keiner) schiebt int 0 auf den Stapel
iconst_1 (keiner) schiebt int 1 auf den Stapel
iconst_2 (keiner) schiebt int 2 auf den Stapel
iconst_3 (keiner) schiebt int 3 auf den Stapel
iconst_4 (keiner) schiebt int 4 auf den Stapel
iconst_5 (keiner) schiebt int 5 auf den Stapel
fconst_0 (keiner) schiebt float 0 auf den Stapel
fconst_1 (keiner) drückt float 1 auf den stapel
fconst_2 (keiner) drückt float 2 auf den stapel

Die in der vorherigen Tabelle gezeigten Opcodes drücken Ints und Floats, bei denen es sich um 32-Bit-Werte handelt. Jeder Steckplatz im Java-Stack ist 32 Bit breit. Daher belegt jedes Mal, wenn ein Int oder Float auf den Stapel geschoben wird, ein Slot.

Die in der nächsten Tabelle gezeigten Opcodes drücken Longs und Doubles. Lange und doppelte Werte belegen 64 Bit. Jedes Mal, wenn ein Long oder Double auf den Stapel geschoben wird, belegt sein Wert zwei Slots auf dem Stapel. Opcodes, die einen bestimmten Long- oder Double-Wert zum Push angeben, werden in der folgenden Tabelle angezeigt:

Opcode Operand (en) Beschreibung
lconst_0 (keiner) schiebt lange 0 auf den Stapel
lconst_1 (keiner) schiebt lange 1 auf den Stapel
dconst_0 (keiner) schiebt die doppelte 0 auf den Stapel
dconst_1 (keiner) schiebt Doppel 1 auf den Stapel

One other opcode pushes an implicit constant value onto the stack. The aconst_null opcode, shown in the following table, pushes a null object reference onto the stack. The format of an object reference depends upon the JVM implementation. An object reference will somehow refer to a Java object on the garbage-collected heap. A null object reference indicates an object reference variable does not currently refer to any valid object. The aconst_null opcode is used in the process of assigning null to an object reference variable.

Opcode Operand(s) Description
aconst_null (none) pushes a null object reference onto the stack

Two opcodes indicate the constant to push with an operand that immediately follows the opcode. These opcodes, shown in the following table, are used to push integer constants that are within the valid range for byte or short types. The byte or short that follows the opcode is expanded to an int before it is pushed onto the stack, because every slot on the Java stack is 32 bits wide. Operations on bytes and shorts that have been pushed onto the stack are actually done on their int equivalents.

Opcode Operand(s) Description
bipush byte1 expands byte1 (a byte type) to an int and pushes it onto the stack
sipush byte1, byte2 expands byte1, byte2 (a short type) to an int and pushes it onto the stack

Three opcodes push constants from the constant pool. All constants associated with a class, such as final variables values, are stored in the class's constant pool. Opcodes that push constants from the constant pool have operands that indicate which constant to push by specifying a constant pool index. The Java virtual machine will look up the constant given the index, determine the constant's type, and push it onto the stack.

The constant pool index is an unsigned value that immediately follows the opcode in the bytecode stream. Opcodes lcd1 and lcd2 push a 32-bit item onto the stack, such as an int or float. The difference between lcd1 and lcd2 is that lcd1 can only refer to constant pool locations one through 255 because its index is just 1 byte. (Constant pool location zero is unused.) lcd2 has a 2-byte index, so it can refer to any constant pool location. lcd2w also has a 2-byte index, and it is used to refer to any constant pool location containing a long or double, which occupy 64 bits. The opcodes that push constants from the constant pool are shown in the following table:

Opcode Operand(s) Description
ldc1 indexbyte1 pushes 32-bit constant_pool entry specified by indexbyte1 onto the stack
ldc2 indexbyte1, indexbyte2 pushes 32-bit constant_pool entry specified by indexbyte1, indexbyte2 onto the stack
ldc2w indexbyte1, indexbyte2 pushes 64-bit constant_pool entry specified by indexbyte1, indexbyte2 onto the stack

Pushing local variables onto the stack

Local variables are stored in a special section of the stack frame. The stack frame is the portion of the stack being used by the currently executing method. Each stack frame consists of three sections -- the local variables, the execution environment, and the operand stack. Pushing a local variable onto the stack actually involves moving a value from the local variables section of the stack frame to the operand section. The operand section of the currently executing method is always the top of the stack, so pushing a value onto the operand section of the current stack frame is the same as pushing a value onto the top of the stack.

The Java stack is a last-in, first-out stack of 32-bit slots. Because each slot in the stack occupies 32 bits, all local variables occupy at least 32 bits. Local variables of type long and double, which are 64-bit quantities, occupy two slots on the stack. Local variables of type byte or short are stored as local variables of type int, but with a value that is valid for the smaller type. For example, an int local variable which represents a byte type will always contain a value valid for a byte (-128 <= value <= 127).

Each local variable of a method has a unique index. The local variable section of a method's stack frame can be thought of as an array of 32-bit slots, each one addressable by the array index. Local variables of type long or double, which occupy two slots, are referred to by the lower of the two slot indexes. For example, a double that occupies slots two and three would be referred to by an index of two.

Several opcodes exist that push int and float local variables onto the operand stack. Some opcodes are defined that implicitly refer to a commonly used local variable position. For example, iload_0 loads the int local variable at position zero. Other local variables are pushed onto the stack by an opcode that takes the local variable index from the first byte following the opcode. The iload instruction is an example of this type of opcode. The first byte following iload is interpreted as an unsigned 8-bit index that refers to a local variable.

Unsigned 8-bit local variable indexes, such as the one that follows the iload instruction, limit the number of local variables in a method to 256. A separate instruction, called wide, can extend an 8-bit index by another 8 bits. This raises the local variable limit to 64 kilobytes. The wide opcode is followed by an 8-bit operand. The wide opcode and its operand can precede an instruction, such as iload, that takes an 8-bit unsigned local variable index. The JVM combines the 8-bit operand of the wide instruction with the 8-bit operand of the iload instruction to yield a 16-bit unsigned local variable index.

The opcodes that push int and float local variables onto the stack are shown in the following table:

Opcode Operand(s) Description
iload vindex pushes int from local variable position vindex
iload_0 (none) drückt int von der lokalen Variablenposition Null
iload_1 (keiner) drückt int von der lokalen Variablenposition eins
iload_2 (keiner) drückt int von Position zwei der lokalen Variablen
iload_3 (keiner) drückt int von Position drei der lokalen Variablen
fload vindex drückt float von der lokalen variablen Position vindex
fload_0 (keiner) drückt float von der lokalen variablen Position Null
fload_1 (keiner) drückt float von lokaler variabler Position eins
fload_2 (keiner) drückt float von lokaler variabler Position zwei
fload_3 (keiner) drückt float von lokaler variabler Position drei

Die nächste Tabelle zeigt die Anweisungen, mit denen lokale Variablen vom Typ long und double auf den Stapel verschoben werden. Diese Anweisungen verschieben 64 Bit vom lokalen Variablenabschnitt des Stapelrahmens zum Operandenabschnitt.