image

В этой статье мы разберём, как добавить к файлу класса публичный атрибут. Когда загрузчик классов завершит загрузку модифицированного файла класса, мы увидим то поле, которое добавили вручную.

После того, как компилятор 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.

image

iconst_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

Комментарии (6)


  1. ifilonov
    08.04.2023 12:13

    В байткоде для начальных значений полей места просто не предусмотрено, они просто инициализируются в значение по умолчанию. Чтобы добавить аналог int x = 10; надо 1) добавить само поле 2) во все конструкторы добавить iload/putfield 3) если конструкторов нет вообще — добавить конструктор.


  1. ValeryIvanov
    08.04.2023 12:13
    +2

    Примечание: я установил начальное значение в 4. Но в консоль всё равно выводится 0. То есть, добавленный атрибут не инициализируется в 4. Почему – я не знаю.

    Ответ есть в документации.

    This parameter is only used for static fields. Its value is ignored for non static fields, which must be initialized through bytecode instructions in constructors or methods.


  1. Apoheliy
    08.04.2023 12:13
    +2

    Такая модель памяти используется не только в Java, она также существует и в C++. В большей или меньшей степени то же касается и других языков.

    Пардонте, но это единственное упоминание C++ в тексте статьи :((. Или у вас ИИ тэги статьи выставляет?


    1. isden
      08.04.2023 12:13
      +1

      Там в тэгах и JavaScript выставлено :) А Kotlin нет. Похоже, тут даже и ИИ не пахнет.


      1. ht-pro
        08.04.2023 12:13

        Если Java, то JavaScript, очевидно же.


  1. Maccimo
    08.04.2023 12:13
    +2

    Начинать статью с «компилятор Java обработает наш код» и потом хреначить весь код на Kotlin это какая-то шиза.

    Хотя бы потому, что в class-файлах, генерируемых компилятором Котлина, целая гора требухи, которой нет в class-файлах, генерируемых компилятором Java. Те же проверки на null для значений non-nullable типов или Kotlin-специфичные аннотации, например.

    Чтение и синтаксический разбор этого файла выполняет ClassLoader.

    ClassLoader откуда-то берёт байтики class-файла и просит JVM загрузить из них класс, всё. Никакого «синтаксического разбора» он не делает.

    На этой странице docs.oracle.com/javase/specs/jvms/se7/html/jvms-4.html#jvms-4.4.1 перечислены все типы констант.

    Ссылаться на спецификацию Java 7 в 2021 году, когда вышел оригинал этой статьи, несколько странно. На тот момент прошло уже более 7 лет с выхода Java 8, а актуальной на тот момент LTS-версией была 11 на начало года и 17 — в конце. Типов констант со времён Java 7 поприбавилось.

    Если из этого перевода вы так и не поняли, что это за пул констант такой и с чем его едят, то рекомендую хабрастатью про это: https://habr.com/ru/articles/222519/
    C момента написания появилось некоторое количество новых типов записей в constant pool, но основы неизменны и переданы отлично.

    Такая модель памяти используется не только в Java, она также существует и в C++.

    И в Java, и в C++ «моделью памяти» называют несколько иное. А именно, гарантии видимости изменений в ОЗУ при работе многопоточной программы.

    Примечание: я установил начальное значение в 4. Но в консоль всё равно выводится 0. То есть, добавленный атрибут не инициализируется в 4. Почему – я не знаю.

    Как уже указали в комментариях, для нестатических полей класса нужно явно инициализировать их в конструкторе. Здесь проектировщики API библиотеки ASM переусердствовали в погоне за удобством.

    ifilonov

    В байткоде для начальных значений полей места просто не предусмотрено, они просто инициализируются в значение по умолчанию.

    Не совсем так. Для статических полей предусмотрен аттрибут ConstantValue (JVMS §4.7.2). Работает с полями строкового и примитивных типов.

    JVMS §4.7.2
    4.7.2. The ConstantValue Attribute

    The ConstantValue attribute is a fixed-length attribute in the attributes table of a field_info structure (§4.5). A ConstantValue attribute represents the value of a constant expression (JLS §15.28), and is used as follows:
    • If the ACC_STATIC flag in the access_flags item of the field_info structure is set, then the field represented by the field_info structure is assigned the value represented by its ConstantValue attribute as part of the initialization of the class or interface declaring the field (§5.5). This occurs prior to the invocation of the class or interface initialization method of that class or interface (§2.9.2).
    • Otherwise, the Java Virtual Machine must silently ignore the attribute.


    Но с не static final полями он не работает и именно об это споткнулся автор, невнимательно читавший JavaDoc. Тут, конечно, и авторы ASM хороши, накостылявшие в API такую сигнатуру для visitField().