Иконка ресурса

Прекрасный мир байт-кода

Версия(и) Minecraft
all
Предисловие
Етак, добрый вечер. Данная статья преследует цель в общих чертах дать вам широкое представление о том как работать с байт-кодом JVM при помощи библиотеки ASM.
Эта статья не является гайдом для новичков, для понимания того что тут происходит вам нужно написать хотя бы один простейший трансформер.

В этой статье я не буду приводить примеры кода, но буду довольно часто неявным образом описывать архитектуру ASM. Когда я буду говорить, что A является частью B или A лежи в B, я буду иметь ввиду, что при помощи визитора A вы можете получить B. Например имя является частью класса означает, что при помощи ClassVisitor мы можем узнать имя класса.

Каждая часть этой статьи независима друг от друга, так что можете пропускать любую, если она кажется вам не интересной.

Немного приводим мысли в порядок
Для начала давайте начнем говорить на одном языке. В этом параграфе составим общее представление что есть что.

Вот тут по верхам объяснена структура файла класса. Все вродь как понятно пулы, методы, поля, но есть такая новая штука как атрибуты класса. Атрибуты класса - это очень мощная штука, которая дает крайне вкусные возможности по части анализа классов. Прочитать можно тут. ASM подает нам их в самых неожиданых местах неявным образом. Я буду постоянно явным образом делать референсы на них, чтобы вы проводили для себя праллели и могли в будущем быстрее находить нужную информацию.

Так вот поехали.
Класс - это имя, родитель, интерфейсы, сигнатура, методы и поля

Родитель - это другой класс от которого наследует наш класс. Он есть у всех классов кроме Object и module-info

Сигнатура - это строка с информацией о генериках класса или метода . Тут указаны границы генериков или, если вы наследуете от класса с генериком, генерик с которым вы наследуете. Типа class MyAwesomeList extends AbstractList<Fruit>. В общем оч классная штука. Никогда не пытайтесь парсить ее самостоятельно. Используйте SignatureReader. Кстати сигнатура это один из атрибутов класса.

Метод - это имя, дескриптор, тело, дефолтное значение и сигнатура

Дескриптор - описывает тип метода или поля. Структура довольно сложная. С ней можно ознакомится тут.

Тело метода - набор опкодов и их параметров. Ну и таблица try-catch блоков. Кстати это тоже атрибут класса.

Дефолтное значение - это интересная штука которая объединяет в себе значения из двух атрибутов. Атрибут значений констант и Атрибут дефолтных значений аннотации. То есть эта штука появляется как у полей так и у методов, но означает совершенно разные вещи.

Поле - имя, дескриптор и дефолтное значение.

Аннотации
Аннотации появились не так давно и по этой причине очень дружно попали в атрибуты класса. Как опытные жава программисты вы, вероятно, отлично знаете как и где их можно применять. Мы же тут поговорим как они мапяться на байт-код.

Аннотации есть двух категорий:
  1. Обычные аннотации
  2. Аннотации типов (А вот про них вы, вероятно, не в курсе. Загуглите Type Annotations)
И они есть двух типов:
  1. Видимые - можно достать рефлексией
  2. Невидимые - нельзя достать рефлексией
Итого имеем комбинаторный взрыв
Обычные Видимые - атрибут
Обычные Невидимые - атрибут
Типовые Видимые - атрибут
Типовые Невидимые - атрибут

Помимо этого завели отдельный атрибут для аннотаций, которые навешиваются на параметры метода. И они конечно тоже есть двух типов.

Аннотации параметров Видимые - атрибут
Аннотации параметров Невидимые - атрибут

ASM выдает обычные аннотации как часть классов, методов, полей и параметров. То есть он опять неявно объединил атрибут и сущности класса.

ASM выдает аннотации типов как обычные аннотации но с путем до типа. В целом пожелаю вам удачи если вам когда-нибудь понадобится с этим разбираться.

Из интересного про аннотации:
  • При использовании TreeApi выдает их значение в виде одного листа у которого каждый 2n элемент это имя параметра а 2n + 1 его значение.
  • Значения в аннотации всегда в ну очень непредсказуемом формате и там нужно опытным путем определять что во что вырождается.
  • Если вы прочитаете аннотацию класса в ней не будет дефолтов этой аннотации. Вам придется доставать байт-код класса аннотации и читать дефолтные значения оттуда.

Оптимизации жавы или когда ломается ASM
Начнем издалека.
max_stack и max_locals
max_stack и max_locals - это 2 переменные метода и являются частью метода.

max_stack - максимальный размер стека в любой момент времени исполнения функции

max_locals - максимальное колличество локальных переменных в любой момент времени исполнения функции

Обе эти переменные изменяются через visitMaxs.

Вам не обязательно высчитывать их самостоятельно. Достаточно просто передать флаг COMPUTE_MAXS в ClassWriter и ASM сам все высчитает. Точнее когда вы вызовете visitMaxs он проигнорит переданные параметры и запишет, то что сам посчитает.

Забавность max_stack и max_locals заключается в том, что даже если вы просто возьмете и вызовете visitMaxs(999, 999) оно нормально сработает и ничего не сломается и даже если visitMaxs(0, 0) тоже все будет окей, но от такого подхода ломаются обфускаторы.

Но нас конечно же все это не волнует до тех пор пока COMPUTE_MAXS не ломается. А ломается он только в одном случае: когда вы наложали в опкодах в методе. К сожалению других вариантов нет и вам нужно смирится, что дело в вас.
Начинайте просматривать каждую строчку вашего кода и сверяться с этой табличкой. Скорее всего у вас где то ошибка типов или количество переменных на стеке ушло в минус.
Пожалуйста не пытайтесь отключать COMPUTE_MAXS и сувать visitMaxs(999, 999). Оно, конечно, поможет просто записать класс но в рантайме упадет.

stack_map
Сказать честно я очень мало знаю про эту штуку, но я знаю, что она может ломать.

Опять же stack_map - это атрибут.

stack_map - это довольно сложная штука, и я ни разу не генерировал ее ручками. Я всегда передавал флаг COMPUTE_FRAMES в ClassWriter, чего и вам советую. С этим подходом есть несколько подводных камней:
  1. COMPUTE_FRAMES ломается с очень не красивыми ошибками, но по тем же причинам что и COMPUTE_MAXS.
  2. Для создания stack_map ASM'у необходима возможность узнать общего предка двух классов. Эта возможность реализована в ClassWriter#getCommonSuperClass и работает она при помощи рефлексии.

Из-за устройства ClassWriter#getCommonSuperClass также возникают несколько проблем:
  1. Из-за того что рефлексия загружает классы могут возникнуть дед локи.
  2. Если класса нет в класс пазе будет выброшено исключение
Я как то раз решил, что справедливо будет утверждать, что java/lang/Object является родителем для всех и просто переопределил ClassWriter#getCommonSuperClass так чтоб он всегда возвращал java/lang/Object.
Так вот хочу предостеречь таких же мечтателей. Так нельзя поверьте на слово. Слишком долго объяснять почему. Просто попробуйте потом теренарные операторы с аперкастом.

INVOKE_DYNAMIC
Есть такой очень страшный опкод INVOKE_DYNAMIC.

В Java 8 он применяется только для лямбд, а в Java 9+ еще и для конкатенации строк.
Мы будем говорить только про лямбды, про строки я задал вопрос на стэке и там накидали референсов.

У INVOKE_DYNAMIC есть несколько параметров
  1. String: name
  2. String: desc
  3. Handle: bootstrap method handle
  4. Object[]: bootstrap method args
Этот опкод делался для языков с динамической типизацией для уменьшения кол-ва рефлексии. То есть для случая когда например в груви
Gradle (Groovy):
def function(Object value) {
    value.add(5)
}
такой код считается валидным и в рантайме он попытаеся найти метод add рефлексией, но так как это медленно придумали INVOKE_DYNAMIC.

Так вот, изначальная идея работы INVOKE_DYNAMIC такая:
  1. Вызывается метод обозначенный в bootstrap method handle с параметрами (name, desc, args). Этот метод, кстати, обычный жава метод в класспазе. Никакой магии.
  2. Этот метод исходя из переданных аргументов возвращает нам CallSite
  3. JVM запоминает его и вызывает метод, обозначенный в нем.
  4. На следующем разе JVM использует уже созданный CallSite
И вот на этом моменте у любого нормального человека возникнет вопрос: а при чем тут лямбды?
Дело в том, что Oracle решили генерировать лямбды в рантайме. То есть многие думают, что
Java:
Runnable r = () -> {};
является не более чем синтаксическим сахаром над
Java:
Runnable r = new Runnable() {
    public void run() {
   
    }
};

Но на самом деле первый случай, внезапно, породит следующий байт-код
Код:
INVOKEDYNAMIC run()Ljava/lang/Runnable; [
      // handle kind 0x6 : INVOKESTATIC
      java/lang/invoke/LambdaMetafactory.metafactory(Ljava/lang/invoke/MethodHandles$Lookup;Ljava/lang/String;Ljava/lang/invoke/MethodType;Ljava/lang/invoke/MethodType;Ljava/lang/invoke/MethodHandle;Ljava/lang/invoke/MethodType;)Ljava/lang/invoke/CallSite;
      // arguments:
      ()V,
      // handle kind 0x6 : INVOKESTATIC
      test/Main.lambda$main$0()V,
      ()V
    ]

Собственно если это перевести в реалии (name, desc, args) и bootstrap method handle получится
name = "run"
desc = "()Ljava/lang/Runnable;"
bootstrap method handle = "java/lang/invoke/LambdaMetafactory.metafactory"
args = ("()V", "test/Main.lambda[imath]main[/imath]0()V", "()V")

Вот и, если не вдаваться в подробности, LambdaMetafactory.metafactory сделает следующее:
  • Достанет тип из возвращаемое значение из desc. Это тип нашей лямбды. Параметры тут будут только если лямбда захватила какую то переменную в себя.
  • Возьмет второй параметр из args. Это метод, который является телом нашей лямбды.
  • Сгененрирует в рантайме класс и вернет CallSite, который указывает на его конструктор.
Более подробно вы можете почитать в доках LambdaMetafactory.metafactory. Но в целом как можете видеть это довольно безобидная аанотация и можно даже придумать ей применение


Двойные параметры
Есть одна интересная штука с числами в байткоде. Если конкретней у long и double

Если например в статичном методе с дескриптором (II)V первую переменную можно достать с помощью ILOAD 0, а вторую при помощи ILOAD 1(напоминаю что I это int), то вот в методе с дескриптором (JJ)V(j это long) LLOAD 1 выкинет ошибку, а вот LLOAD 2 нет и отработает так как должен был бы LLOAD 1.


P.S.
У меня еще есть много чего рассказать, но так как только на это я потратил 4 часа, мне интересно услышать ваше мнение. Нужна ли вам эта информация и понятен ли такой стиль повествования?
Автор
JustAGod
Просмотры
2,273
Первый выпуск
Обновление
Оценка
5.00 звёзд 3 оценок

Другие ресурсы пользователя JustAGod

Последние рецензии

Это чудесно. Когда продолжение?
JustAGod
JustAGod
а что продолжать то?
Прекрасный теоретический материал!
Дал почитать бабушке, сказала что очень хорошо написано и вообще классный гайд, но она ни слова не поняла. Ставлю 5
Сверху