Nozioni di base sul bytecode

Benvenuti a un'altra puntata di "Under The Hood". Questa colonna offre agli sviluppatori Java un'idea di cosa sta succedendo sotto i loro programmi Java in esecuzione. L'articolo di questo mese dà una prima occhiata al set di istruzioni bytecode della Java virtual machine (JVM). L'articolo copre i tipi primitivi gestiti da bytecode, bytecode che convertono tra tipi e bytecode che operano sullo stack. Gli articoli successivi discuteranno di altri membri della famiglia bytecode.

Il formato bytecode

I bytecode sono il linguaggio macchina della macchina virtuale Java. Quando una JVM carica un file di classe, ottiene un flusso di bytecode per ogni metodo nella classe. I flussi di bytecode vengono memorizzati nell'area del metodo della JVM. I bytecode per un metodo vengono eseguiti quando quel metodo viene richiamato durante l'esecuzione del programma. Possono essere eseguiti mediante interpretazione, compilazione just-in-time o qualsiasi altra tecnica scelta dal progettista di una particolare JVM.

Il flusso di bytecode di un metodo è una sequenza di istruzioni per la macchina virtuale Java. Ogni istruzione è costituita da un codice operativo di un byte seguito da zero o più operandi . Il codice operativo indica l'azione da intraprendere. Se sono necessarie più informazioni prima che la JVM possa intraprendere l'azione, tali informazioni vengono codificate in uno o più operandi che seguono immediatamente il codice operativo.

Ogni tipo di codice operativo ha un mnemonico. Nel tipico stile del linguaggio assembly, i flussi di bytecode Java possono essere rappresentati dai loro mnemonici seguiti da qualsiasi valore di operando. Ad esempio, il seguente flusso di bytecode può essere smontato in mnemonici:

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

Il set di istruzioni bytecode è stato progettato per essere compatto. Tutte le istruzioni, tranne due che riguardano il salto di tabella, sono allineate sui limiti dei byte. Il numero totale di codici operativi è sufficientemente piccolo in modo che i codici operativi occupino solo un byte. Ciò consente di ridurre al minimo le dimensioni dei file di classe che potrebbero viaggiare attraverso le reti prima di essere caricati da una JVM. Aiuta anche a mantenere ridotte le dimensioni dell'implementazione JVM.

Tutti i calcoli nella JVM sono centrati sullo stack. Poiché la JVM non ha registri per la memorizzazione di valori abitrari, tutto deve essere inserito nello stack prima di poter essere utilizzato in un calcolo. Le istruzioni bytecode quindi operano principalmente sullo stack. Ad esempio, nella sequenza del bytecode sopra una variabile locale viene moltiplicata per due spingendo prima la variabile locale nello stack con l' iload_0istruzione, quindi spingendone due nello stack con iconst_2. Dopo che entrambi gli interi sono stati inseriti nello stack, l' imulistruzione estrae efficacemente i due interi dallo stack, li moltiplica e rimette il risultato nello stack. Il risultato viene estratto dalla cima dello stack e memorizzato nella variabile locale dalistore_0istruzione. La JVM è stata progettata come una macchina basata su stack piuttosto che una macchina basata su registri per facilitare l'implementazione efficiente su architetture prive di registri come l'Intel 486.

Tipi primitivi

La JVM supporta sette tipi di dati primitivi. I programmatori Java possono dichiarare e utilizzare variabili di questi tipi di dati e i bytecode Java operano su questi tipi di dati. I sette tipi primitivi sono elencati nella tabella seguente:

genere Definizione
byte un byte firmato intero con complemento a due
short numero intero con complemento a due con segno a due byte
int Intero con complemento a due con segno a 4 byte
long Intero con complemento a due con segno a 8 byte
float Float a precisione singola IEEE 754 a 4 byte
double Float a doppia precisione IEEE 754 a 8 byte
char Carattere Unicode a 2 byte senza segno

I tipi primitivi vengono visualizzati come operandi nei flussi di bytecode. Tutti i tipi primitivi che occupano più di 1 byte vengono memorizzati in ordine big-endian nel flusso bytecode, il che significa che i byte di ordine superiore precedono i byte di ordine inferiore. Ad esempio, per inserire il valore della costante 256 (esadecimale 0100) nello stack, dovresti utilizzare il sipushcodice operativo seguito da un breve operando. Il corto appare nel flusso bytecode, mostrato sotto, come "01 00" perché la JVM è big-endian. Se la JVM fosse little-endian, il corto apparirebbe come "00 01".

// Stream Bytecode: 17 01 00 // Disassemblaggio: sipush 256; // 17 01 00

Gli opcode Java generalmente indicano il tipo dei loro operandi. Ciò consente agli operandi di essere solo se stessi, senza la necessità di identificare il loro tipo nella JVM. Ad esempio, invece di avere un codice operativo che inserisce una variabile locale nello stack, la JVM ne ha diversi. Opcode iload, lload, fload, e dloadspingono variabili locali di tipo int, long, float, e doppio, rispettivamente, sulla pila.

Inserimento di costanti in pila

Molti codici operativi inseriscono le costanti nello stack. Gli opcode indicano il valore costante da inviare in tre modi diversi. Il valore della costante è implicito nel codice operativo stesso, segue il codice operativo nel flusso del bytecode come un operando o è preso dal pool di costanti.

Alcuni codici operativi da soli indicano un tipo e un valore costante da spingere. Ad esempio, il iconst_1codice operativo dice alla JVM di inviare il valore intero uno. Tali bytecode sono definiti per alcuni numeri comunemente inviati di vario tipo. Queste istruzioni occupano solo 1 byte nel flusso del bytecode. Aumentano l'efficienza dell'esecuzione del bytecode e riducono la dimensione dei flussi di bytecode. Gli opcode che spingono int e float sono mostrati nella tabella seguente:

Codice operativo Operando (i) Descrizione
iconst_m1 (nessuna) spinge int -1 in pila
iconst_0 (nessuna) inserisce int 0 nello stack
iconst_1 (nessuna) spinge int 1 sullo stack
iconst_2 (nessuna) spinge int 2 sullo stack
iconst_3 (nessuna) spinge int 3 sullo stack
iconst_4 (nessuna) spinge int 4 sullo stack
iconst_5 (nessuna) spinge int 5 nello stack
fconst_0 (nessuna) spinge il float 0 sullo stack
fconst_1 (nessuna) spinge il galleggiante 1 sulla pila
fconst_2 (nessuna) spinge il galleggiante 2 sulla pila

Gli opcode mostrati nella tabella precedente spingono int e float, che sono valori a 32 bit. Ciascuno slot nello stack Java ha una larghezza di 32 bit. Pertanto ogni volta che un int o un float viene inserito nello stack, occupa uno slot.

Gli opcode mostrati nella tabella successiva spingono long e double. I valori lunghi e doppi occupano 64 bit. Ogni volta che un long o double viene messo in pila, il suo valore occupa due slot in pila. Gli opcode che indicano uno specifico valore lungo o doppio da spingere sono mostrati nella tabella seguente:

Codice operativo Operando (i) Descrizione
lconst_0 (nessuna) spinge il lungo 0 in pila
lconst_1 (nessuna) spinge il lungo 1 sulla pila
dconst_0 (nessuna) spinge il doppio 0 in pila
dconst_1 (nessuna) spinge il doppio 1 sulla pila

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) spinge int dalla posizione zero della variabile locale
iload_1 (nessuna) spinge int dalla posizione della variabile locale uno
iload_2 (nessuna) spinge int dalla posizione della variabile locale due
iload_3 (nessuna) spinge int dalla posizione della variabile locale tre
fload vindex spinge float dalla posizione della variabile locale vindex
fload_0 (nessuna) spinge il float dalla posizione zero della variabile locale
fload_1 (nessuna) spinge float dalla posizione della variabile locale uno
fload_2 (nessuna) spinge float dalla posizione della variabile locale due
fload_3 (nessuna) spinge float dalla posizione della variabile locale tre

La tabella successiva mostra le istruzioni che spingono le variabili locali di tipo long e double nello stack. Queste istruzioni spostano 64 bit dalla sezione delle variabili locali dello stack frame alla sezione degli operandi.