Skip to content
欢迎扫码关注公众号

JEP 484: Class-File API | 类文件 API

概述

提供用于解析、生成和转换 Java 类文件的标准 API。

历史

Class-File API 最初由 JEP 457JDK 22 中作为预览功能提出,并在 JDK 23 中通过 JEP 466 进行了改进。我们在此提议基于进一步的经验和反馈,在 JDK 24 中以较小的改动(详见 下文)最终确定该 API。

目标

  • 提供一个处理类文件的 API,该 API 跟踪由 Java 虚拟机规范 定义的 class 文件格式。
  • 使 JDK 组件能够迁移到标准 API,并最终移除 JDK 内部对第三方 ASM 库的副本。

非目标

  • 并不旨在淘汰现有的处理类文件的库,也不是要成为世界上最快的类文件库。
  • 并不打算扩展 核心反射 API 以访问加载类的字节码。
  • 并不打算提供代码分析功能;这些功能可以通过第三方库在 Class-File API 之上构建。

动机

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

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

因为类文件格式每六个月就可能进化一次,框架越来越频繁地遇到其捆绑的类文件库版本更新的类文件。这种版本偏移导致应用开发者可见的错误,或者更糟糕的是,框架开发者尝试编写代码来解析未来的类文件,并且相信不会有太严重的变化。框架开发者需要一个可以信任与运行时 JDK 保持最新状态的类文件库。

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

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

说明

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

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

  • 树形结构表示 —— 类文件具有树形结构。一个类包含一些元数据(名称、父类等)以及数量可变的字段、方法和属性。字段和方法本身也包含元数据,并进一步包含属性,包括 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。

使用模式解析类文件

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

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

迭代的样板代码消失了,但深入嵌套的 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.transformClass(cf.parse(bytes), classTransform);

变更

以下是自第二个预览版以来的详细变更列表:

测试

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

替代方案

一个明显的想法是“直接”将 ASM 合并到 JDK 中并承担其后续维护的责任,但这不是正确的选择。ASM 是一个带有大量遗留负担的老代码库。它难以进化,而且影响其架构设计优先级的因素可能与我们今天的选择不同。此外,自 ASM 创建以来,Java 语言已经有了实质性的改进,所以在 2002 年可能是最佳 API 模式的东西在二十年后可能并不理想。