JEP 457: Class-File API (Preview)
Class-File API (версия Preview (предварительная версия))
| Автор | Brian Goetz |
| Ответственный | Adam Sotona |
| Тип | Feature |
| Область | SE |
| Статус | Closed / Delivered |
| Выпуск | 22 |
| Компонент | core-libs / java.lang.classfile |
| Обсуждение | classfile dash api dash dev at openjdk dot org |
| Трудоёмкость | M |
| Длительность | M |
| Связан с | JEP 466: Class-File API (Second Preview) |
| Рецензенты | Alex Buckley, Paul Sandoz |
| Одобрен | Paul Sandoz |
| Создан | 2022/01/20 14:51 |
| Обновлён | 2025/02/25 16:31 |
| Задача | 8280389 |
Аннотация
Предоставить стандартный API для разбора, генерации и преобразования class-файлов Java. Это API в статусе Preview.
Цели
-
Предоставить API для обработки class-файлов, который следует формату файлов
class, определённому в Java Virtual Machine Specification (спецификация виртуальной машины Java). -
Дать компонентам JDK возможность перейти на стандартный API и со временем удалить внутреннюю копию сторонней библиотеки ASM из JDK.
Что не является целью
-
Цель не в том, чтобы сделать устаревшими существующие библиотеки для обработки class-файлов, и не в том, чтобы создать самую быструю в мире библиотеку для работы с class-файлами.
-
Цель не в том, чтобы расширить Core Reflection API и дать доступ к байт-коду загруженных классов.
-
Цель не в том, чтобы предоставить функции анализа кода: их можно построить поверх Class-File API в сторонних библиотеках.
Мотивация
Class-файлы — общий язык экосистемы 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 не может безопасно генерировать новые для JDK N возможности class-файлов до JDK N+1. Особенно остро это проявляется, когда JDK N — долгожданный выпуск, например JDK 21, и разработчики хотят поскорее писать программы, которым нужны новые возможности class-файлов.
Платформа Java должна определять и реализовывать стандартный API для работы с class-файлами, который развивается вместе с форматом class-файлов. Компоненты платформы смогут опираться только на этот API, а не постоянно зависеть от готовности сторонних разработчиков обновлять и тестировать свои библиотеки. Фреймворки и инструменты, использующие стандартный 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-методов, карты стека и т. д.) выводятся из других частей class-файла. Нет смысла просить пользователя строить их напрямую: это лишняя работа для пользователя и повышенный риск ошибки. 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-файл целиком. Некоторые элементы, например методы, являются составными элементами: помимо того что они сами являются элементами, они содержат собственные элементы, и с ними можно работать целиком или разбирать их дальше.
-
Каждому виду составного элемента соответствует построитель со специальными методами построения (например,
ClassBuilder::withMethod), который также являетсяConsumerдля соответствующего типа элементов. -
Наконец, преобразование — это функция, которая принимает элемент и построитель и определяет, как этот элемент преобразуется в другие элементы и преобразуется ли вообще.
Мы представим API, показав, как с его помощью разбирать class-файлы, генерировать class-файлы и объединять разбор и генерацию в преобразование.
Это API в статусе Preview, по умолчанию отключён
Чтобы опробовать приведённые ниже примеры в JDK 22, нужно включить Preview-возможности следующим образом:
-
Скомпилируйте программу с
javac --release 22 --enable-preview Main.javaи запускайте её сjava --enable-preview Main; или -
При использовании средства запуска исходного кода запускайте программу с
java --source 22 --enable-preview Main.java
Разбор 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.invokeInstruction(i.opcode(),
ClassDesc.of("Bar"),
i.name(), i.type());
default -> codeBuilder.with(e);
}
}
});
}
else
methodBuilder.with(me);
}
});
}
else
classBuilder.with(ce);
}
});
Навигация по дереву class-файла путём разбора сущностей на элементы и проверки каждого элемента требует некоторого шаблонного кода, который повторяется на нескольких уровнях. Эта идиома общая для всех обходов, поэтому с ней должна помогать библиотека. Общий шаблон — взять сущность class-файла, получить соответствующий построитель, проверить каждый элемент сущности и, возможно, заменить его другими элементами — можно выразить с помощью преобразователей (transforms), которые применяются методами преобразования.
Преобразователь принимает построитель и элемент. Он либо заменяет элемент другими элементами, либо удаляет элемент, либо передаёт элемент построителю без изменений. Преобразователи являются функциональными интерфейсами, поэтому логику преобразования можно выразить лямбда-выражениями.
Метод преобразования копирует соответствующие метаданные (имена, флаги и т. д.) из составного элемента в построитель, а затем обрабатывает элементы составного элемента, применяя преобразователь, и берёт на себя повторяющиеся разбор и перебор.
С помощью преобразования предыдущий пример можно переписать так:
ClassFile cf = ClassFile.of();
ClassModel classModel = cf.parse(bytes);
byte[] newBytes = cf.transform(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.invokeInstruction(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.invokeInstruction(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.transform(cf.parse(bytes), classTransform);
Тестирование
У Class-File API большая поверхность API, и он должен генерировать классы в соответствии с Java Virtual Machine Specification (спецификация виртуальной машины Java), поэтому потребуется серьёзное тестирование качества и соответствия спецификации. Кроме того, в той мере, в какой мы заменим использование ASM в JDK на использование Class-File API, мы будем сравнивать результаты работы обеих библиотек, чтобы выявлять регрессии, и проведём обширное тестирование производительности, чтобы выявлять регрессии производительности и не допускать их.
Альтернативы
Очевидная идея — «просто» включить ASM в JDK и взять на себя его дальнейшее сопровождение, но это неверный выбор. ASM — старая кодовая база с большим грузом унаследованного кода. Её трудно развивать, а приоритеты проектирования, определившие её архитектуру, скорее всего, не те, что мы выбрали бы сегодня. Более того, со времени создания ASM язык Java существенно улучшился, поэтому то, что могло быть лучшими идиомами API в 2002 году, может оказаться не идеальным два десятилетия спустя.