Как стать автором
Обновить

Java Bytecode Fundamentals

Время на прочтение 6 мин
Количество просмотров 62K
Автор оригинала: Anton Arhipov
Разработчики приложений на Java обычно не нуждаются в знании о байт-коде, выполняющемся в виртуальной машине, однако тем, кто занимается разработкой современных фреймворков, компиляторов или даже инструментов Java может понадобиться понимание байт-кода и, возможно, даже понимание того, как его использовать в своих целях. Несмотря на то, что специальные библиотеки типа ASM, cglib, Javassist помогают в использовании байт-кода, необходимо понимание основ для того, чтобы использовать эти библиотеки эффективно.
В статье описаны самые основы, от которых можно отталкиваться в дальнейшем раскапывании данной темы (прим. пер.).

Давайте начнём с простого примера, а именно 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-редакторе:

image

Каждая пара шестнадцатеричных чисел (байт) переводится в опкоды (мнемоника). Было бы жестоко попытаться прочитать это в двоичном формате. Давайте перейдем к мнемоничному представлению.

Команда 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-стек, содержащий фреймы. Например, если мы запустим приложение в дебаггере, то увидим следующие фреймы:
image

При каждом вызове метода создается новый фрейм. Фрейм состоит из стека операнда, массива локальных переменных и ссылку на пул констант класса выполняемого метода.
image

Размер массива локальных переменных определяется во время компиляции в зависимости от количества и размера локальных переменных и параметров метода. Стек операндов — 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-редакторе, вы увидите в байткод-массиве следующие значения:

image

Так, байткод для метода 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-языков или другим утилитам.
Теги:
Хабы:
+53
Комментарии 9
Комментарии Комментарии 9

Публикации

Истории

Работа

Java разработчик
359 вакансий

Ближайшие события

Московский туристический хакатон
Дата 23 марта – 7 апреля
Место
Москва Онлайн
Геймтон «DatsEdenSpace» от DatsTeam
Дата 5 – 6 апреля
Время 17:00 – 20:00
Место
Онлайн