跳到主要内容

JEP 466:类文件 API(第二次预览)

QWen Max 中英对照 JEP 466: Class-File API (Second Preview)

总结

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

历史

Class-File APIJDK 22 中由 JEP 457 提议作为预览功能。我们在此提议进行第二次预览,并根据经验和反馈进行了改进。在此预览中,我们有:

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

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

目标

  • 提供一个用于处理类文件的 API,该 API 跟踪由 Java Virtual Machine Specification 定义的 class 文件格式。

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

非目标

  • 本提案的目标并不是要淘汰现有的处理 class 文件的库,也不是要成为世界上最快的 class 文件库。

  • 本提案的目标并不是扩展 核心反射 API 以提供对已加载类的字节码的访问能力。

  • 本提案的目标并不是提供代码分析功能;该功能可以通过第三方库基于 Class-File API 实现。

动机

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

Java 生态系统有许多用于解析和生成类文件的库,每个库都有不同的设计目标、优势和劣势。处理类文件的框架通常会捆绑一个类文件库,例如 ASMBCELJavassist。然而,类文件库面临的一个重要问题是,由于 JDK 的 六个月发布周期类文件格式 的演变速度比以往更快。近年来,类文件格式已经发展到支持 Java 语言特性,例如 密封类(sealed classes),并暴露 JVM 特性,例如 动态常量(dynamic constants)嵌套成员(nestmates)。随着即将推出的功能如 值类(value classes) 和泛型方法特化(generic method specialization),这一趋势将继续下去。

由于类文件格式可以每六个月演变一次,因此框架经常会遇到比它们所捆绑的类文件库更新的类文件。这种版本不匹配会导致应用程序开发者可见的错误,或者更糟糕的是,框架开发者试图编写代码来解析来自未来的类文件,并抱着不会发生太严重的变化的侥幸心理。框架开发者需要一个他们可以信任的、与运行中的 JDK 保持最新的类文件库。

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

Java 平台应该定义并实现一个与 class 文件格式一起发展的标准 class 文件 API。平台的组件将能够完全依赖此 API,而不是永远依赖第三方开发者更新和测试他们的 class 文件库。使用标准 API 的框架和工具将自动支持来自最新 JDK 的 class 文件,因此可以快速轻松地采用在 class 文件中有表示的新语言和虚拟机特性。

描述

我们为 Class-File 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。

元素、构建器和转换

Class-File API 位于 java.lang.classfile 包及其子包中。它定义了三个主要的抽象概念:

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

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

  • 最后,一个 transform(转换) 表示一个函数,该函数接受一个元素和一个构建器,并控制该元素如何(如果需要的话)被转换为其他元素。

我们通过展示如何使用该 API 来解析类文件、生成类文件,以及将解析和生成组合为转换,从而引入这个 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,并处理那些包含对其他类型符号引用的部分:

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

使用构建器生成类文件

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

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

ASM 中的 MethodVisitor 同时充当访问器和构建器。客户端可以直接创建一个 ClassWriter,然后可以向 ClassWriter 请求一个 MethodVisitor。Class-File API 对这种惯用法进行了反转:客户端不再通过构造函数或工厂创建构建器,而是提供一个接受构建器的 lambda 表达式:

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。

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

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,因此典型的转换惯用法自然就出现了。

假设我们要处理一个类文件,并保持所有内容不变,只是删除名称以 "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);
}
});

通过将实体分解为元素并检查每个元素来遍历类文件树涉及一些在多个层次重复的样板代码。这种惯用法在所有遍历中都很常见,因此库应该提供帮助。获取一个类文件实体、获得相应的构建器、检查实体的每个元素并可能将其替换为其他元素的通用模式可以通过 转换 来表达,这些转换由 转换方法 应用。

一个转换(transform)接受一个构建器(builder)和一个元素。它要么用其他元素替换该元素,要么丢弃该元素,要么将该元素传递给构建器。转换是函数式接口,因此转换逻辑可以用 lambda 表达式来捕获。

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

通过转换,我们可以将前面的例子重写为:

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 中来简化这一点:

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 虚拟机规范》生成类,因此需要进行大量的质量和一致性测试。此外,在我们用 Class-File API 替换 JDK 中 ASM 的使用时,会将两个库的使用结果进行对比,以检测回归问题,并进行广泛的性能测试,从而发现并避免性能退化。

替代方案

一个显而易见的想法是“直接”将 ASM 合并到 JDK 中,并承担其后续维护的责任,但这并不是正确的选择。ASM 是一个老旧的代码库,带有大量遗留包袱。它难以演进,而且影响其架构的设计优先级可能与我们今天的选择大相径庭。此外,自 ASM 创建以来,Java 语言已经有了显著的改进,因此 2002 年时的最佳 API 惯用法在二十年后可能并不理想。