openjdk.ruOpenJDK на русском

JEP 466: Class-File API (Second Preview)

Class-File API: вторая версия Preview (предварительная версия)

АвторBrian Goetz
ОтветственныйAdam Sotona
ТипFeature
ОбластьSE
СтатусClosed / Delivered
Выпуск23
Компонентcore-libs / java.lang.classfile
Обсуждениеcore dash libs dash dev at openjdk dot org
ТрудоёмкостьS
ДлительностьM
Связан сJEP 457: Class-File API (Preview)
JEP 484: Class-File API
РецензентыAlex Buckley, Paul Sandoz
ОдобренPaul Sandoz
Создан2024/01/30 13:45
Обновлён2025/02/25 16:32
Задача8324965

Аннотация

Предоставить стандартный API для разбора, генерации и преобразования class-файлов Java. Это API в статусе Preview.

История

Class-File API был предложен как Preview-возможность в JEP 457 в JDK 22. Здесь мы предлагаем вторую версию Preview с доработками на основе полученного опыта и отзывов. В этой версии Preview мы:

  • Упростили класс CodeBuilder. В этом классе есть три вида фабричных методов для инструкций байт-кода: низкоуровневые фабрики, фабрики среднего уровня и высокоуровневые построители для базовых блоков. По отзывам мы удалили методы среднего уровня, которые дублировали низкоуровневые методы или редко использовались, а оставшиеся методы среднего уровня переименовали, чтобы ими было удобнее пользоваться.

  • Сделали экземпляры AttributeMapper в Attributes доступными через статические методы вместо статических полей, чтобы их можно было инициализировать лениво и снизить затраты на запуск.

  • Переделали Signature.TypeArg в алгебраический тип данных, чтобы упростить доступ к ограничивающему типу, когда вид TypeArg — ограниченный.

  • Добавили учитывающие тип методы ClassReader::readEntryOrNull и ConstantPool::entryByIndex, которые выбрасывают ConstantPoolException вместо ClassCastException, если элемент по данному индексу имеет не тот тип. Так обработчики class-файлов могут указать, что несоответствие типа элемента пула констант — проблема формата class-файла, а не самого обработчика.

  • Улучшили класс ClassSignature, чтобы он точнее моделировал обобщённые сигнатуры суперклассов и суперинтерфейсов.

  • Исправили несогласованность в именовании в TypeKind.

  • Удалили методы реализации из ClassReader.

Цели

  • Предоставить 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, а не постоянно зависеть от готовности сторонних разработчиков обновлять и тестировать свои библиотеки для работы с 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-методов, карты стека и т. д.) выводятся из других частей 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 23, нужно включить Preview-возможности следующим образом:

  • Скомпилируйте программу с javac --release 23 --enable-preview Main.java и запускайте её с java --enable-preview Main; или

  • При использовании средства запуска исходного кода запускайте программу с java --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.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.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.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.transform(cf.parse(bytes), classTransform);

Тестирование

У Class-File API большая поверхность, и он должен генерировать классы в соответствии с Java Virtual Machine Specification (спецификацией виртуальной машины Java), поэтому потребуется серьёзное тестирование качества и соответствия. Кроме того, по мере того как мы будем заменять использование ASM в JDK на Class-File API, мы будем сравнивать результаты работы обеих библиотек, чтобы выявлять регрессии, и проводить обширное тестирование производительности, чтобы выявлять и не допускать регрессий производительности.

Альтернативы

Очевидная идея — «просто» включить ASM в JDK и взять на себя ответственность за его дальнейшее сопровождение, но это неправильный выбор. ASM — старая кодовая база с большим унаследованным багажом. Её трудно развивать, а приоритеты проектирования, определившие её архитектуру, скорее всего, не те, что мы выбрали бы сегодня. Более того, язык Java существенно улучшился с момента создания ASM, поэтому то, что могло быть лучшими идиомами API в 2002 году, может оказаться неидеальным два десятилетия спустя.