Основы байт-кода

Добро пожаловать в очередной выпуск «Под капотом». Этот столбец дает разработчикам Java представление о том, что происходит под их работающими программами Java. В статье этого месяца дается первый взгляд на набор инструкций байт-кода виртуальной машины Java (JVM). В статье рассматриваются примитивные типы, оперируемые байт-кодами, байт-кодами, которые преобразуют типы, и байт-кодами, которые работают в стеке. В следующих статьях будут обсуждаться другие члены семейства байт-кода.

Формат байт-кода

Байт-коды - это машинный язык виртуальной машины Java. Когда JVM загружает файл класса, она получает один поток байт-кодов для каждого метода в классе. Потоки байт-кода хранятся в области методов JVM. Байт-коды для метода выполняются, когда этот метод вызывается в ходе выполнения программы. Они могут быть выполнены путем интерпретации, своевременной компиляции или любого другого метода, который был выбран разработчиком конкретной JVM.

Поток байт-кода метода - это последовательность инструкций для виртуальной машины Java. Каждая инструкция состоит из однобайтового кода операции, за которым следует ноль или более операндов . Код операции указывает действие, которое необходимо предпринять. Если требуется дополнительная информация до того, как JVM сможет выполнить действие, эта информация кодируется в один или несколько операндов, которые следуют сразу за кодом операции.

Каждый тип кода операции имеет мнемонику. В типичном стиле языка ассемблера потоки байт-кода Java могут быть представлены их мнемоникой, за которой следуют любые значения операндов. Например, следующий поток байт-кодов можно разобрать на мнемонику:

// Поток байт-кода: 03 3b 84 00 01 1a 05 68 3b a7 ff f9 // Дизассемблирование: 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 

Набор инструкций байт-кода был разработан, чтобы быть компактным. Все инструкции, кроме двух, которые связаны с переходом по таблице, выровнены по границам байтов. Общее количество кодов операций достаточно мало, так что коды операций занимают только один байт. Это помогает минимизировать размер файлов классов, которые могут перемещаться по сети перед загрузкой JVM. Это также помогает сохранить небольшой размер реализации JVM.

Все вычисления в JVM сосредоточены в стеке. Поскольку JVM не имеет регистров для хранения произвольных значений, все должно быть помещено в стек, прежде чем оно может быть использовано в вычислениях. Поэтому инструкции байт-кода работают в основном со стеком. Например, в приведенной выше последовательности байт-кода локальная переменная умножается на два, сначала помещая локальную переменную в стек с помощью iload_0инструкции, а затем помещая две в стек с помощью iconst_2. После того, как оба целых числа помещены в стек, imulинструкция фактически выталкивает два целых числа из стека, умножает их и помещает результат обратно в стек. Результат извлекается из вершины стека и сохраняется обратно в локальную переменную с помощьюistore_0инструкция. JVM была разработана как машина на основе стека, а не как машина на основе регистров, чтобы облегчить эффективную реализацию на архитектурах с недостаточным регистром, таких как Intel 486.

Примитивные типы

JVM поддерживает семь примитивных типов данных. Программисты Java могут объявлять и использовать переменные этих типов данных, а байт-коды Java работают с этими типами данных. Семь примитивных типов перечислены в следующей таблице:

Тип Определение
byte однобайтовое целое число с дополнением до двух со знаком
short двухбайтовое целое число с дополнением до двух со знаком
int 4-байтовое целое число с дополнением до двух со знаком
long 8-байтовое целое число с дополнением до двух со знаком
float 4-байтовое число с плавающей запятой одинарной точности IEEE 754
double 8-байтное число с плавающей запятой двойной точности IEEE 754
char 2-байтовый беззнаковый символ Юникода

Примитивные типы появляются как операнды в потоках байт-кода. Все примитивные типы, занимающие более 1 байта, хранятся в потоке байт-кода в обратном порядке, что означает, что байты более высокого порядка предшествуют байтам более низкого порядка. Например, чтобы поместить в стек постоянное значение 256 (шестнадцатеричное 0100), вы должны использовать sipushкод операции, за которым следует короткий операнд. Сокращение отображается в потоке байт-кода, показанном ниже, как «01 00», потому что JVM является прямым порядком байтов. Если бы JVM была с прямым порядком байтов, короткое значение было бы как «00 01».

// Поток байт-кода: 17 01 00 // Разборка: sipush 256; // 17 01 00

Коды операций Java обычно указывают тип своих операндов. Это позволяет операндам быть самими собой, без необходимости определять их тип для JVM. Например, вместо одного кода операции, который помещает локальную переменную в стек, в JVM их несколько. Opcodes iload, lload, floadи dloadнажмите локальные переменные типа Int, длинные, с плавающей точкой, и двойной, соответственно, на стек.

Помещение констант в стек

Многие коды операций помещают константы в стек. Коды операций указывают на постоянное значение, которое нужно отправить тремя разными способами. Постоянное значение либо неявно присутствует в самом коде операции, следует за кодом операции в потоке байт-кода как операнд или берется из пула констант.

Некоторые коды операций сами по себе указывают тип и постоянное значение для отправки. Например, iconst_1код операции сообщает JVM, что нужно вставить целое значение один. Такие байт-коды определены для некоторых часто передаваемых номеров различных типов. Эти инструкции занимают только 1 байт в потоке байт-кода. Они повышают эффективность выполнения байт-кода и уменьшают размер потоков байт-кода. Коды операций, которые вставляют int и float, показаны в следующей таблице:

Код операции Операнд (ы) Описание
iconst_m1 (никто) помещает int -1 в стек
iconst_0 (никто) помещает int 0 в стек
iconst_1 (никто) помещает int 1 в стек
iconst_2 (никто) помещает int 2 в стек
iconst_3 (никто) помещает int 3 в стек
iconst_4 (никто) помещает int 4 в стек
iconst_5 (никто) помещает int 5 в стек
fconst_0 (никто) помещает float 0 в стек
fconst_1 (никто) помещает float 1 в стек
fconst_2 (никто) помещает поплавок 2 в стек

Коды операций, показанные в предыдущей таблице, вставляют int и float, которые являются 32-битными значениями. Каждый слот в стеке Java имеет ширину 32 бита. Поэтому каждый раз, когда int или float помещается в стек, он занимает один слот.

Коды операций, показанные в следующей таблице, включают длинные и двойные значения. Длинные и двойные значения занимают 64 бита. Каждый раз, когда long или double помещается в стек, его значение занимает два слота в стеке. Коды операций, указывающие на отправку определенного длинного или двойного значения, показаны в следующей таблице:

Код операции Операнд (ы) Описание
lconst_0 (никто) помещает длинный 0 в стек
lconst_1 (никто) помещает длинную 1 в стек
dconst_0 (никто) помещает двойной 0 в стек
dconst_1 (никто) помещает двойную 1 в стек

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) выталкивает int из нулевой позиции локальной переменной
iload_1 (никто) выталкивает int из первой позиции локальной переменной
iload_2 (никто) выталкивает int из второй позиции локальной переменной
iload_3 (никто) выталкивает int из третьей позиции локальной переменной
fload виндекс выталкивает float из позиции локальной переменной vindex
fload_0 (никто) выталкивает поплавок из нулевой позиции локальной переменной
fload_1 (никто) выталкивает float из первой позиции локальной переменной
fload_2 (никто) выталкивает float из второй позиции локальной переменной
fload_3 (никто) выталкивает поплавок из третьей позиции локальной переменной

В следующей таблице показаны инструкции, которые помещают в стек локальные переменные типа long и double. Эти инструкции перемещают 64 бита из раздела локальной переменной кадра стека в раздел операнда.