Міксіни працюють на основі байт-коду Java, тому, щоб зрозуміти їх, необхідно зрозуміти їх основи.
Щоб дізнатися, як переглянути байт-код класу у вашому IDE, зверніться до розділу перегляду байт-коду на [сторінці «Поради та підказки» (../getting-started/tips-and-tricks).
Назви та символи
Багато речей у байт-коді, як-от класи, поля та методи, все ще ідентифікуються за іменем (і дескриптором для полів і методів, ми дійдемо до цього),, так само як і у вихідному коді. Однак точний формат цих назв дещо відрізняється.
Назви класів
Класи, як правило, називаються їхніми внутрішніми назвами, які приблизно еквівалентні повній назві класу (повній назві, включаючи пакет), де всі крапки . замінені скісними рисками /. Наприклад, внутрішня назва класу java.lang.Object — це java/lang/Object.
Вкладені класи використовують символи $, щоб відокремити свою назву від навколишніх класів. Наприклад, дано:
java
package pkg;
class Foo {
class Bar {
}
}1
2
3
4
5
2
3
4
5
... внутрішня назва Bar буде pkg/Foo$Bar.
Анонімні класи використовують номери замість імен. Наприклад, якби було два анонімних класи в класі Foo з блоку коду вище, їхні внутрішні імена були б pkg/Foo$1 і pkg/Foo$2 відповідно.
Локальні класи (класи, визначені в методі) мають номер, після якого йде їх назва. Наприклад, назва локального класу може виглядати так: pkg/Foo$1Local.
Типи дескрипторів
Коли байт-код повинен посилатися на примітивні типи або масиви, використовуються дескриптори типу. Ось таблиця типів даних і відповідних дескрипторів типів:
| Тип | Дескриптор |
|---|---|
boolean | Z |
byte | B |
char | C |
double | D |
float | F |
int | I |
long | J |
short | S |
void | V |
| Масиви | [ + тип елемента: int[] -> [I |
| Об'єкти | L + внутрішня назва + ;: String -> Ljava/lang/String; |
Поле та метод дескрипторів
У байт-коді поля та методи ідентифікуються поєднанням їх імені та дескриптора. Для полів це дескриптор їхнього типу даних.
З іншого боку, методи отримують своє шляхом комбінування типів параметрів і типу повернення. Наприклад, такий метод:
java
void drawText(int x, int y, String text, int color) {
// ...
}1
2
3
2
3
... маємо дескриптор (IILjava/lang/String;I)V.
Дескриптори для типів параметрів безпосередньо об’єднані разом без роздільників. У цьому випадку є I для int двічі (x і y), потім Ljava/lang/String; для String (text) і ще одне I для останнього int (color).
Конструктори та статичні ініціалізатори
На рівні байт-коду конструктори є ще одним методом: детальні відмінності між ними виходять за рамки цього огляду.
Ім’я методу конструктора – <init> (з кутовими дужками <>), а тип повернення в його дескрипторі — V (void). Усі нестатичні ініціалізації полів після компіляції будуть знайдені всередині методів <init>.
З іншого боку, статичні ініціалізатори (блок static {} у вихідному коді, а також ініціалізатори статичних полів за деякими винятками) також є ще одним методом, який запускається під час завантаження класу: його ім’я <clinit>, а його дескриптор ()V.
Локальні змінні
У вихідному коді локальні змінні ідентифікуються за їх назвою. У байт-коді вони натомість ідентифікуються числом або індексом у таблиці локальних змінних (LVT, ТЛЗ). Параметри методу включені до ТЛЗ, як і об’єкт this в нестатичних методах.
Розглянемо такий метод як приклад:
java
public int getX(int offset) {
int result = this.x + offset;
return result;
}1
2
3
4
2
3
4
bytecode
public getX (I)I
aload 0 // this
getfield x
iload 1 // offset
iadd
istore 2 // result
iload 2 // result
ireturn1
2
3
4
5
6
7
8
9
2
3
4
5
6
7
8
9
У байт-коді this отримує індекс 0, offset — індекс 1, а `result`` — індекс 2.
Статичні методи не мають this у ТЛЗ, тому перший параметр статичних методів безпосередньо отримує індекс 0.
Довгі та подвійні займають 2 індекси в ТЛЗ. Наприклад, у наступному статичному методі:
java
public static double add(double x, double y, double z) {
return x + y + z;
}1
2
3
2
3
bytecode
static add (DDD)D
dload 0 // x
dload 2 // y
dadd
dload 4 // z
dadd
dreturn1
2
3
4
5
6
7
2
3
4
5
6
7
... параметр x отримує індекс 0, параметр y отримує індекс 2, а параметр z отримує індекс 4.
INFO
Ми побачили, що байт-код не потребує назв локальних змінних, оскільки він ідентифікує їх за індексом ТЛЗ. Попри це, багато бібліотек зберігають налагоджувальну інформацію, включаючи імена локальних змінних, щоб полегшити налагодження та дозволити вам націлювати локальні змінні за назвою під час розробки міксинів.
Однак Minecraft 1.21.11 не надає цього усталено і тому називається обфускованим. Зауважте, що майбутні версії Minecraft будуть деобфускованими.
Стек операндів
Подібно до того, як нативна збірка використовує реєстри процесора, байт-код Java використовує стек операндів для зберігання тимчасових значень.
Як і в будь-якому стеку, значення додаються («виштовхуються») до верхньої частини стеку та видаляються («висуваються») з неї. Подумайте про це як про стопку тарілок: коли ви додаєте тарілку на стопку, ви ставите її зверху, а коли вам потрібна, ви берете верхню. Таку структуру даних називають Last-In, First-Out, тому що остання «тарілка», вставлена в стек, буде витягнута першою.
Давайте знову поглянемо на попередній приклад getX:
java
public int getX(int offset) {
int result = this.x + offset;
return result;
}1
2
3
4
2
3
4
bytecode
public getX (I)I
aload 0 // this
getfield x
iload 1 // offset
iadd
istore 2 // result
iload 2 // result
ireturn1
2
3
4
5
6
7
8
9
2
3
4
5
6
7
8
9
Уявімо, що getX(5) викликається, коли this.x має значення 42, і простежмо, що відбувається, інструкція за інструкцією:
| Індекс | Таблиця локальних змінних | Стек операндів |
|---|---|---|
| 2 | ||
| 1 | offset: 5 | |
| 0 | this |
Щоб перейти до інструкцій, натискайте кнопки вище.
Діаграма вище покаже стан таблиці локальних змінних і стека операндів після інструкції.
Зверніть увагу, що слот ТЛЗ 0 містить this: це тому, що getX не є статичним методом.
Умовні інструкції
Ми бачили, як JVM виконує інструкції послідовно, одну за одною. Однак певні інструкції повідомляють JVM перейти до іншої точки байт-коду:
goto: Завжди переходить до інструкції, на яку посилаєтьсяifeq: витягує верхнє значення зі стеку операндів і, якщо воно дорівнює 0, переходить до інструкції, на яку посилаєтьсяifne: витягує верхнє значення зі стеку операндів і, якщо воно не дорівнює 0, переходить до інструкції, на яку посилаєтьсяif_icmpXX: відкриває два верхніх значення стека операндів і порівнює їх. Якщо порівняння вірне, тоді JVM переходить до інструкції, на яку посилається. Наприклад:if_icmpeq(==): Успіх, якщо два значення рівніif_icmpgt(>): Успіх, якщо перше більше за другеif_icmple(<=): Успіх, якщо перше значення менше або дорівнює другому
Наприклад, розглянемо такий метод:
java
static String makeFoobar(boolean cond) {
String result;
if (cond) {
result = "foo";
} else {
result = "bar";
}
return result;
}1
2
3
4
5
6
7
8
9
2
3
4
5
6
7
8
9
bytecode
static makeFoobar (Z)Ljava/lang/String;
iload 0 // cond
ifeq L1
ldc "foo"
astore 1 // result
goto L2
L1
ldc "bar"
astore 1 // result
L2
aload 1 // result
areturn1
2
3
4
5
6
7
8
9
10
11
12
2
3
4
5
6
7
8
9
10
11
12
Зверніть увагу, що мішені для стрибків позначені L*.
Інструкція ifeq порівнює значення у верхній частині стеку операндів (яке є cond через попередню інструкцію iload) з 0 і переходить до L1, якщо воно дорівнює 0 (false).
Якщо він не дорівнює 0, що означає, що cond має значення true, він продовжує виконувати наступні інструкції, поки не дійде до інструкції goto, яка потім пропускає до L2.
Блок if по суті стає рядками від ifeq L1 до L1, а блок else — це L1-L2. Інструкції умовного переходу, що нагадують [goto-era programming] (https://xkcd.com/292/), є тим, як компілюються оператори if, цикли, трійкові тощо.
Компіляція може закінчитися створенням складної логіки, яку не тільки важко прочитати, але й важко націлити за допомогою міксинів. Розглянемо такий класичний приклад:
java
static void doSomething(boolean cond1, boolean cond2) {
if (cond1) {
if (cond2) {
System.out.println("Something is being done");
}
// inject here?
}
}1
2
3
4
5
6
7
8
2
3
4
5
6
7
8
bytecode
static doSomething (ZZ)V
iload 0 // cond1
ifeq L1
iload 1 // cond2
ifeq L1
getstatic System.out
invokevirtual println
L1
return1
2
3
4
5
6
7
8
9
2
3
4
5
6
7
8
9
Оскільки байт-код для обох умов if переходить до точно такої самої мітки, у байт-коді немає місця, що відповідає коментарю // inject here?, тобто потрібно використовувати обхідні шляхи, щоб націлити його за допомогою міксинів.
Загальні шаблони байт-коду
Ось посилання на найпоширеніші інструкції та шаблони байт-коду, з якими ви зіткнетеся під час розробки міксинів. Щоб отримати повний розширений список інструкцій, перегляньте Список інструкцій щодо байт-коду Java у Вікіпедії.
Константи
Константні інструкції надсилають постійне значення в стек операндів.
iconst_m1,iconst_0,iconst_1, ...,iconst_5: цілі літерали від-1до5lconst_0,dconst_1,fconst_2тощо: буквальні числа, якlong,doubleіfloatвідповідноbipush,sipush: надсилає більшу цілу константуldc: може надсилати кілька різних типів констант, включаючи рядки та навіть більші цілі числа
Змінні
Інструкції завантаження зчитують значення з ЛЗТ і надсилають його до стеку операндів.
Інструкції щодо збереження витягують верхнє значення зі стеку операндів і записують його до локальної змінної.
iload,istore: завантажує або зберігає змінні типуint,boolean,byte,charіshortlload,lstore: завантажує або зберігає змінні типуlongfload,fstore: завантажує або зберігає змінні типуfloatdload,dstore: завантажує або зберігає змінні типуdoubleaload,astore: завантажує або зберігає змінні непримітивних типів
Поля
getfield: читає нестатичне полеputfield: записує в нестатичне полеgetstatic: читає статичне полеputstatic: записує в статичне поле
Виклики методів
invokestatic: викликає статичний методinvokevirtual: викликає нестатичний метод. Ураховує поліморфізм і успадкування, викликаючи перевизначену версію, де це можливоinvokespecial: викликає нестатичний метод, саме той, що оголошений, без урахування поліморфізму/наслідування. Використання включає конструктори викликів і методи суперкласуinvokeinterface: викликає нестатичний метод інтерфейсу
Умови
Див. умовні інструкції.
Оператори
Інструкції оператора зазвичай витягують два значення зі стеку операндів, виконують операцію та надсилають результат. Ось список деяких поширених інструкцій оператора:
iadd,ladd,fadd,tadd: додаванняisub,lsub,fsub,dsub: відніманняimul,lmul,fmul,dmul: множенняidiv,ldiv,fdiv,ddiv: діленняirem,lrem,frem,drem: модульineg,lneg,fneg,dneg: від'ємне. Витягує зі стеку лише одне значення
Префікси i, l, f, d, як видно з інструкціями змінної, визначають тип даних, до яких застосовуватиметься оператор.
Повернення
Інструкції повернення закривають виклик методу, повертаючи значення у верхній частині стеку операндів (за винятком методів void).
Якщо перед ним є i, l, f, d і a, як і інструкції змінної, метод повертає значення цього типу. Інструкцією для методів void є просто return.
Створення нового об'єкта
У вихідному коді написання new MyClass() створює новий екземпляр MyClass і викликає його конструктор. У байт-коді ці два кроки стають різними операціями. Візьмемо, наприклад, такий код:
java
static Creeper createCreeper(Level level) {
return new Creeper(level);
}1
2
3
2
3
bytecode
static createCreeper (Lnet/minecraft/world/level/Level;)Lnet/mineraft/world/entity/monster/Creeper;
new net/minecraft/world/entity/monster/Creeper
dup
aload 0 // level
invokespecial net/minecraft/world/entity/monster/Creeper.<init> (Lnet/minecraft/world/level/Level;)V
areturn1
2
3
4
5
6
2
3
4
5
6
Розгляньмо, що відбувається в стеку операндів.
| Індекс | Таблиця локальних змінних | Стек операндів |
|---|---|---|
| 2 | ||
| 1 | ||
| 0 | level |
Слот ТЛЗ 0 містить level. Він не містить this, оскільки метод є статичним.
Лямбди
Лямбда-вирази компілюються в окремий метод, який потім викликається лямбда-екземпляром, екземпляр якого створено інструкцією invokedynamic.
Подробиці інструкції invokedynamic виходять за рамки цього огляду, але корисно знати, якого коду очікувати. Деякі invokedynamic операнди були опущені в цьому розділі для простоти.
Ось приклад:
java
static void hello() {
Runnable r = () -> System.out.println("Hello, World!");
r.run();
}1
2
3
4
2
3
4
bytecode
static hello ()V
invokedynamic run ()Ljava/lang/Runnable; java/lang/invoke/LambdaMetafactory.metafactory ()V lambda$hello$1 ()V
astore 0 // r
aload 0 // r
invokeinterface run
return
static lambda$hello$1 ()V
getstatic System.out
ldc "Hello, World!"
invokevirtual println
return1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
2
3
4
5
6
7
8
9
10
11
12
13
14
15
Тут ви можете побачити, що вміст лямбда-визначення було переміщено в окремий метод, у цьому випадку lambda$hello$1.
Якщо ви хочете націлити вміст лямбда за допомогою міксинів, це метод, на який ви захочете націлитися. Після цього екземпляр лямбда створюється за допомогою інструкції invokedynamic, а потім зберігається в змінній r.
Якщо лямбда фіксує будь-які змінні, ці змінні в кінцевому підсумку стануть параметрами лямбда-методів. Наприклад:
java
static void hello(String name) {
Runnable r = () -> System.out.println("Hello, " + name + "!");
r.run();
}1
2
3
4
2
3
4
bytecode
static hello (Ljava/lang/String;)V
aload 0 // name
invokedynamic run (Ljava/lang/String;)Ljava/lang/Runnable; java/lang/invoke/LambdaMetafactory.metafactory ()V lambda$hello$1 (Ljava/lang/String;)V ()V
astore 1 // r
aload 1 // r
invokeinterface run
return
static lambda$hello$1 (Ljava/lang/String;)V
getstatic System.out
aload 0 // name
invokedynamic makeConcatWithConstants (Ljava/lang/String;)Ljava/lang/String; java/lang/invoke/StringConcatFactory.makeConcatWithConstants "Hello, \1!"
invokevirtual println
return1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
Тут параметр name передається як параметр у лямбду. Зверніть також увагу на те, як конкатенація рядків реалізована за допомогою invokedynamic.


