JEP 457: Class-File API (Preview) | 类文件 API(预览)
摘要
提供一个用于解析、生成和转换 Java 类文件的标准 API。这是一个 预览版 API。
目标
提供一个处理类文件的 API,该 API 跟踪由 Java 虚拟机规范 定义的
class
文件格式。允许 JDK 组件迁移到标准 API,并最终移除 JDK 中第三方 ASM 库的内部副本。
非目标
不是为了淘汰现有的类文件处理库,也不是为了成为世界上最快的类文件库。
不是为了扩展 核心反射 API 以访问已加载类的字节码。
不是为了提供代码分析功能;这可以通过第三方库在类文件 API 之上分层实现。
动机
类文件是 Java 生态系统的通用语言。解析、生成和转换类文件无处不在,因为它允许独立的工具和库在不影响源代码可维护性的情况下检查和扩展程序。例如,框架使用即时字节码转换来透明地添加功能,这些功能对于应用程序开发人员来说,如果不可能的话,在源代码中包含这些功能将不切实际。
Java 生态系统中有许多用于解析和生成类文件的库,每个库都有不同的设计目标、优势和劣势。处理类文件的框架通常会捆绑一个类文件库,如 ASM、BCEL 或 Javassist。然而,类文件库的一个重大问题是,类文件格式 的演进速度比以往更快,这是由于 JDK 的 每六个月发布一次的节奏 所致。近年来,类文件格式已经发展以支持 Java 语言特性,如 密封类,并公开 JVM 特性,如 动态常量 和 嵌套伙伴。随着即将推出的特性,如 值类 和泛型方法特化,这一趋势将继续下去。
由于类文件格式可以每六个月更新一次,框架更频繁地遇到比它们捆绑的类文件库更新的类文件。这种版本偏差会导致应用程序开发人员可见的错误,或者更糟糕的是,导致框架开发人员试图编写代码来解析未来的类文件,并相信不会有太大的变化。框架开发人员需要一个可以信赖的类文件库,该库与正在运行的 JDK 保持同步。
JDK 在 javac
编译器内部有自己的类文件库。它还捆绑了 ASM 来实现诸如 jar
和 jlink
等工具,并支持在运行时实现 lambda 表达式。不幸的是,JDK 使用第三方库导致整个生态系统中对新类文件特性的采用出现了令人厌倦的延迟。JDK N 的 ASM 版本不能在 JDK N 最终确定之前最终确定,因此 JDK N 中的工具无法处理 JDK N 中新增的类文件特性,这意味着 javac
在 JDK N+1 之前无法安全地发出 JDK N 中新增的类文件特性。当 JDK N 是备受期待的发布版本(如 JDK 21)时,这个问题尤为严重,开发人员渴望编写涉及使用新类文件特性的程序。
Java 平台应该定义并实现一个与类文件格式同步发展的标准类文件 API。平台的组件将能够仅依赖此 API,而不是永远依赖第三方开发人员更新和测试其类文件库的意愿。使用标准 API 的框架和工具将自动支持最新 JDK 的类文件,以便可以快速轻松地采用类文件中表示的新语言和 VM 特性。
描述
我们为 Class-File API 采用了以下设计目标和原则。
类文件实体由不可变对象表示 — 所有类文件实体,如字段、方法、属性、字节码指令、注解等,均由不可变对象表示。这有助于在转换类文件时进行可靠的共享。
树状结构表示 — 类文件具有树状结构。一个类包含一些元数据(名称、超类等),以及可变数量的字段、方法和属性。字段和方法本身具有元数据,并进一步包含属性,包括
Code
属性。Code
属性进一步包含指令、异常处理器等。用于导航和构建类文件的 API 应反映此结构。用户驱动的导航 — 我们在类文件树中的路径由用户选择决定。如果用户只关心字段上的注解,那么我们只需要解析到
field_info
结构内的注解属性即可;我们无需查看任何类属性或方法体,或字段的其他属性。用户应该能够根据需要,将复合实体(如方法)作为单个单元或构成部分的流来处理。惰性 — 用户驱动的导航能够实现显著的效率,例如,不解析比满足用户需求更多的类文件内容。如果用户不打算深入方法的内容,那么我们无需解析比确定下一个类文件元素开始位置所需更多的
method_info
结构。当用户请求时,我们可以懒惰地膨胀并缓存完整表示。统一的流式和具体化视图 — 与 ASM 一样,我们希望支持类文件的流式视图和具体化视图。流式视图适用于大多数用例,而具体化视图更为通用,因为它支持随机访问。与 ASM 相比,我们可以通过不可变性实现的惰性来以更低的成本提供具体化视图。此外,我们可以使流式视图和具体化视图保持一致,以便它们使用共同的词汇表并可根据每个用例的方便性进行协调使用。
涌现式转换 — 如果类文件解析和生成 API 足够对齐,则转换可以成为一种涌现属性,无需其自己的特殊模式或显著的新 API 表面。(ASM 通过为读写器使用共同的访问者结构来实现这一点。)如果类、字段、方法和代码体可以作为元素流进行读写,则可以将转换视为该流上的扁平映射操作,该操作由 lambda 表达式定义。
隐藏细节 — 类文件的许多部分(常量池、引导方法表、栈映射等)都是从类文件的其他部分派生出来的。要求用户直接构造这些部分是没有意义的;这会增加用户的工作量并增加出错的机会。API 将根据添加到类文件的字段、方法和指令自动生成与其他实体紧密耦合的实体。
利用语言特性 — 2002 年,ASM 使用的访问者方法似乎很巧妙,并且肯定比之前的方法更易于使用。然而,自那以后,Java 编程语言已经取得了巨大的进步——引入了 lambda 表达式、记录、密封类和模式匹配——并且 Java 平台现在有一个用于描述类文件常量的标准 API(
java.lang.constant
)。我们可以利用这些特性来设计一个更灵活、更易于使用、更简洁且更不易出错的 API。
元素、构建器和转换器
类文件 API 位于 java.lang.classfile
包及其子包中。它定义了三个主要的抽象概念:
元素 是对类文件某部分的不可变描述;它可能是一条指令、一个属性、一个字段、一个方法或整个类文件。某些元素(如方法)是 复合元素;除了是元素之外,它们还包含自己的元素,可以整体处理或进一步分解。
每种复合元素都有一个对应的 构建器,该构建器具有特定的构建方法(例如,
ClassBuilder::withMethod
),并且也是相应元素类型的Consumer
。最后,转换器 表示一个函数,该函数接受一个元素和一个构建器,并决定(如果有的话)如何将该元素转换为其他元素。
我们通过展示如何使用该 API 来解析类文件、生成类文件以及将解析和生成组合成转换来介绍该 API。
这是 预览 API,默认禁用
要在 JDK 22 中尝试以下示例,您必须按以下方式启用预览功能:
使用
javac --release 22 --enable-preview Main.java
编译程序,并使用java --enable-preview Main
运行它;或者,当使用 源代码启动器 时,使用
java --source 22 --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());
... 以及对instanceof、cast等的类似处理 ...
}
}
使用构建器生成类文件
假设我们希望在类文件中生成以下方法:
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
。类文件 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_();
});
由于块作用域由类文件 API 管理,因此我们无需生成标签或分支指令——它们会为我们自动插入。类似地,类文件 API 还可以选择性地管理局部变量的块作用域分配,从而同样解放客户端对局部变量槽位的簿记工作。
转换类文件
类文件 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.invokeInstruction(i.opcode(),
ClassDesc.of("Bar"),
i.name(), i.type());
default -> codeBuilder.with(e);
}
}
});
}
else
methodBuilder.with(me);
}
});
}
else
classBuilder.with(ce);
}
});
通过将实体分解为元素并检查每个元素来遍历类文件树涉及一些在多个级别上重复的样板代码。这种习惯用法在所有遍历中都很常见,因此是库应该帮助处理的内容。获取类文件实体、获取相应的构建器、检查实体的每个元素并可能用其他元素替换它的常见模式可以通过 转换 来表达,这些转换通过 转换方法 应用。
转换接受一个构建器和一个元素。它要么用其他元素替换该元素,要么删除该元素,要么将该元素传递给构建器。转换是函数式接口,因此转换逻辑可以捕获在 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.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);
});
迭代样板代码已经消失,但使用 lambda 深度嵌套来访问指令仍然令人望而生畏。我们可以通过将特定于指令的活动分解为 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);
测试
类文件 API 具有广泛的表面积,并且必须生成符合 Java 虚拟机规范的类,因此需要进行大量的质量和一致性测试。此外,在 JDK 中将 ASM 的使用替换为类文件 API 的使用时,我们将比较使用这两个库的结果以检测回归,并进行广泛的性能测试以检测和避免性能回归。
替代方案
一个显而易见的想法是“仅仅”将 ASM 合并到 JDK 中,并承担其持续维护的责任,但这并不是正确的选择。ASM 是一个拥有大量遗留问题的旧代码库。它难以发展,其架构所依据的设计优先级可能不是我们今天会选择的。此外,自 ASM 创建以来,Java 语言已经有了很大的改进,因此 2002 年可能是最佳 API 惯用法的元素在二十年后可能并不理想。