Статьи

Овладение байт-кодом Java

Привет! Счастливого Пришествия: D Я Саймон Мэйпл ( @sjmaple) , Технический Евангелист для ZeroTurnaround. Вы знаете, ребята JRebel ! В результате мы напишем такой продукт, как JRebel, который взаимодействует с байт-кодом, чаще, чем вы себе представляете. Мы узнали о нем много вещей, которыми хотели бы поделиться.

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

Чтобы быть более кратким, байт-код Java – это кодовый набор, используемый виртуальной машиной Java, который JIT-компилируется в собственный код во время выполнения.

Вы когда-нибудь играли с ассемблером или машинным кодом? Байт-код в некотором роде похож, но многие люди в отрасли на самом деле не играют с ним больше, больше из-за необходимости отсутствия. Тем не менее, важно понимать, что происходит, и полезно, если вы хотите кого-то обмануть в пабе.

Во-первых, давайте взглянем на некоторые основы байт-кода. Сначала мы возьмем выражение «1 + 2» и посмотрим, как оно выполняется как байт-код Java. 1 + 2 можно записать в обратной польской записи как 1 2 +. Почему? Хорошо, когда мы помещаем это в стек, все становится ясно …

1

Хорошо, в байт-коде мы фактически увидели бы коды операций (iconst_1 и iconst_2) и инструкцию (iadd), а не push и add, но последовательность действий та же. Фактические инструкции имеют длину один байт, следовательно, байт-код. В результате получается 256 возможных кодов операций, но только 200 или около того используются. К кодам операций добавляется префикс с типом, за которым следует имя операции. Итак, то, что мы видели ранее с iconst и iadd, это константы целочисленного типа и инструкция добавления для целочисленных типов.

Это все очень хорошо, но как насчет чтения файлов классов. Как правило, все, что вы обычно видите в файле класса при открытии в выбранном вами редакторе, это набор смайликов и несколько квадратов, точек и других странных символов, верно? Ответ в javap, утилите кода, которую вы фактически получаете с вашим JDK. Давайте посмотрим на пример кода, чтобы увидеть javap в действии.

1
2
3
4
5
6
7
8
9
public class Main {
 
    public static void main(String[] args){
 
        MovingAverage app = new MovingAverage();
 
    }
 
}

Как только этот класс скомпилирован в файл Main.class, мы можем использовать следующую команду для извлечения байт-кода: javap -c Main

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
Compiled from "Main.java"
 
public class algo.Main {
  public algo.Main();
       Code:
       0: aload_0
       1: invokespecial #1
       4: return
// Method java/lang/Object."<init>":()V
public static void main(java.lang.String[]);
     Code:
       0: new           #2
       3: dup
       4: invokespecial #3
       7: astore_1
      8: return
}

Мы можем видеть, что у нас есть конструктор по умолчанию и метод main в байтовом коде сразу. Кстати, именно так Java дает вам конструктор по умолчанию для классов без конструктора! Байт-код в конструкторе – это просто вызов super (), в то время как наш метод main создает новый экземпляр MovingAverage и возвращает его. Символы #n на самом деле ссылаются на константы, которые мы можем просматривать с помощью аргумента -verbose следующим образом: javap -c -verbose Main. Интересная часть того, что возвращается, показана ниже:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
public class algo.Main
 
  SourceFile: "Main.java"
 
  minor version: 0
 
  major version: 51
 
  flags: ACC_PUBLIC, ACC_SUPER
 
Constant pool:
 
   #1 = Methodref    #5.#21         //  java/lang/Object."<init>":()V
 
   #2 = Class        #22            //  algo/MovingAverage
 
   #3 = Methodref    #2.#21         //  algo/MovingAverage."<init>":()V
 
   #4 = Class        #23            //  algo/Main
 
   #5 = Class        #24            //  java/lang/Object

Теперь мы можем сопоставить наши инструкции с нашими константами и собрать воедино то, что на самом деле происходит гораздо проще. Что-то еще беспокоит вас по поводу примера выше? Нет? Как насчет номеров перед каждой инструкцией …

1
2
3
4
5
6
7
8
9
       0: new           #2
 
       3: dup
 
       4: invokespecial #3
 
       7: astore_1
 
       8: return

Теперь это действительно беспокоит тебя, верно? 🙂 Вот что мы получим, если представим тело этого метода в виде массива:

2

Обратите внимание, что каждая инструкция имеет представление HEX, поэтому, используя это, мы действительно видим это:

3

На самом деле мы можем увидеть это в файле класса, если откроем его в редакторе HEX:

4

Мы могли бы на самом деле изменить байт-код здесь, в нашем HEX-редакторе, но, честно говоря, это не то, что вы действительно хотели бы сделать, особенно в пятницу днем ​​после обязательной поездки в паб. Лучшими способами сделать это было бы использование ASM или javassist.

Давайте перейдем от нашего основного примера и добавим некоторые локальные переменные, которые хранят состояние и взаимодействуют напрямую с нашим стеком. Проверьте следующий код:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
public static void main(String[] args) {
 
  MovingAverage ma = new MovingAverage();
 
 
 
  int num1 = 1;
 
  int num2 = 2;
 
 
 
  ma.submit(num1);
 
  ma.submit(num2);
 
 
 
  double avg = ma.getAvg();
 
}

Давайте посмотрим, что мы получим в этот раз в нашем байт-коде:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
Code:
0: new  #2    // class algo/MovingAverage
 
3: dup
 
4: invokespecial #3  // Method algo/MovingAverage."<init>":()V
 
7: astore_1
 
8: iconst_1
 
9: istore_2
 
10: iconst_2
 
11: istore_3
 
12: aload_1
 
13: iload_2
 
14: i2d
 
15: invokevirtual #4        // Method algo/MovingAverage.submit:(D)V
 
18: aload_1
 
19: iload_3
 
20: i2d
 
21: invokevirtual #4        // Method algo/MovingAverage.submit:(D)V
 
24: aload_1
 
25: invokevirtual #5        // Method algo/MovingAverage.getAvg:()D
 
28: dstore     4
 
 
 
LocalVariableTable:
 
Start  Length  Slot  Name   Signature
 
 
 
0       31         0    args   [Ljava/lang/String;
 
8       23        1      ma     Lalgo/MovingAverage;
 
10      21         2     num1   I
 
12       19         3      num2   I
 
30       1        4    avg     D

Это выглядит намного интереснее … Мы видим, что мы создаем объект типа MovingAverage, который хранится в локальной переменной ma с помощью инструкции astore_1 (1 – это номер слота в LocalVariableTable). Инструкции iconst_1 и iconst_2 предназначены для загрузки констант 1 и 2 в стек и сохранения их в слотах LocalVariableTable 2 и 3 соответственно с помощью инструкций istore_2 и istore_3. Инструкция загрузки помещает локальную переменную в стек, а инструкция сохранения извлекает следующий элемент из стека и сохраняет его в LocalVariableTable. Важно понимать, что когда используется инструкция сохранения, элемент извлекается из стека, и если вы хотите использовать его снова, вам необходимо загрузить его.

Как насчет потока выполнения? Все, что мы видели, – это простое продвижение от одной строки к другой. Я хочу увидеть немного GOTO 10 в базовом стиле! Давайте возьмем другой пример:

1
2
3
4
5
6
7
MovingAverage ma = new MovingAverage();
 
for (int number : numbers) {
 
    ma.submit(number);
 
}

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

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
0: new #2 // class algo/MovingAverage
 
3: dup
 
4: invokespecial #3 // Method algo/MovingAverage."<init>":()V
 
7: astore_1
 
8: getstatic #4 // Field numbers:[I
 
11: astore_2
 
12: aload_2
 
13: arraylength
 
14: istore_3
 
15: iconst_0
 
16: istore 4
 
18: iload 4
 
20: iload_3
 
21: if_icmpge 43
 
24: aload_2
 
25: iload 4
 
27: iaload
 
28: istore 5
 
30: aload_1
 
31: iload 5
 
33: i2d
 
34: invokevirtual #5 // Method algo/MovingAverage.submit:(D)V
 
37: iinc 4, 1
 
40: goto 18
 
43: return
 
 
 
LocalVariableTable:
 
Start  Length  Slot  Name   Signature
 
30       7         5    number I
 
12       31        2    arr$     [I
 
15       28        3    len     $I
 
18       25         4     i$      I
 
0       49         0     args  [Ljava/lang/String;
 
8       41         1    ma     Lalgo/MovingAverage;
 
48      1         2    avg    D

Инструкции с позиций с 8 по 17 используются для настройки цикла. В таблице LocalVariable есть три переменные, которые на самом деле не упомянуты в источнике: arr $, len $ и i $. Это переменные цикла. arr $ хранит эталонное значение поля чисел, из которого определяется длина цикла len $. i $ – счетчик цикла, который увеличивается с помощью инструкции iinc.

Сначала нам нужно проверить наше выражение цикла, которое выполняется инструкцией сравнения:

1
2
3
4
5
18: iload 4
 
20: iload_3
 
21: if_icmpge 43

Мы загружаем 4 и 4 в стек, которые являются счетчиком цикла и длиной цикла. Мы проверяем, что id i $ больше или равно len $. Если это так, мы переходим к утверждению 43, в противном случае мы продолжаем. Затем мы можем выполнить нашу логику в цикле и в конце мы увеличиваем наш счетчик и возвращаемся к нашему коду, который проверяет условие цикла в операторе 18.

1
2
3
37: iinc       4, 1       // increment i$
 
40: goto       18         // jump back to the beginning of the loop

Существует несколько арифметических кодов операций и комбинаций команд типа, которые можно использовать в байт-коде, включая следующие:

5

А также ряд кодов операций преобразования типов, которые важны при назначении, скажем, целого числа переменной типа long.

6

В нашем драгоценном примере мы передаем целое число в метод submit, который принимает удвоение. Синтаксис Java делает это для нас, но в байт-коде вы увидите, что используется код операции i2d:

1
2
3
4
5
31: iload 5
 
33: i2d

 
34: invokevirtual #5 // Method algo/MovingAverage.submit:(D)V

Итак, вы сделали это так далеко. Молодец, ты заработал кофе! Что-нибудь из этого на самом деле полезно знать, или это просто чудак? Ну, это оба! Во-первых, теперь вы можете сказать своим друзьям, что вы JVM, который может обрабатывать байт-код, а во-вторых, вы можете лучше понять, что вы делаете, когда пишете байт-код. Например, при использовании ObjectWeb ASM, который является одним из наиболее широко используемых инструментов манипулирования байт-кодом, вы обнаружите, что создаете инструкции, и эти знания окажутся бесценными!

Если вы нашли это интересным и хотите узнать больше, то ознакомьтесь с нашим бесплатным отчетом по освоению байт-кода Java от Антон Архипова, руководителя по продукту JRebel на ZeroTurnaround. (JRebel использует javassist, и нам было очень интересно учиться и взаимодействовать с байт-кодом Java!) Этот отчет углубляется и затрагивает, как использовать ASM.

Спасибо за чтение! Дайте мне знать, что вы думали! ( @sjmaple )

Справка: освоение Java-байт-кода от нашего партнера по JCG Аттилы Михали Балаза в блоге Java Advent Calendar .