Basisprincipes van bytecode

Welkom bij een nieuwe aflevering van "Under The Hood." Deze kolom geeft Java-ontwikkelaars een glimp van wat er onder hun actieve Java-programma's gebeurt. Het artikel van deze maand geeft een eerste blik op de bytecode-instructieset van de Java virtual machine (JVM). Het artikel behandelt primitieve typen die worden bediend door bytecodes, bytecodes die tussen typen converteren en bytecodes die op de stapel werken. Volgende artikelen zullen andere leden van de bytecode-familie bespreken.

Het bytecode-formaat

Bytecodes zijn de machinetaal van de virtuele Java-machine. Wanneer een JVM een klassebestand laadt, krijgt het een stroom bytecodes voor elke methode in de klasse. De bytecodestromen worden opgeslagen in het methodegebied van de JVM. De bytecodes voor een methode worden uitgevoerd wanneer die methode wordt aangeroepen tijdens het uitvoeren van het programma. Ze kunnen worden uitgevoerd door middel van intepretatie, just-in-time-compilatie of elke andere techniek die is gekozen door de ontwerper van een bepaalde JVM.

De bytecodestroom van een methode is een reeks instructies voor de virtuele Java-machine. Elke instructie bestaat uit een opcode van één byte gevolgd door nul of meer operanden . De opcode geeft de actie aan die moet worden ondernomen. Als er meer informatie nodig is voordat de JVM de actie kan ondernemen, wordt die informatie gecodeerd in een of meer operanden die onmiddellijk op de opcode volgen.

Elk type opcode heeft een geheugensteuntje. In de typische assembleertaalstijl kunnen streams van Java-bytecodes worden weergegeven door hun geheugensteuntjes gevolgd door operandwaarden. De volgende stroom bytecodes kan bijvoorbeeld worden gedemonteerd tot geheugensteuntjes:

// 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 ga naar -7 // a7 ff f9 

De bytecode-instructieset is ontworpen om compact te zijn. Alle instructies, behalve twee die te maken hebben met tafelspringen, zijn uitgelijnd op bytegrenzen. Het totale aantal opcodes is klein genoeg zodat opcodes slechts één byte innemen. Dit helpt bij het minimaliseren van de grootte van klassebestanden die via netwerken kunnen reizen voordat ze door een JVM worden geladen. Het helpt ook om de grootte van de JVM-implementatie klein te houden.

Alle berekeningen in de JVM staan ​​op de stapel. Omdat de JVM geen registers heeft voor het opslaan van bepaalde waarden, moet alles op de stapel worden geduwd voordat het in een berekening kan worden gebruikt. Bytecode-instructies werken daarom voornamelijk op de stapel. In de bovenstaande bytecode-reeks wordt bijvoorbeeld een lokale variabele met twee vermenigvuldigd door eerst de lokale variabele met de iload_0instructie op de stapel te duwen en vervolgens met twee op de stapel te duwen iconst_2. Nadat beide gehele getallen op de stapel zijn gedrukt, haalt de imulinstructie de twee gehele getallen effectief van de stapel, vermenigvuldigt ze en duwt het resultaat terug op de stapel. Het resultaat wordt van de bovenkant van de stapel gepopt en door deistore_0instructie. De JVM is ontworpen als een stack-gebaseerde machine in plaats van een register-gebaseerde machine om een ​​efficiënte implementatie op register-arme architecturen zoals de Intel 486 mogelijk te maken.

Primitieve typen

De JVM ondersteunt zeven primitieve gegevenstypen. Java-programmeurs kunnen variabelen van deze gegevenstypen declareren en gebruiken, en Java-bytecodes werken op deze gegevenstypen. De zeven primitieve typen worden vermeld in de volgende tabel:

Type Definitie
byte een-byte ondertekend twee-complement geheel getal
short twee-byte ondertekend twee-complement geheel getal
int 4-byte ondertekend twee-complement geheel getal
long Geheel getal van 8 bytes met twee-complement
float 4-byte IEEE 754 float met enkele precisie
double 8-byte IEEE 754 dubbele precisie float
char 2-bytes niet-ondertekend Unicode-teken

De primitieve typen verschijnen als operanden in bytecodestromen. Alle primitieve typen die meer dan 1 byte innemen, worden in de bytecodestroom in big-endian-volgorde opgeslagen, wat betekent dat bytes van hogere orde voorafgaan aan bytes van lagere orde. Als u bijvoorbeeld de constante waarde 256 (hex 0100) op de stapel wilt plaatsen, gebruikt u de sipushopcode gevolgd door een korte operand. De short verschijnt in de bytecode-stream, hieronder weergegeven, als "01 00" omdat de JVM big-endian is. Als de JVM little-endian was, zou de short verschijnen als "00 01".

// Bytecode-stroom: 17 01 00 // Dissassembly: sipush 256; // 17 01 00

Java-opcodes geven over het algemeen het type van hun operanden aan. Hierdoor kunnen operanden gewoon zichzelf zijn, zonder dat ze hun type hoeven te identificeren voor de JVM. In plaats van één opcode te hebben die een lokale variabele op de stapel duwt, heeft de JVM er meerdere. Opcodes iload, lload, floaden dloadduw lokale variabelen van het type int, lange, float en double respectievelijk op de stapel.

Constanten op de stapel duwen

Veel opcodes pushen constanten op de stapel. Opcodes geven de constante waarde op drie verschillende manieren aan. De constante waarde is ofwel impliciet in de opcode zelf, volgt de opcode in de bytecodestroom als een operand, of wordt uit de constante pool gehaald.

Sommige opcodes geven op zichzelf een type en constante waarde aan die moeten worden gepusht. De iconst_1opcode vertelt de JVM bijvoorbeeld om de gehele waarde één te pushen. Dergelijke bytecodes zijn gedefinieerd voor een aantal vaak gepushte nummers van verschillende typen. Deze instructies nemen slechts 1 byte in beslag in de bytecodestream. Ze verhogen de efficiëntie van de uitvoering van bytecodes en verkleinen de grootte van bytecode-streams. De opcodes die ints en floats pushen, worden weergegeven in de volgende tabel:

Opcode Operand (en) Omschrijving
iconst_m1 (geen) duwt int -1 op de stapel
iconst_0 (geen) duwt int 0 op de stapel
iconst_1 (geen) duwt int 1 op de stapel
iconst_2 (geen) duwt int 2 op de stapel
iconst_3 (geen) duwt int 3 op de stapel
iconst_4 (geen) schuift int 4 op de stapel
iconst_5 (geen) duwt int 5 op de stapel
fconst_0 (geen) duwt float 0 op de stapel
fconst_1 (geen) duwt drijver 1 op de stapel
fconst_2 (geen) duwt vlotter 2 op de stapel

De opcodes die in de vorige tabel worden getoond, pushen ints en floats, dit zijn 32-bits waarden. Elke sleuf op de Java-stack is 32 bits breed. Elke keer dat een int of float op de stapel wordt gedrukt, neemt deze dus één slot in beslag.

De opcodes die in de volgende tabel worden getoond, pushen lang en dubbel. Lange en dubbele waarden nemen 64 bits in beslag. Elke keer dat een long of double op de stapel wordt geduwd, neemt de waarde ervan twee slots op de stapel in beslag. Opcodes die een specifieke lange of dubbele waarde aangeven die moet worden gepusht, worden weergegeven in de volgende tabel:

Opcode Operand (en) Omschrijving
lconst_0 (geen) duwt lange 0 op de stapel
lconst_1 (geen) schuift lang 1 op de stapel
dconst_0 (geen) schuift dubbel 0 op de stapel
dconst_1 (geen) schuift dubbel 1 op de 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) duwt int van lokale variabele positie nul
iload_1 (geen) duwt int van lokale variabele positie één
iload_2 (geen) duwt int van lokale variabele positie twee
iload_3 (geen) duwt int van lokale variabele positie drie
fload vindex duwt float van lokale variabele positie vindex
fload_0 (geen) duwt float van lokale variabele positie nul
fload_1 (geen) duwt float van lokale variabele positie één
fload_2 (geen) duwt float van lokale variabele positie twee
fload_3 (geen) duwt float van lokale variabele positie drie

De volgende tabel toont de instructies die lokale variabelen van het type long en double op de stapel duwen. Deze instructies verplaatsen 64 bits van de lokale variabele sectie van het stapelframe naar de operandsectie.