[Перевод] Манипуляции с байт-кодом Java
В этой статье мы разберём, как добавить к файлу класса публичный атрибут. Когда загрузчик классов завершит загрузку модифицированного файла класса, мы увидим то поле, которое добавили вручную.
После того, как компилятор Java обработает наш код, будет сгенерирован файл класса. В этом файле будут содержаться инструкции, записанные байт-кодом так, как это определено в спецификации Java. Тем не менее, это всего один физический файл. Его придётся загрузить в память и разобрать, а затем на его основе будет сконструирован объект Class. Здесь мы действуем точно как при синтаксическом разборе XML-файла, но сначала нам потребуется определить, какие узлы разрешены в этом XML-файле. Далее парсер сможет разобрать файл, ориентируясь на заранее определённые узлы. С файлом класса Java всё обстоит точно так же. Структура файла класса заранее определена в Oracle. Парсер должен «понимать» структуру файла и выполнять с ним конкретные действия. Чтение и синтаксический разбор этого файла выполняет ClassLoader. После того, как ClassLoader загрузит класс, один объект Class помещается в кучу.
Прежде, чем обсудить структуру файла класса, давайте сначала немного поговорим о наборах инструкций.
Инструкции байт-кода похожи на ассемблерный код в том, что эти команды загружают значения из определённых участков памяти. Далее над этими значениями производятся операции, и их результаты записываются обратно в память по заданным адресам. На высокоуровневых языках мы всегда работаем с символами. Символ может представлять собой имя метода, имя переменной, т. д. Сам символ — это просто представление некоторого места в памяти, абсолютное или относительное.
Например, следующая инструкция может быть написана на любом языке.
fun calculate {
int i = 4
}
i — это символ. Он представляет местоположение в памяти. Данная инструкция приказывает записать число 4 по тому адресу в памяти, который представлен через i. Прежде, чем что-либо может быть записано по адресу в памяти, компьютеру требуется зарезервировать место для этой информации. В данном случае размер указывается при помощи int.
Ещё один важный факт: программа всегда выполняется в одном потоке или в нескольких потоках. Для каждого потока есть соответствующая структура Stack (стек), в которой хранятся состояния среды выполнения актуального потока. В этом стеке можно хранить локальные переменные вызываемых функций. Ещё одна область памяти — это куча. Куча используется для хранения глобальных выделенных объектов. Такая модель памяти используется не только в Java, она также существует и в C++. В большей или меньшей степени то же касается и других языков.
Модель памяти Java
Виртуальная машина Java (JVM) создаёт по одному кадру для каждого вызова функции. Все локальные переменные хранятся в одном кадре. Можно трактовать кадр просто как единый массив локальных переменных. Так, наш int i = 4 просто сохраняет число 4 в крайнее местоположение в массиве. Целесообразно иметь инструкции для выполнения этой функции. Действительно, есть функции, выполняющие такие операции в соответствии со списком инструкций, приведённым здесь docs.oracle.com/javase/specs/jvms/se7/html/jvms-6.html.
Чтобы просмотреть, как это происходит, я создал простой тестовый класс TestClass.
class TestClass(val name: String) {
fun testMethod() {
val i: Int = 3
print(i)
}
}
Чтобы просмотреть байт-код этой функции, можно воспользоваться javap -c TestClass.class.
iconst_3: продвинуть константу 3 в стек операндов.
Команда push — в стек операндов
istore_1: вытолкнуть целое число из стека операндов и сохранить целое число по индексу 1 в массиве локальных переменных актуального кадра.
Команда pop — из стека операндов
❯ Стек операндов
Почему нам требуется стек операндов? Как было сказано выше, сначала такие инструкции должны загрузить данные из памяти, проделать над ними операции и записать результат этих операций обратно в память. Где же JVM хранит данные, загруженные из памяти?
В ЦП компьютера есть регистры. Значение переменной сначала загружается в регистры ЦП, и в регистрах производятся расчёты. Затем результат расчётов извлекается из регистра и записывается обратно в память. Я считаю, что JVM позаимствовала такой дизайн прямо из самого ЦП.
Почему JVM не загружает данные непосредственно в регистры ЦП? Потому что инструкции JVM — это не машинный код. Чтобы можно было работать с регистрами, инструкции сначала нужно преобразовать в машинный код. Эту задачу выполняет JIT.
❯ Как в JVM представлено создание нового экземпляра объекта?
class Foo(val name: String) {
fun foo() {
print("foo")
}
}
class TestClass(val name: String) {
fun testMethod() {
val foo: Foo = Foo("yogi") //creating instance of class
}
}
Один экземпляр создаётся в три этапа:
- Загружается целевой класс;
- В куче выделяется память для экземпляра класса;
- Вызывается функция конструктора.
Рассмотрим сгенерированный байт-код.
Первая инструкция — это new #8. Число 8 соответствует одному индексу в таблице пула констант.
❯ Пул констант
Пул констант — это структура, относящаяся к времени исполнения и создаваемая JVM после загрузки файла класса. В ней содержатся все символьные ссылки, которые использовались в исходном классе.
При помощи javap -v TestClass.class можно просмотреть в необработанном виде содержимое пула констант в файле класса (следующий вывод как раз интерпретируется javap).
После того, как загрузчик классов прочитает наш файл TestClass.class, JVM создаст одну таблицу пула констант.
Каждая запись в таблице пула констант — это структура переменной величины. Каждая запись может представлять различные типы констант. Тип константы в данном случае представлен первым байтом, который называется «тег». На этой странице docs.oracle.com/javase/specs/jvms/se7/html/jvms-4.html#jvms-4.4.1 перечислены все типы констант. В представленной нами картине перечислены два типа констант.
CONSTANT_Class_info {
u1 tag;
u2 name_index;
}CONSTANT_Utf8_info {
u1 tag;
u2 length;
u1 bytes[length];
}
name_index в CONSTANT_Class_info — это число, соответствующее индексному номеру в пуле констант. Запись по адресу constant_pool[name_index] относится к типу CONSTANT_Utf8_info, и здесь в нашем случае содержится имя нашего класса «com/qiusuo/Foo».
После того, как загрузчик классов сконструирует пул констант, он разрешит ссылки на методы и на классы. В нашем случае Classloader разрешит класс «com/qiusuo/Foo». Он загрузит Foo.class из пути классов и сконструирует в куче объект Class. Он заменит символьную ссылку «com/qiusuo/Foo» конкретным адресом в памяти, по которому располагается сконструированный объект, класс Foo.
На самом деле, здесь может применяться жадная загрузка и ленивая загрузка. Classloader способен во время выполнения загрузить ссылки на классы и на методы.
Ссылки на методы содержатся в соответствующей ссылочной структуре.
CONSTANT_Methodref_info {
u1 tag;
u2 class_index;
u2 name_and_type_index;
}
Мы не будем подробно разбирать вышеприведённую структуру, так как она похожа на CONSTANT_Class_info.
Когда ссылки на методы разрешаются и преобразуются в расположенные в памяти адреса, тогда инструкции JVM, например, invokeSpecial #14 могут вызывать функцию конструктора класса Foo.
Вот другие важные структуры, содержащиеся в файле класса:
field_info fields[fields_count];
method_info methods[methods_count];
В структурах field_info и method_info содержится информация о полях и методах из загружаемого класса.
❯ Манипуляции с байт-кодом
Теперь, рассмотрев формат файла класса, понимаем, что манипуляции с байт-кодом — это просто изменение содержимого в различных разделах файла класса после его прочтения. Для экспериментов воспользуемся библиотекой ASM, так как она используется и в Spring. Наша цель — добавить простой атрибут public int test = 0 к уже имеющемуся у нас классу TestClass.
class TestClass(val name: String) {
fun testMethod() {
val foo: Foo = Foo("yogi") // создаём экземпляр класса
}
public var test: Int = 0 // будет добавлено ASM
}
Поскольку информация о полях представлена в структуре field_info в файле класса, библиотеке ASM требуется просто добавить ещё одну field_info в файл класса.
field_info {
u2 access_flags;
u2 name_index;
u2 descriptor_index;
u2 attributes_count;
attribute_info attributes[attributes_count];
}
access_flags: например, public (публичный), private (приватный) или protected (защищённый).
name_index: подобен name_index в CONSTANT_Class_info.
attribute_info: содержит типы, аннотации, информацию о дженериках, константные значения поля.
descriptor_index: индекс из пула констант, данная запись представляет тип поля.
Подробно об этой структуре рассказано здесь: docs.oracle.com/javase/specs/jvms/se7/html/jvms-4.html#jvms-4.5
❯ Интерфейсы ASM
- ClassReader — отвечает за считывание содержимого из файла класса. Он вызовет ClassVisitor, который посетит каждый раздел в файле класса.
- ClassVisitor —сам класс, вызываемый из различных разделов файла класса
- ClassWriter — записыватель классов, фактически, расширяющий ClassVisitor. Функция visitField из ClassWriter сначала проверяет, существует ли поле. Если поле не существует, то это поле нужно создать в структуре файла класса.
Наш CustomFieldAdder:
class CustomFieldAdder(val access: Int, val name: String, val fieldType: String, val signature: String?, val value: Any, val cv: ClassVisitor, val api: Int): ClassVisitor(api, cv) {
var isFieldPresent = false override fun visitField(access: Int, name: String?, desc: String?, signature: String?, value: Any?): FieldVisitor? {
if(name.equals(this.name)) {
isFieldPresent = true
}
return cv.visitField(access, name, desc, signature, value)
}
override fun visitEnd() {
if (!isFieldPresent) {
val fv = cv.visitField(access, name, fieldType, null, value) //cv is the ClassWriter
fv?.visitEnd()
}
cv.visitEnd()
}
}
Тестовый класс:
class TestClassWriter: ClassLoader() {
fun run() {
val className = "com.qiusuo.bytecode.TestClass"
val constValue = 4
val accessType = org.objectweb.asm.Opcodes.ACC_PUBLIC
val name = "test"
val fieldType = Type.INT_TYPE.toString()
val reader = ClassReader(className)
val writer = ClassWriter(reader, 0)
val fieldAdder = CustomFieldAdder(accessType, name, fieldType, null, constValue, writer, Opcodes.ASM7)
reader.accept(fieldAdder, 0)
val modified = writer.toByteArray()
val modifiedClass: Class<*> = defineClass(className, modified, 0, modified.size)
val instance = modifiedClass.getDeclaredConstructor().newInstance()
val value = modifiedClass.getDeclaredField("test").get(instance)
println(value)
}
}
Примечание: я установил начальное значение в 4. Но в консоль всё равно выводится 0. То есть, добавленный атрибут не инициализируется в 4. Почему — я не знаю.
Исходный код ко всем экспериментам находится здесь:
https://github.com/ryan-zheng-teki/springboottutorial/blob/master/springcoretutorial/src/main/kotlin/com/qiusuo/bytecode/CustomFieldAdder.kt