JEP 484: Class-File API
Class-File API
| Автор | Brian Goetz |
| Ответственный | Adam Sotona |
| Тип | Feature |
| Область | SE |
| Статус | Closed / Delivered |
| Выпуск | 24 |
| Компонент | core-libs / java.lang.classfile |
| Обсуждение | core dash libs dash dev at openjdk dot org |
| Трудоёмкость | S |
| Длительность | M |
| Связан с | JEP 466: Class-File API (Second Preview) |
| Рецензенты | Paul Sandoz |
| Одобрен | Paul Sandoz |
| Создан | 2024/06/21 08:36 |
| Обновлён | 2025/08/14 08:02 |
| Задача | 8334712 |
Аннотация
Предоставить стандартный API для разбора, генерации и преобразования class-файлов Java.
История
Class-File API был впервые предложен в статусе Preview (предварительная версия) в JEP 457 в JDK 22 и доработан в JEP 466 в JDK 23. Здесь мы предлагаем сделать API окончательным в JDK 24 с небольшими изменениями, подробно описанными ниже, на основе накопленного опыта и отзывов.
Цели
-
Предоставить API для обработки class-файлов, который следует формату файлов
class, определённому в Java Virtual Machine Specification. -
Дать компонентам JDK возможность перейти на стандартный API и в конечном итоге удалить из JDK внутреннюю копию сторонней библиотеки ASM.
Что не является целью
-
Цель не в том, чтобы сделать устаревшими существующие библиотеки для обработки class-файлов, и не в том, чтобы стать самой быстрой в мире библиотекой для работы с class-файлами.
-
Цель не в том, чтобы расширить Core Reflection API и дать доступ к байт-коду загруженных классов.
-
Цель не в том, чтобы предоставить функциональность анализа кода; её можно построить поверх Class-File API с помощью сторонних библиотек.
Мотивация
Class-файлы — lingua franca экосистемы Java. Разбор, генерация и преобразование class-файлов встречаются повсеместно, поскольку так независимые инструменты и библиотеки могут исследовать и расширять программы, не ставя под угрозу сопровождаемость исходного кода. Например, фреймворки применяют преобразование байт-кода на лету, чтобы прозрачно добавлять функциональность, которую разработчикам приложений было бы непрактично, если не невозможно, включить в исходный код.
В экосистеме Java много библиотек для разбора и генерации class-файлов, и у каждой свои цели проектирования, сильные и слабые стороны. Фреймворки, обрабатывающие class-файлы, обычно включают в свой состав библиотеку для работы с class-файлами, например ASM, BCEL или Javassist. Однако серьёзная проблема для таких библиотек в том, что формат class-файлов развивается быстрее, чем раньше, из-за шестимесячного цикла выпусков JDK. В последние годы формат class-файлов развивался, чтобы поддержать такие возможности языка Java, как Sealed Classes (запечатанные классы), и открыть такие возможности JVM, как динамические константы и nestmates. Эта тенденция сохранится с будущими возможностями, такими как Value Classes (классы-значения) и специализация обобщённых методов.
Поскольку формат class-файлов может меняться каждые шесть месяцев, фреймворки всё чаще сталкиваются с class-файлами новее, чем входящая в их состав библиотека для работы с class-файлами. Это расхождение версий приводит к ошибкам, которые видят разработчики приложений, или, что хуже, к тому, что разработчики фреймворков пытаются писать код для разбора class-файлов из будущего и слепо надеются, что ничего слишком серьёзного не изменится. Разработчикам фреймворков нужна библиотека для работы с class-файлами, в актуальности которой относительно работающего JDK они могут быть уверены.
В JDK есть собственная библиотека для работы с class-файлами внутри компилятора javac. Кроме того, в JDK входит ASM — для реализации таких инструментов, как jar и jlink, и для поддержки реализации лямбда-выражений во время выполнения. К сожалению, из-за использования сторонней библиотеки в JDK новые возможности class-файлов осваиваются во всей экосистеме с утомительной задержкой. Версия ASM для JDK N не может стать окончательной раньше, чем окончательным станет JDK N, поэтому инструменты в JDK N не могут обрабатывать возможности class-файлов, появившиеся в JDK N, а значит, javac не может безопасно генерировать возможности class-файлов, новые для JDK N, вплоть до JDK N+1. Особенно это проблематично, когда JDK N — долгожданный выпуск, такой как JDK 21, и разработчикам не терпится писать программы, требующие новых возможностей class-файлов.
Платформа Java должна определять и реализовывать стандартный API для работы с class-файлами, который развивается вместе с форматом class-файлов. Компоненты платформы смогут полагаться только на этот API, а не зависеть постоянно от готовности сторонних разработчиков обновлять и тестировать свои библиотеки для работы с class-файлами. Фреймворки и инструменты, использующие стандартный API, будут автоматически поддерживать class-файлы последнего JDK, так что новые возможности языка и VM, представленные в class-файлах, можно будет внедрять быстро и легко.
Описание
Для Class-File API мы приняли следующие цели проектирования и принципы.
-
Сущности class-файла представлены неизменяемыми объектами — все сущности class-файла, такие как поля, методы, атрибуты, инструкции байт-кода, аннотации и т. д., представлены неизменяемыми объектами. Это облегчает надёжное совместное использование при преобразовании class-файла.
-
Древовидное представление — class-файл имеет древовидную структуру. У класса есть метаданные (имя, суперкласс и т. д.) и переменное число полей, методов и атрибутов. У полей и методов, в свою очередь, есть метаданные, и они содержат атрибуты, в том числе атрибут
Code. АтрибутCode, в свою очередь, содержит инструкции, обработчики исключений и так далее. API для навигации по class-файлам и их построения должен отражать эту структуру. -
Навигация под управлением пользователя — путь по дереву class-файла определяется выбором пользователя. Если пользователя интересуют только аннотации на полях, то разбирать нужно только до атрибутов аннотаций внутри структуры
field_info; не нужно заглядывать ни в атрибуты класса, ни в тела методов, ни в другие атрибуты поля. Пользователи должны иметь возможность работать с составными сущностями, такими как методы, либо как с единым целым, либо как с потоками составляющих их частей — как им удобно. -
Ленивость — навигация под управлением пользователя даёт значительный выигрыш в эффективности, например можно не разбирать class-файл дальше, чем требуется для нужд пользователя. Если пользователь не собирается углубляться в содержимое метода, то структуру
method_infoнужно разобрать лишь настолько, чтобы определить, где начинается следующий элемент class-файла. Полное представление можно лениво развернуть и закэшировать, когда пользователь его запросит. -
Единые потоковое и материализованное представления — как и ASM, мы хотим поддерживать и потоковое, и материализованное представление class-файла. Потоковое представление подходит для большинства сценариев, а материализованное более универсально, поскольку допускает произвольный доступ. Благодаря ленивости, которую обеспечивает неизменяемость, мы можем предоставить материализованное представление намного дешевле, чем ASM. Кроме того, мы можем согласовать потоковое и материализованное представления так, чтобы они использовали общий словарь и могли применяться совместно — как удобно в каждом конкретном сценарии.
-
Преобразование как естественное следствие — если API разбора и генерации class-файлов достаточно согласованы, то преобразование может стать естественным свойством, не требующим собственного особого режима или значительного объёма нового API. (ASM добивается этого, используя общую структуру посетителей для чтения и записи.) Если классы, поля, методы и тела кода можно читать и записывать как потоки элементов, то преобразование можно рассматривать как операцию flat-map над этим потоком, определяемую лямбдами.
-
Сокрытие деталей — многие части class-файла (пул констант, таблица bootstrap-методов, карты стека и т. д.) выводятся из других его частей. Нет смысла просить пользователя строить их напрямую: это лишняя работа для пользователя и повышенный риск ошибки. API будет автоматически генерировать сущности, тесно связанные с другими сущностями, на основе полей, методов и инструкций, добавленных в class-файл.
-
Опора на язык — в 2002 году подход с посетителями, используемый в ASM, казался изящным и уж точно был удобнее того, что было до него. Однако с тех пор язык программирования Java значительно улучшился — появились лямбды, Records (записи), Sealed Classes и Pattern Matching (сопоставление с образцом), — а в платформе Java теперь есть стандартный API для описания констант class-файлов (
java.lang.constant). С помощью этих возможностей мы можем спроектировать API, который гибче и удобнее в использовании, менее многословен и менее подвержен ошибкам.
Элементы, построители и преобразования
Class-File API находится в пакете java.lang.classfile и его подпакетах. Он определяет три основные абстракции:
-
Элемент — неизменяемое описание некоторой части class-файла; это может быть инструкция, атрибут, поле, метод или весь class-файл. Некоторые элементы, например методы, являются составными элементами: помимо того что они сами элементы, они содержат собственные элементы, и с ними можно работать целиком или разбирать их дальше.
-
Каждому виду составного элемента соответствует построитель (builder), у которого есть особые методы построения (например,
ClassBuilder::withMethod) и который также являетсяConsumerдля соответствующего типа элементов. -
Наконец, преобразование (transform) — это функция, которая принимает элемент и построитель и определяет, как этот элемент преобразуется в другие элементы, если преобразуется вообще.
Мы познакомим с API, показав, как с его помощью разбирать class-файлы, генерировать class-файлы и объединять разбор и генерацию в преобразование.
Разбор class-файлов с помощью шаблонов
Потоковое представление class-файлов в ASM основано на посетителях. Посетители громоздки и негибки; шаблон «посетитель» часто называют библиотечным обходным путём, компенсирующим отсутствие Pattern Matching в языке. Теперь, когда в языке Java есть Pattern Matching, мы можем выражать мысли прямее и лаконичнее. Например, если мы хотим обойти атрибут Code и собрать зависимости для графа зависимостей классов, то можно просто перебрать инструкции и сопоставлять с шаблонами те, что нам интересны. CodeModel описывает атрибут Code; мы можем перебрать его элементы CodeElement и обработать те, что содержат символические ссылки на другие типы:
CodeModel code = ...
Set<ClassDesc> deps = new HashSet<>();
for (CodeElement e : code) {
switch (e) {
case FieldInstruction f -> deps.add(f.owner());
case InvokeInstruction i -> deps.add(i.owner());
... and so on for instanceof, cast, etc ...
}
}
Генерация class-файлов с помощью построителей
Допустим, мы хотим сгенерировать в class-файле следующий метод:
void fooBar(boolean z, int x) {
if (z)
foo(x);
else
bar(x);
}
С помощью ASM этот метод можно сгенерировать так:
ClassWriter classWriter = ...;
MethodVisitor mv = classWriter.visitMethod(0, "fooBar", "(ZI)V", null, null);
mv.visitCode();
mv.visitVarInsn(ILOAD, 1);
Label label1 = new Label();
mv.visitJumpInsn(IFEQ, label1);
mv.visitVarInsn(ALOAD, 0);
mv.visitVarInsn(ILOAD, 2);
mv.visitMethodInsn(INVOKEVIRTUAL, "Foo", "foo", "(I)V", false);
Label label2 = new Label();
mv.visitJumpInsn(GOTO, label2);
mv.visitLabel(label1);
mv.visitVarInsn(ALOAD, 0);
mv.visitVarInsn(ILOAD, 2);
mv.visitMethodInsn(INVOKEVIRTUAL, "Foo", "bar", "(I)V", false);
mv.visitLabel(label2);
mv.visitInsn(RETURN);
mv.visitEnd();
MethodVisitor в ASM выступает одновременно и посетителем, и построителем. Клиенты могут создать ClassWriter напрямую, а затем запросить у ClassWriter объект MethodVisitor. Class-File API переворачивает эту идиому: вместо того чтобы клиент создавал построитель с помощью конструктора или фабрики, клиент передаёт лямбду, которая принимает построитель:
ClassBuilder classBuilder = ...;
classBuilder.withMethod("fooBar", MethodTypeDesc.of(CD_void, CD_boolean, CD_int), flags,
methodBuilder -> methodBuilder.withCode(codeBuilder -> {
Label label1 = codeBuilder.newLabel();
Label label2 = codeBuilder.newLabel();
codeBuilder.iload(1)
.ifeq(label1)
.aload(0)
.iload(2)
.invokevirtual(ClassDesc.of("Foo"), "foo", MethodTypeDesc.of(CD_void, CD_int))
.goto_(label2)
.labelBinding(label1)
.aload(0)
.iload(2)
.invokevirtual(ClassDesc.of("Foo"), "bar", MethodTypeDesc.of(CD_void, CD_int))
.labelBinding(label2);
.return_();
});
Это более конкретно и прозрачно — у построителя много удобных методов, таких как aload(n), — но пока не лаконичнее и не более высокоуровнево. Однако уже есть мощное скрытое преимущество: захватывая последовательность операций в лямбде, мы получаем возможность повторного воспроизведения, благодаря чему библиотека может выполнять работу, которую раньше приходилось делать клиенту. Например, смещения переходов могут быть короткими или длинными. Если клиенты генерируют инструкции императивно, им приходится вычислять размер смещения каждого перехода при его генерации, а это сложно и чревато ошибками. Но если клиент передаёт лямбду, принимающую построитель, библиотека может оптимистично попытаться сгенерировать метод с короткими смещениями, а если это не удастся, отбросить сгенерированное состояние и повторно вызвать лямбду с другими параметрами генерации кода.
Отделение построителей от обхода посетителями также позволяет нам предоставить более высокоуровневые удобства для управления областями видимости блоков и вычисления индексов локальных переменных, а также избавиться от ручного управления метками и переходами:
CodeBuilder classBuilder = ...;
classBuilder.withMethod("fooBar", MethodTypeDesc.of(CD_void, CD_boolean, CD_int), flags,
methodBuilder -> methodBuilder.withCode(codeBuilder -> {
codeBuilder.iload(codeBuilder.parameterSlot(0))
.ifThenElse(
b1 -> b1.aload(codeBuilder.receiverSlot())
.iload(codeBuilder.parameterSlot(1))
.invokevirtual(ClassDesc.of("Foo"), "foo",
MethodTypeDesc.of(CD_void, CD_int)),
b2 -> b2.aload(codeBuilder.receiverSlot())
.iload(codeBuilder.parameterSlot(1))
.invokevirtual(ClassDesc.of("Foo"), "bar",
MethodTypeDesc.of(CD_void, CD_int))
.return_();
});
Поскольку областями видимости блоков управляет Class-File API, нам не пришлось генерировать метки и инструкции перехода — они вставляются за нас. Точно так же Class-File API может при желании управлять выделением локальных переменных в пределах блоков, избавляя клиентов и от учёта слотов локальных переменных.
Преобразование class-файлов
Методы разбора и генерации в Class-File API согласованы между собой, поэтому преобразование выполняется без швов. В примере разбора выше обходилась последовательность CodeElement, и клиент мог сопоставлять отдельные элементы с образцами. Построитель принимает CodeElement, поэтому типичные идиомы преобразования получаются естественным образом.
Предположим, мы хотим обработать class-файл и оставить в нём всё без изменений, кроме удаления методов, имена которых начинаются с "debug". Мы получили бы ClassModel, создали бы ClassBuilder, перебрали бы элементы исходного ClassModel и передали бы в построитель их все, кроме методов, которые хотим отбросить:
ClassFile cf = ClassFile.of();
ClassModel classModel = cf.parse(bytes);
byte[] newBytes = cf.build(classModel.thisClass().asSymbol(),
classBuilder -> {
for (ClassElement ce : classModel) {
if (!(ce instanceof MethodModel mm
&& mm.methodName().stringValue().startsWith("debug"))) {
classBuilder.with(ce);
}
}
});
Преобразовывать тела методов немного сложнее, поскольку нужно разложить классы на составные части (поля, методы и атрибуты), выбрать элементы-методы, разложить элементы-методы на их части (включая атрибут кода), а затем разложить атрибут кода на его элементы (то есть инструкции). Следующее преобразование заменяет вызовы методов класса Foo на вызовы методов класса Bar:
ClassFile cf = ClassFile.of();
ClassModel classModel = cf.parse(bytes);
byte[] newBytes = cf.build(classModel.thisClass().asSymbol(),
classBuilder -> {
for (ClassElement ce : classModel) {
if (ce instanceof MethodModel mm) {
classBuilder.withMethod(mm.methodName(), mm.methodType(),
mm.flags().flagsMask(), methodBuilder -> {
for (MethodElement me : mm) {
if (me instanceof CodeModel codeModel) {
methodBuilder.withCode(codeBuilder -> {
for (CodeElement e : codeModel) {
switch (e) {
case InvokeInstruction i
when i.owner().asInternalName().equals("Foo")) ->
codeBuilder.invoke(i.opcode(),
ClassDesc.of("Bar"),
i.name(), i.type());
default -> codeBuilder.with(e);
}
}
});
}
else
methodBuilder.with(me);
}
});
}
else
classBuilder.with(ce);
}
});
Навигация по дереву class-файла путём разложения сущностей на элементы и проверки каждого элемента требует некоторого шаблонного кода, который повторяется на нескольких уровнях. Эта идиома общая для всех обходов, поэтому с ней должна помогать библиотека. Общий шаблон, при котором берётся сущность class-файла, получается соответствующий построитель, проверяется каждый элемент сущности и, возможно, заменяется другими элементами, можно выразить преобразованиями, которые применяются методами преобразования.
Преобразование принимает построитель и элемент. Оно либо заменяет элемент другими элементами, либо отбрасывает элемент, либо передаёт элемент в построитель без изменений. Преобразования являются функциональными интерфейсами, поэтому логику преобразования можно записать в лямбдах.
Метод преобразования копирует соответствующие метаданные (имена, флаги и т. д.) из составного элемента в построитель, а затем обрабатывает элементы составного элемента, применяя преобразование, и берёт на себя повторяющиеся разложение и перебор.
С помощью преобразования предыдущий пример можно переписать так:
ClassFile cf = ClassFile.of();
ClassModel classModel = cf.parse(bytes);
byte[] newBytes = cf.transformClass(classModel, (classBuilder, ce) -> {
if (ce instanceof MethodModel mm) {
classBuilder.transformMethod(mm, (methodBuilder, me)-> {
if (me instanceof CodeModel cm) {
methodBuilder.transformCode(cm, (codeBuilder, e) -> {
switch (e) {
case InvokeInstruction i
when i.owner().asInternalName().equals("Foo") ->
codeBuilder.invoke(i.opcode(), ClassDesc.of("Bar"),
i.name().stringValue(),
i.typeSymbol(), i.isInterface());
default -> codeBuilder.with(e);
}
});
}
else
methodBuilder.with(me);
});
}
else
classBuilder.with(ce);
});
Шаблонный код перебора исчез, но глубокая вложенность лямбд для доступа к инструкциям по-прежнему пугает. Это можно упростить, вынеся действия, относящиеся к инструкциям, в CodeTransform:
CodeTransform codeTransform = (codeBuilder, e) -> {
switch (e) {
case InvokeInstruction i when i.owner().asInternalName().equals("Foo") ->
codeBuilder.invoke(i.opcode(), ClassDesc.of("Bar"),
i.name().stringValue(),
i.typeSymbol(), i.isInterface());
default -> codeBuilder.accept(e);
}
};
Затем это преобразование элементов кода можно поднять до преобразования элементов методов. Когда поднятое преобразование встречает атрибут Code, оно преобразует его с помощью преобразования кода, а все остальные элементы метода передаёт без изменений:
MethodTransform methodTransform = MethodTransform.transformingCode(codeTransform);
То же самое можно проделать ещё раз, чтобы поднять полученное преобразование элементов методов до преобразования элементов классов:
ClassTransform classTransform = ClassTransform.transformingMethods(methodTransform);
Теперь наш пример становится совсем простым:
ClassFile cf = ClassFile.of();
byte[] newBytes = cf.transformClass(cf.parse(bytes), classTransform);
Изменения
Подробный список изменений со времени второй версии Preview:
-
Переименованы значения перечислений:
-
Перемещены и переименованы поля:
AttributesProcessingOption.DROP_UNSTABLE_ATRIBUTES→DROP_UNSTABLE_ATTRIBUTESClassFile.AEV_*→AnnotationValue.TAG_*ClassFile.CRT_*→CharacterRange.FLAG_*ClassFile.TAG_*→PoolEntry.TAG_*ClassFile.TAT_*→TypeAnnotation.TARGET_*ClassFile.VT_*→StackMapFrameInfo.VerificationTypeInfo.ITEM_*StackMapFrameInfo.SimpleVerificationTypeInfo.ITEM_*→*
-
Удалены поля, которые без необходимости раскрывали детали или были избыточными:
-
Добавлены методы:
-
Добавлены перегрузки методов:
-
Переименованы методы:
-
Методы перемещены из одного интерфейса в другой:
-
Изменён тип возвращаемого значения методов:
-
Удалён суперинтерфейс:
-
Изменена сигнатура интерфейса:
-
Удалены интерфейсы, которые без необходимости раскрывали внутреннее устройство реализации:
-
Удалены методы, которые без необходимости раскрывали внутреннее устройство реализации или были избыточными альтернативами:
AccessFlags::ofClass(AccessFlag ...)AccessFlags::ofClass(int)AccessFlags::ofField(AccessFlag ...)AccessFlags::ofField(int)AccessFlags::ofMethod(AccessFlag ...)AccessFlags::ofMethod(int)BufWriter::copyTo(byte[], int)BufWriter::writeBytes(BufWriter)BufWriter::writeListIndices(List<? extends PoolEntry>)ClassBuilder::original()ClassFileBuilder::canWriteDirect(ConstantPool)ClassFileTransform::resolve(B)ClassReader::readClassEntry(int)ClassReader::readMethodHandleEntry(int)ClassReader::readModuleEntry(int)ClassReader::readNameAndTypeEntry(int)ClassReader::readPackageEntry(int)ClassReader::readUtf8Entry(int)ClassReader::readUtf8EntryOrNull(int)ClassTransform::resolve(ClassBuilder)CodeBuilder::loadConstant(Opcode, ConstantDesc)CodeBuilder::original()CodeRelabeler::relabel(Label, CodeBuilder)CodeTransform::resolve(CodeBuilder)CompoundElement::elements()ConstantPoolBuilder::annotationConstantValueEntry(ConstantDesc)ConstantPoolBuilder::writeBootstrapMethods(BufWriter)FieldBuilder::original()FieldTransform::resolve(FieldBuilder)MethodBuilder::original()MethodTransform::resolve(MethodBuilder)ModuleAttributeBuilder::build()Opcode::constantValue()Opcode::isUnconditionalBranch()Opcode::primaryTypeKind()Opcode::secondaryTypeKind()Opcode::slot()TypeKind::descriptor()TypeKind::typeName()
Тестирование
У Class-File API большая площадь поверхности, и он должен генерировать классы в соответствии с Java Virtual Machine Specification, поэтому потребуется значительное тестирование качества и соответствия. Кроме того, в той мере, в какой мы заменим использование ASM в JDK на использование Class-File API, мы будем сравнивать результаты работы обеих библиотек, чтобы выявлять регрессии, и проведём обширное тестирование производительности, чтобы выявлять и не допускать регрессий производительности.
Альтернативы
Очевидная идея — «просто» включить ASM в JDK и взять на себя ответственность за его дальнейшее сопровождение, но это неправильный выбор. ASM — старая кодовая база с большим грузом унаследованных решений. Её трудно развивать, а приоритеты проектирования, на которых основана её архитектура, скорее всего, не те, что мы выбрали бы сегодня. Более того, язык Java существенно улучшился со времени создания ASM, поэтому то, что могло быть лучшими идиомами API в 2002 году, может оказаться неидеальным два десятилетия спустя.