Разработчики приложений на Java обычно не нуждаются в знании о байт-коде, выполняющемся в виртуальной машине, однако тем, кто занимается разработкой современных фреймворков, компиляторов или даже инструментов Java может понадобиться понимание байт-кода и, возможно, даже понимание того, как его использовать в своих целях. Несмотря на то, что специальные библиотеки типа ASM, cglib, Javassist помогают в использовании байт-кода, необходимо понимание основ для того, чтобы использовать эти библиотеки эффективно.
В статье описаны самые основы, от которых можно отталкиваться в дальнейшем раскапывании данной темы (прим. пер.).
Давайте начнём с простого примера, а именно POJO с одним полем и геттером и сеттером для него.
Когда вы скомпилируете класс, используя команду javac Foo.java, у вас появится файл Foo.class, содержащий байт-код. Вот как его содержание выглядит в HEX-редакторе:
Каждая пара шестнадцатеричных чисел (байт) переводится в опкоды (мнемоника). Было бы жестоко попытаться прочитать это в двоичном формате. Давайте перейдем к мнемоничному представлению.
Команда javap -c Foo выведет байт-код:
Класс очень простой, поэтому будет легко увидеть связь между исходным кодом и сгенерированным байт-кодом. Первым делом мы видим, что в байт-код-версии класса компилятор вызывает конструктор по умолчанию (как и написано в спецификациях JVM).
Далее, изучая байт-кодовые инструкции (у нас это aload_0 и aload_1), мы видим, что некоторые из них имеют префиксы типа aload_0 и istore_2. Это относится к типу данных, с которыми оперирует инструкция. Префикс «a» обозначает, что опкод управляет ссылкой на объект. «i», соответственно, управляет integer.
Интересный момент здесь заключается в том, что некоторые из инструкций оперируют странными операндами типа #1 и #2, что на самом деле относится к пулу констант класса. Самое время изучить class-файл поближе. Выполните команду javap -c -s -verbose (-s для вывода сигнатур, -verbose для подробного вывода)
Теперь видно, что это за странные операнды. Например, #2:
const #2 = Field #3.#18; // Foo.bar:Ljava/lang/String;
Он ссылается на:
const #3 = class #19; // Foo
const #18 = NameAndType #5:#6;// bar:Ljava/lang/String;
И так далее.
Отметим, что, каждый код операции помечен номером (0: aload_0). Это указание на позицию инструкции внутри фрейма — дальше объясню, что это значит.
Чтобы понять, как работает байт-код, достаточно взглянуть на модель выполнения. JVM использует модель выполнения на основе стеков. Каждый тред имеет JVM-стек, содержащий фреймы. Например, если мы запустим приложение в дебаггере, то увидим следующие фреймы:
При каждом вызове метода создается новый фрейм. Фрейм состоит из стека операнда, массива локальных переменных и ссылку на пул констант класса выполняемого метода.
Размер массива локальных переменных определяется во время компиляции в зависимости от количества и размера локальных переменных и параметров метода. Стек операндов — LIFO-стек для записи и удаления значений в стеке; размер также определяется во время компиляции. Некоторые опкоды добавляют значения в стек, другие берут из стека операнды, изменяют их состояние и возвращают в стек. Стек операндов также используется для получения значений, возвращаемых методом (return values).
Байткод для этого метода состоит из трёх опкодов. Первый опкод, aload_0, проталкивает в стек значение с индексом 0 из таблицы локальных переменных. Ссылка this в таблице локальных переменных для конструкторов и instance-методов всегда имеет индекс 0. Следующий опкод, getfield, достает поле объекта. Последняя инструкция, areturn, возвращает ссылку из метода.
Каждый метод имеет соответствующий байткод-массив. Смотря на содержимое .class-файла в hex-редакторе, вы увидите в байткод-массиве следующие значения:
Так, байткод для метода getBar — 2A B4 00 02 B0. 2A относится к инструкции aload_0, B0 — к areturn. Может показаться странным, что байткод для метода имеет три инструкции, а в массиве байт 5 элементов. Это связано с тем, что getfield (B4) нуждается в двух параметрах (00 02), занимающих позиции 2 и 3 в массиве, отсюда и 5 элементов в массиве. Инструкция areturn сдвигается на 4 позицию.
Таблица локальных переменных
Для иллюстрации того, что происходит с локальными переменными, воспользуемся ещё одним примером:
Здесь две локальных переменных — параметр метода и локальная переменная int b. Вот как выглядит байт-код:
LocalVariableTable:
Start Length Slot Name Signature
0 6 0 this LExample;
0 6 1 a I
2 4 2 b I
Метод загружает константу 1 с помощью iconst_1 и ложит её в локальную переменную 2 с помощью istore_2. Теперь в таблице локальных переменных слот 2 занят переменной b, как и ожидалось. Далее, iload_1 загружает значение в стек, iload_2 загружает значение b. iadd выталкивает 2 операнда из стека, добавляет их и возвращает значение метода.
Обработка исключений
Интересный пример того, какой получается байт-код в случае с обработкой исключений, например, для конструкции try-catch-finally.
Байт-код для метода foo():
Компилятор генерирует код для всех сценариев, возможных внутри блока try-catch-finally: finallyMethod() вызывается три раза(!). Блок try скомпилировался так, как будто try не было и он был объединён с finally:
0: aload_0
1: invokespecial #2; //Method tryMethod:()V
4: aload_0
5: invokespecial #3; //Method finallyMethod:()V
Если блок выполняется, то инструкция goto перекидывает выполнение на 30-ю позицию с опкодом return.
Если tryMethod бросит Exception, будет выбран первый подходящий (внутренний) обработчик исключений из таблицы исключений. Из таблицы исключений мы видим, что позиция с перехватом исключения равна 11:
0 4 11 Class java/lang/Exception
Это перекидывает выполнение на catchMethod() и finallyMethod():
11: astore_1
12: aload_0
13: invokespecial #5; //метод catchMethod:()V
16: aload_0
17: invokespecial #3; //метод finallyMethod:()V
Если в процессе выполнения будет брошено другое исключение, мы увидим, что в таблице исключений позиция будет равна 23:
0 4 23 any
11 16 23 any
23 24 23 any
Инструкции, начиная с 23:
23: astore_2
24: aload_0
25: invokespecial #3; //Method finallyMethod:()V
28: aload_2
29: athrow
30: return
Так что finallyMethod() будет выполнен в любом случае, с aload_2 и athrow, бросающим необрабатываемое исключение.
Заключение
Это всего лишь несколько моментов из области байткода JVM. Большинство было почерпнуто из статьи developerWorks Peter Haggar — Java bytecode: Understanding bytecode makes you a better programmer. Статья немного устарела, но до сих пор актуальна. Руководство пользователя BCEL содержит достойное описание основ байт-кода, поэтому я предложил бы почитать его интересующимся. Кроме того, спецификация виртуальной машины также может быть полезным источником информации, но ее нелегко читать, кроме этого отсутствует графический материал, который бывает полезным при понимании.
В целом, я думаю, что понимание того, как работает байт-код, является важным моментом в углублении своих знаний в Java-программировании, особенно для тех, кто присматривается к фреймворкам, компиляторам JVM-языков или другим утилитам.
В статье описаны самые основы, от которых можно отталкиваться в дальнейшем раскапывании данной темы (прим. пер.).
Давайте начнём с простого примера, а именно POJO с одним полем и геттером и сеттером для него.
public class Foo {
private String bar;
public String getBar(){
return bar;
}
public void setBar(String bar) {
this.bar = bar;
}
}
Когда вы скомпилируете класс, используя команду javac Foo.java, у вас появится файл Foo.class, содержащий байт-код. Вот как его содержание выглядит в HEX-редакторе:
Каждая пара шестнадцатеричных чисел (байт) переводится в опкоды (мнемоника). Было бы жестоко попытаться прочитать это в двоичном формате. Давайте перейдем к мнемоничному представлению.
Команда javap -c Foo выведет байт-код:
public class Foo extends java.lang.Object {
public Foo();
Code:
0: aload_0
1: invokespecial #1; //Method java/lang/Object."<init>":()V
4: return
public java.lang.String getBar();
Code:
0: aload_0
1: getfield #2; //Field bar:Ljava/lang/String;
4: areturn
public void setBar(java.lang.String);
Code:
0: aload_0
1: aload_1
2: putfield #2; //Field bar:Ljava/lang/String;
5: return
}
Класс очень простой, поэтому будет легко увидеть связь между исходным кодом и сгенерированным байт-кодом. Первым делом мы видим, что в байт-код-версии класса компилятор вызывает конструктор по умолчанию (как и написано в спецификациях JVM).
Далее, изучая байт-кодовые инструкции (у нас это aload_0 и aload_1), мы видим, что некоторые из них имеют префиксы типа aload_0 и istore_2. Это относится к типу данных, с которыми оперирует инструкция. Префикс «a» обозначает, что опкод управляет ссылкой на объект. «i», соответственно, управляет integer.
Интересный момент здесь заключается в том, что некоторые из инструкций оперируют странными операндами типа #1 и #2, что на самом деле относится к пулу констант класса. Самое время изучить class-файл поближе. Выполните команду javap -c -s -verbose (-s для вывода сигнатур, -verbose для подробного вывода)
Compiled from "Foo.java"
public class Foo extends java.lang.Object
SourceFile: "Foo.java"
minor version: 0
major version: 50
Constant pool:
const #1 = Method #4.#17; // java/lang/Object."":()V
const #2 = Field #3.#18; // Foo.bar:Ljava/lang/String;
const #3 = class #19; // Foo
const #4 = class #20; // java/lang/Object
const #5 = Asciz bar;
const #6 = Asciz Ljava/lang/String;;
const #7 = Asciz ;
const #8 = Asciz ()V;
const #9 = Asciz Code;
const #10 = Asciz LineNumberTable;
const #11 = Asciz getBar;
const #12 = Asciz ()Ljava/lang/String;;
const #13 = Asciz setBar;
const #14 = Asciz (Ljava/lang/String;)V;
const #15 = Asciz SourceFile;
const #16 = Asciz Foo.java;
const #17 = NameAndType #7:#8;// "":()V
const #18 = NameAndType #5:#6;// bar:Ljava/lang/String;
const #19 = Asciz Foo;
const #20 = Asciz java/lang/Object;
{
public Foo();
Signature: ()V
Code:
Stack=1, Locals=1, Args_size=1
0: aload_0
1: invokespecial #1; //Method java/lang/Object."":()V
4: return
LineNumberTable:
line 1: 0
public java.lang.String getBar();
Signature: ()Ljava/lang/String;
Code:
Stack=1, Locals=1, Args_size=1
0: aload_0
1: getfield #2; //Field bar:Ljava/lang/String;
4: areturn
LineNumberTable:
line 5: 0
public void setBar(java.lang.String);
Signature: (Ljava/lang/String;)V
Code:
Stack=2, Locals=2, Args_size=2
0: aload_0
1: aload_1
2: putfield #2; //Field bar:Ljava/lang/String;
5: return
LineNumberTable:
line 8: 0
line 9: 5
}
Теперь видно, что это за странные операнды. Например, #2:
const #2 = Field #3.#18; // Foo.bar:Ljava/lang/String;
Он ссылается на:
const #3 = class #19; // Foo
const #18 = NameAndType #5:#6;// bar:Ljava/lang/String;
И так далее.
Отметим, что, каждый код операции помечен номером (0: aload_0). Это указание на позицию инструкции внутри фрейма — дальше объясню, что это значит.
Чтобы понять, как работает байт-код, достаточно взглянуть на модель выполнения. JVM использует модель выполнения на основе стеков. Каждый тред имеет JVM-стек, содержащий фреймы. Например, если мы запустим приложение в дебаггере, то увидим следующие фреймы:
При каждом вызове метода создается новый фрейм. Фрейм состоит из стека операнда, массива локальных переменных и ссылку на пул констант класса выполняемого метода.
Размер массива локальных переменных определяется во время компиляции в зависимости от количества и размера локальных переменных и параметров метода. Стек операндов — LIFO-стек для записи и удаления значений в стеке; размер также определяется во время компиляции. Некоторые опкоды добавляют значения в стек, другие берут из стека операнды, изменяют их состояние и возвращают в стек. Стек операндов также используется для получения значений, возвращаемых методом (return values).
public String getBar(){
return bar;
}
public java.lang.String getBar();
Code:
0: aload_0
1: getfield #2; //Field bar:Ljava/lang/String;
4: areturn
Байткод для этого метода состоит из трёх опкодов. Первый опкод, aload_0, проталкивает в стек значение с индексом 0 из таблицы локальных переменных. Ссылка this в таблице локальных переменных для конструкторов и instance-методов всегда имеет индекс 0. Следующий опкод, getfield, достает поле объекта. Последняя инструкция, areturn, возвращает ссылку из метода.
Каждый метод имеет соответствующий байткод-массив. Смотря на содержимое .class-файла в hex-редакторе, вы увидите в байткод-массиве следующие значения:
Так, байткод для метода getBar — 2A B4 00 02 B0. 2A относится к инструкции aload_0, B0 — к areturn. Может показаться странным, что байткод для метода имеет три инструкции, а в массиве байт 5 элементов. Это связано с тем, что getfield (B4) нуждается в двух параметрах (00 02), занимающих позиции 2 и 3 в массиве, отсюда и 5 элементов в массиве. Инструкция areturn сдвигается на 4 позицию.
Таблица локальных переменных
Для иллюстрации того, что происходит с локальными переменными, воспользуемся ещё одним примером:
public class Example {
public int plus(int a){
int b = 1;
return a + b;
}
}
Здесь две локальных переменных — параметр метода и локальная переменная int b. Вот как выглядит байт-код:
public int plus(int);
Code:
Stack=2, Locals=3, Args_size=2
0: iconst_1
1: istore_2
2: iload_1
3: iload_2
4: iadd
5: ireturn
LineNumberTable:
line 5: 0
line 6: 2
LocalVariableTable:
Start Length Slot Name Signature
0 6 0 this LExample;
0 6 1 a I
2 4 2 b I
Метод загружает константу 1 с помощью iconst_1 и ложит её в локальную переменную 2 с помощью istore_2. Теперь в таблице локальных переменных слот 2 занят переменной b, как и ожидалось. Далее, iload_1 загружает значение в стек, iload_2 загружает значение b. iadd выталкивает 2 операнда из стека, добавляет их и возвращает значение метода.
Обработка исключений
Интересный пример того, какой получается байт-код в случае с обработкой исключений, например, для конструкции try-catch-finally.
public class ExceptionExample {
public void foo(){
try {
tryMethod();
}
catch (Exception e) {
catchMethod();
}finally{
finallyMethod();
}
}
private void tryMethod() throws Exception{}
private void catchMethod() {}
private void finallyMethod(){}
}
Байт-код для метода foo():
public void foo();
Code:
0: aload_0
1: invokespecial #2; //Method tryMethod:()V
4: aload_0
5: invokespecial #3; //Method finallyMethod:()V
8: goto 30
11: astore_1
12: aload_0
13: invokespecial #5; //Method catchMethod:()V
16: aload_0
17: invokespecial #3; //Method finallyMethod:()V
20: goto 30
23: astore_2
24: aload_0
25: invokespecial #3; //Method finallyMethod:()V
28: aload_2
29: athrow
30: return
Exception table:
from to target type
0 4 11 Class java/lang/Exception
0 4 23 any
11 16 23 any
23 24 23 any
Компилятор генерирует код для всех сценариев, возможных внутри блока try-catch-finally: finallyMethod() вызывается три раза(!). Блок try скомпилировался так, как будто try не было и он был объединён с finally:
0: aload_0
1: invokespecial #2; //Method tryMethod:()V
4: aload_0
5: invokespecial #3; //Method finallyMethod:()V
Если блок выполняется, то инструкция goto перекидывает выполнение на 30-ю позицию с опкодом return.
Если tryMethod бросит Exception, будет выбран первый подходящий (внутренний) обработчик исключений из таблицы исключений. Из таблицы исключений мы видим, что позиция с перехватом исключения равна 11:
0 4 11 Class java/lang/Exception
Это перекидывает выполнение на catchMethod() и finallyMethod():
11: astore_1
12: aload_0
13: invokespecial #5; //метод catchMethod:()V
16: aload_0
17: invokespecial #3; //метод finallyMethod:()V
Если в процессе выполнения будет брошено другое исключение, мы увидим, что в таблице исключений позиция будет равна 23:
0 4 23 any
11 16 23 any
23 24 23 any
Инструкции, начиная с 23:
23: astore_2
24: aload_0
25: invokespecial #3; //Method finallyMethod:()V
28: aload_2
29: athrow
30: return
Так что finallyMethod() будет выполнен в любом случае, с aload_2 и athrow, бросающим необрабатываемое исключение.
Заключение
Это всего лишь несколько моментов из области байткода JVM. Большинство было почерпнуто из статьи developerWorks Peter Haggar — Java bytecode: Understanding bytecode makes you a better programmer. Статья немного устарела, но до сих пор актуальна. Руководство пользователя BCEL содержит достойное описание основ байт-кода, поэтому я предложил бы почитать его интересующимся. Кроме того, спецификация виртуальной машины также может быть полезным источником информации, но ее нелегко читать, кроме этого отсутствует графический материал, который бывает полезным при понимании.
В целом, я думаю, что понимание того, как работает байт-код, является важным моментом в углублении своих знаний в Java-программировании, особенно для тех, кто присматривается к фреймворкам, компиляторам JVM-языков или другим утилитам.