Привет! Счастливого Пришествия: D Я Саймон Мэйпл ( @sjmaple) , Технический Евангелист для ZeroTurnaround. Вы знаете, ребята JRebel ! В результате мы напишем такой продукт, как JRebel, который взаимодействует с байт-кодом, чаще, чем вы себе представляете. Мы узнали о нем много вещей, которыми хотели бы поделиться.
Давайте начнем с самого начала … Java был языком, предназначенным для работы на виртуальной машине, поэтому его нужно было скомпилировать только один раз, чтобы он везде работал (да, да, один раз напиши, повсюду тестируй). В результате JVM, который вы устанавливаете на свою систему, будет встроенным, что позволит коду, работающему на нем, быть независимым от платформы. Java-байт-код является промежуточным представлением Java-кода, который вы пишете как источник, и является результатом компиляции вашего кода. Таким образом, ваши файлы классов являются байт-кодом.
Чтобы быть более кратким, байт-код Java — это кодовый набор, используемый виртуальной машиной Java, который JIT-компилируется в собственный код во время выполнения.
Вы когда-нибудь играли с ассемблером или машинным кодом? Байт-код в некотором роде похож, но многие люди в отрасли на самом деле не играют с ним больше, больше из-за необходимости отсутствия. Тем не менее, важно понимать, что происходит, и полезно, если вы хотите кого-то обмануть в пабе.
Во-первых, давайте взглянем на некоторые основы байт-кода. Сначала мы возьмем выражение «1 + 2» и посмотрим, как оно выполняется как байт-код Java. 1 + 2 можно записать в обратной польской записи как 1 2 +. Почему? Хорошо, когда мы помещаем это в стек, все становится ясно …
Хорошо, в байт-коде мы фактически увидели бы коды операций (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 |
Теперь это действительно беспокоит тебя, верно? 🙂 Вот что мы получим, если представим тело этого метода в виде массива:
Обратите внимание, что каждая инструкция имеет представление HEX, поэтому, используя это, мы действительно видим это:
На самом деле мы можем увидеть это в файле класса, если откроем его в редакторе HEX:
Мы могли бы на самом деле изменить байт-код здесь, в нашем 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 |
Существует несколько арифметических кодов операций и комбинаций команд типа, которые можно использовать в байт-коде, включая следующие:
А также ряд кодов операций преобразования типов, которые важны при назначении, скажем, целого числа переменной типа long.
В нашем драгоценном примере мы передаем целое число в метод 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 )