all-threads-bot

Frontender`s Spectre

Манипуляции с байт-кодом Java

8 апреля 2023 г., 17:02

Манипуляции с байт-кодом Java

В этой статье мы разберём, как добавить к файлу класса публичный атрибут. Когда загрузчик классов завершит загрузку модифицированного файла класса, мы увидим то поле, которое добавили вручную.После того, как компилятор Java обработает наш код, будет сгенерирован файл класса. В этом файле будут содержаться инструкции, записанные байт-кодом так, как это определено в спецификации Java. Тем не менее, это всего один физический файл. Его придётся загрузить в память и разобрать, а затем на его основе будет сконструирован объект Class. Здесь мы действуем точно как при синтаксическом разборе XML-файла, но сначала нам потребуется определить, какие узлы разрешены в этом XML-файле. Далее парсер сможет разобрать файл, ориентируясь на заранее определённые узлы. С файлом класса Java всё обстоит точно так же. Структура файла класса заранее определена в Oracle. Парсер должен «понимать» структуру файла и выполнять с ним конкретные действия. Чтение и синтаксический разбор этого файла выполняет ClassLoader. После того, как ClassLoader загрузит класс, один объект Class помещается в кучу.imageПрежде, чем обсудить структуру файла класса, давайте сначала немного поговорим о наборах инструкций.Инструкции байт-кода похожи на ассемблерный код в том, что эти команды загружают значения из определённых участков памяти. Далее над этими значениями производятся операции, и их результаты записываются обратно в память по заданным адресам. На высокоуровневых языках мы всегда работаем с символами. Символ может представлять собой имя метода, имя переменной, т. д. Сам символ – это просто представление некоторого места в памяти, абсолютное или относительное.Например, следующая инструкция может быть написана на любом языке.
fun calculate {
   int i = 4
 }
i – это символ. Он представляет местоположение в памяти. Данная инструкция приказывает записать число 4 по тому адресу в памяти, который представлен через i. Прежде, чем что-либо может быть записано по адресу в памяти, компьютеру требуется зарезервировать место для этой информации. В данном случае размер указывается при помощи int.Ещё один важный факт: программа всегда выполняется в одном потоке или в нескольких потоках. Для каждого потока есть соответствующая структура Stack (стек), в которой хранятся состояния среды выполнения актуального потока. В этом стеке можно хранить локальные переменные вызываемых функций. Ещё одна область памяти – это куча. Куча используется для хранения глобальных выделенных объектов. Такая модель памяти используется не только в Java, она также существует и в C++. В большей или меньшей степени то же касается и других языков.imageМодель памяти 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.imageiconst_3: продвинуть константу 3 в стек операндов.imageКоманда push — в стек операндовistore_1: вытолкнуть целое число из стека операндов и сохранить целое число по индексу 1 в массиве локальных переменных актуального кадра.imageКоманда pop — из стека операндов

❯ Стек операндов

Почему нам требуется стек операндов? Как было сказано выше, сначала такие инструкции должны загрузить данные из памяти, проделать над ними операции и записать результат этих операций обратно в память. Где же JVM хранит данные, загруженные из памяти?В ЦП компьютера есть регистры. Значение переменной сначала загружается в регистры ЦП, и в регистрах производятся расчёты. Затем результат расчётов извлекается из регистра и записывается обратно в память. Я считаю, что JVM позаимствовала такой дизайн прямо из самого ЦП.imageПочему 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
 	}
 }
Один экземпляр создаётся в три этапа:
  • Загружается целевой класс;
  • В куче выделяется память для экземпляра класса;
  • Вызывается функция конструктора.
Рассмотрим сгенерированный байт-код.imageПервая инструкция – это new #8. Число 8 соответствует одному индексу в таблице пула констант.

❯ Пул констант

Пул констант – это структура, относящаяся к времени исполнения и создаваемая JVM после загрузки файла класса. В ней содержатся все символьные ссылки, которые использовались в исходном классе.При помощи javap -v TestClass.class можно просмотреть в необработанном виде содержимое пула констант в файле класса (следующий вывод как раз интерпретируется javap).imageПосле того, как загрузчик классов прочитает наш файл TestClass.class, JVM создаст одну таблицу пула констант.imageКаждая запись в таблице пула констант – это структура переменной величины. Каждая запись может представлять различные типы констант. Тип константы в данном случае представлен первым байтом, который называется «тег». На этой странице 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