Skip to content

JEP 466: Class-File API (Second Preview) | 类文件 API(第二次预览)

摘要

提供一个用于解析、生成和转换 Java 类文件的标准 API。这是一个 预览 API

历史

类文件 APIJEP 457JDK 22 中作为预览特性提出。我们在此基于经验和反馈提出第二次预览,并进行了改进。在这次预览中,我们:

  • 简化了 CodeBuilder 类。这个类有三种用于字节码指令的工厂方法:低级工厂方法、中级工厂方法和用于基本块的高级构建器。根据反馈,我们删除了重复低级方法或很少使用的中级方法,并重新命名了剩余的中级方法以提高可用性。

  • Attributes 中的 AttributeMapper 实例现在可以通过静态方法而不是静态字段访问,以允许延迟初始化并降低 Java 启动成本。

  • Signature.TypeArg 重塑为代数数据类型,以便在我们知道 TypeArg 的种类是有界的情况下更容易访问绑定类型。

  • 添加了具有类型感知的 ClassReader.readEntryOrNullConstantPool.entryByIndex,如果索引处的条目不是所需类型,则抛出 ConstantPoolException 而不是 ClassCastException。这允许类文件处理器指示常量池条目类型不匹配是类文件格式问题而不是处理器的问题。

  • 改进了 ClassSignature 类,以更准确地建模超类和超接口的泛型签名。

  • 修复了 TypeKind 中的命名不一致错误。

  • 删除了 ClassReader 中的实现细节方法。

目标

  • 提供一个用于处理类文件的 API,该 API 跟踪由 Java 虚拟机规范 定义的 class 文件格式。

  • 使 JDK 组件能够迁移到标准 API,并最终删除 JDK 内部的第三方 ASM 库副本。

非目标

  • 不是要使现有的处理类文件的库过时,也不是要成为世界上最快的类文件库。

  • 不是要扩展 核心反射 API 以提供对已加载类的字节码的访问。

  • 不是要提供代码分析功能;可以通过第三方库在类文件 API 之上分层实现。

动机

类文件是 Java 生态系统的通用语言。解析、生成和转换类文件无处不在,因为它允许独立的工具和库检查和扩展程序,而不会危及源代码的可维护性。例如,框架使用即时字节码转换来透明地添加对于应用程序开发人员来说如果不是不可能的话也是不切实际的功能到源代码中。

Java 生态系统有许多用于解析和生成类文件的库,每个库都有不同的设计目标、优势和劣势。处理类文件的框架通常会捆绑一个类文件库,如 ASMBCELJavassist。然而,类文件库的一个重要问题是,由于 JDK 的 六个月发布周期类文件格式 的发展比过去更快。近年来,类文件格式已经发展为支持 Java 语言特性,如 密封类,并暴露 JVM 特性,如 动态常量嵌套伙伴。随着即将推出的特性,如 值类 和泛型方法特化,这种趋势将继续。

因为类文件格式每六个月就可以演变一次,所以框架更频繁地遇到比它们捆绑的类文件库更新的类文件。这种版本偏差会导致应用程序开发人员看到错误,或者更糟糕的是,框架开发人员试图编写代码来解析来自未来的类文件,并进行信仰飞跃,认为不会有太严重的变化。框架开发人员需要一个他们可以信任的类文件库,该库与正在运行的 JDK 保持同步。

JDK 在 javac 编译器内部有自己的类文件库。它还捆绑了 ASM 以实现诸如 jarjlink 之类的工具,并在运行时支持 lambda 表达式的实现。不幸的是,JDK 使用第三方库导致整个生态系统对新类文件特性的采用出现了令人厌烦的延迟。JDK N 的 ASM 版本在 JDK N 最终确定后才能最终确定,因此 JDK N 中的工具无法处理 JDK N 中的新类文件特性,这意味着在 JDK N + 1 之前,javac 不能安全地发出 JDK N 中的新类文件特性。当 JDK N 是一个备受期待的版本,如 JDK 21 时,这尤其成问题,并且开发人员渴望编写需要使用新类文件特性的程序。

Java 平台应该定义并实现一个与类文件格式一起演变的标准类文件 API。平台的组件将能够仅依赖这个 API,而不是永远依赖第三方开发人员更新和测试他们的类文件库的意愿。使用标准 API 的框架和工具将自动支持来自最新 JDK 的类文件,以便可以快速轻松地采用在类文件中有表示的新语言和 VM 特性。

描述

我们为 类文件 API 采用了以下设计目标和原则。

  • 类文件实体由不可变对象表示——所有类文件实体,如字段、方法、属性、字节码指令、注释等,都由不可变对象表示。这在类文件被转换时便于可靠地共享。

  • 树状结构表示——类文件具有树结构。一个类有一些元数据(名称、超类等),以及可变数量的字段、方法和属性。字段和方法本身具有元数据,并进一步包含属性,包括 Code 属性。Code 属性进一步包含指令、异常处理程序等。用于导航和构建类文件的 API 应该反映这种结构。

  • 用户驱动的导航——我们通过类文件树的路径是由用户选择驱动的。如果用户只关心字段上的注释,那么我们只需要解析到 field_info 结构内的注释属性即可;我们不必查看任何类属性、方法体或字段的其他属性。用户应该能够根据需要将复合实体(如方法)作为单个单元或其组成部分的流进行处理。

  • 延迟加载——用户驱动的导航实现了显著的效率,例如只解析满足用户需求所需的类文件部分。如果用户不打算深入研究方法的内容,那么我们只需要解析足够确定下一个类文件元素开始位置的 method_info 结构部分。当用户请求时,我们可以延迟加载并缓存完整表示。

  • 统一的流和具体化视图——像 ASM 一样,我们希望支持类文件的流视图和具体化视图。流视图适用于大多数用例,而具体化视图更通用,因为它支持随机访问。通过不可变性实现的延迟加载,我们可以比 ASM 更便宜地提供具体化视图。此外,我们可以对齐流视图和具体化视图,以便它们使用共同的词汇表并可以根据每个用例方便地协同使用。

  • 涌现式转换——如果类文件解析和生成 API 足够对齐,那么转换可以是一种不需要自己的特殊模式或大量新 API 表面的涌现属性。(ASM 通过对读取器和写入器使用共同的访问者结构来实现这一点。)如果类、字段、方法和代码体可以作为元素流进行可读和可写,那么转换可以被视为对这个流的 flat-map 操作,由 lambda 定义。

  • 细节隐藏——类文件的许多部分(常量池、引导方法表、栈映射等)是从类文件的其他部分派生的。要求用户直接构建这些是没有意义的;这对用户来说是额外的工作,并且增加了出错的机会。API 将根据添加到类文件中的字段、方法和指令自动生成与其他实体紧密耦合的实体。

  • 利用语言特性——在 2002 年,ASM 使用的访问者方法似乎很聪明,并且肯定比以前的方法更易于使用。然而,自那时以来,Java 编程语言有了巨大的改进——引入了 lambda、记录、密封类和模式匹配——并且 Java 平台现在有一个用于描述类文件常量的标准 API(java.lang.constant)。我们可以使用这些特性来设计一个更灵活、更易于使用、更简洁且不易出错的 API。

元素、构建器和转换

类文件 API 位于 java.lang.classfile 包和子包中。它定义了三个主要抽象:

  • 一个 元素 是类文件某一部分的不可变描述;它可以是一条指令、一个属性、一个字段、一个方法或一个完整的类文件。一些元素,如方法,是 复合元素;除了是元素之外,它们还包含自己的元素,并且可以作为整体处理或进一步分解。

  • 每种复合元素都有一个相应的 构建器,它具有特定的构建方法(例如,ClassBuilder::withMethod),并且也是适当元素类型的 Consumer

  • 最后,一个 转换 表示一个函数,它接受一个元素和一个构建器,并调解该元素如何(如果有的话)转换为其他元素。

我们通过展示如何使用它来解析类文件、生成类文件以及将解析和生成组合成转换来介绍这个 API。

这是 预览 API,默认情况下禁用。

要在 JDK 23 中尝试下面的示例,你必须按如下方式启用预览特性:

  • 使用 javac --release 23 --enable-preview Main.java 编译程序,并使用 java --enable-preview Main 运行它;或者,

  • 当使用 源代码启动器 时,使用 java --source 23 --enable-preview Main.java 运行程序。

使用模式解析类文件

ASM 对类文件的流视图是基于访问者的。访问者庞大且不灵活;访问者模式通常被描述为一种在语言缺乏模式匹配时的库解决方案。现在 Java 语言有了模式匹配,我们可以更直接和简洁地表达事物。例如,如果我们想要遍历一个 Code 属性并为类依赖图收集依赖关系,那么我们可以简单地遍历指令并匹配我们感兴趣的指令。一个 CodeModel 描述一个 Code 属性;我们可以遍历它的 CodeElement 并处理那些包含对其他类型的符号引用的元素:

java
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());
       ... 等等对于 instanceof、cast 等...
    }
}

使用构建器生成类文件

假设我们希望在类文件中生成以下方法:

java
void fooBar(boolean z, int x) {
    if (z)
        foo(x);
    else
        bar(x);
}

使用 ASM,我们可以如下生成这个方法:

java
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();

ASM 中的 MethodVisitor 既用作访问者又用作构建器。客户端可以直接创建一个 ClassWriter,然后可以向 ClassWriter 请求一个 MethodVisitor。类文件 API 颠倒了这种习惯用法:不是客户端使用构造函数或工厂创建构建器,而是客户端提供一个接受构建器的 lambda:

java
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)——但还没有更简洁或更高层次。然而,已经有一个强大的隐藏好处:通过在 lambda 中捕获操作序列,我们获得了 重放 的可能性,这使得库能够完成以前客户端必须完成的工作。例如,分支偏移量可以是短的或长的。如果客户端强制生成指令,那么他们在生成分支时必须计算每个分支偏移量的大小,这很复杂且容易出错。但是,如果客户端提供一个接受构建器的 lambda,那么库可以乐观地尝试使用短偏移量生成方法,如果失败,丢弃生成的状态并使用不同的代码生成参数重新调用 lambda。

将构建器与访问解耦还让我们能够提供更高层次的便利来管理块作用域和局部变量索引计算,并允许我们消除手动标签管理和分支:

java
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_();
});

因为块作用域由类文件 API 管理,所以我们不必生成标签或分支指令——它们会为我们插入。类似地,类文件 API 可以可选地管理块作用域的局部变量分配,从而也使客户端摆脱了局部变量槽的记账工作。

转换类文件

类文件 API 中的解析和生成方法是一致的,因此转换是无缝的。上面的解析示例遍历了一系列 CodeElement,让客户端匹配各个元素。构建器接受 CodeElement,因此典型的转换习惯用法自然出现。

假设我们想要处理一个类文件,并且除了删除名称以“debug”开头的方法之外,保持所有内容不变。我们将获得一个 ClassModel,创建一个 ClassBuilder,遍历原始 ClassModel 的元素,并将所有元素传递给构建器,除了我们想要删除的方法:

java
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 上的方法调用:

java
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);
            }
        });

通过将实体分解为元素并检查每个元素来导航类文件树涉及一些在多个级别重复的样板代码。这种习惯用法对所有遍历都是通用的,因此这是库应该帮助的事情。获取类文件实体、获取相应的构建器、检查实体的每个元素并可能用其他元素替换它的常见模式可以通过 转换 来表示,转换由 转换方法 应用。

转换接受一个构建器和一个元素。它要么用其他元素替换该元素,要么删除该元素,要么将该元素传递给构建器。转换是函数式接口,因此转换逻辑可以捕获在 lambda 中。

转换方法将相关的元数据(名称、标志等)从复合元素复制到构建器中,然后通过应用转换来处理复合元素的元素,处理重复的分解和迭代。

使用转换,我们可以将前面的示例重写为:

java
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);
});

迭代样板代码消失了,但深入嵌套的 lambda 以访问指令仍然令人生畏。我们可以通过将特定于指令的活动提取到 CodeTransform 中来简化这一点:

java
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 属性时,它使用代码转换来转换它,将所有其他方法元素不变地传递过去:

java
MethodTransform methodTransform = MethodTransform.transformingCode(codeTransform);

我们可以再次这样做,将对方法元素的结果转换提升为对类元素的转换:

java
ClassTransform classTransform = ClassTransform.transformingMethods(methodTransform);

现在我们的示例变得简单:

java
ClassFile cf = ClassFile.of();
byte[] newBytes = cf.transform(cf.parse(bytes), classTransform);

测试

类文件 API 有很大的表面积,并且必须生成符合 Java 虚拟机规范的类,因此需要进行大量的质量和一致性测试。此外,在我们用类文件 API 替代 JDK 中对 ASM 的使用的程度上,我们将比较使用这两个库的结果以检测回归,并进行广泛的性能测试以检测并避免性能回归。

替代方案

一个明显的想法是“仅仅”将 ASM 合并到 JDK 中并承担其持续维护的责任,但这不是正确的选择。ASM 是一个旧的代码库,有很多遗留包袱。它很难进化,并且其架构所依据的设计优先级可能不是我们今天会选择的。此外,自 ASM 创建以来,Java 语言有了很大的改进,因此在 2002 年可能是最佳的 API 习惯用法在二十年后可能不是理想的。