JEP 484: Class-File API
作者 Brian Goetz
所有者 Adam Sotona
类型 Feature
范围 SE
状态 Closed / Delivered
发布 24
组件 core-libs / java.lang.classfile
讨论 core-dash-libs-dash-dev at openjdk dot org
工作量 S
持续时间 M
相关 JEP 466: Class-File API (第二次预览)
审阅者 Paul Sandoz
认可者 Paul Sandoz
创建时间 2024/06/21 08:36
更新时间 2025/02/13 18:37
Issue 8334712
摘要
提供一个用于解析、生成和转换 Java class 文件的标准 API。
历史
Class-File API 最初由 JDK 22 中的 JEP 457 提议作为预览功能,并在 JDK 23 中由 JEP 466 进行了改进。我们在此提议在 JDK 24 中最终确定该 API,并根据进一步的经验和反馈进行少量更改(详见下文)。
目标
- 提供一个处理 class 文件的 API,该 API 跟踪 Java 虚拟机规范定义的
class文件格式。 - 使 JDK 组件能够迁移到标准 API,并最终移除 JDK 内部包含的第三方 ASM 库副本。
非目标
- 无意废弃现有的处理 class 文件的库,也无意成为世界上最快的 class 文件库。
- 无意扩展 Core Reflection API 以提供对已加载类的字节码的访问。
- 无意提供代码分析功能;这可以通过第三方库在 Class-File API 之上实现。
动机
Class 文件是 Java 生态系统的通用语言。解析、生成和转换 class 文件无处不在,因为它允许独立的工具和库检查和扩展程序,而不会危及源代码的可维护性。例如,框架使用动态字节码转换来透明地添加功能,这些功能对于应用程序开发人员来说,即使不是不可能,在源代码中实现也是不切实际的。
Java 生态系统有许多用于解析和生成 class 文件的库,每个库都有不同的设计目标、优势和劣势。处理 class 文件的框架通常会捆绑一个 class 文件库,例如 ASM、BCEL 或 Javassist。然而,对于 class 文件库来说,一个重要的问题是,由于 JDK 的六个月发布节奏,class 文件格式的演变速度比过去更快。近年来,class 文件格式已经演变以支持 Java 语言特性(如 sealed classes),并公开 JVM 特性(如 dynamic constants 和 nestmates)。随着即将推出的特性(如 value classes 和 generic method specialization),这种趋势将继续下去。
由于 class 文件格式可能每六个月演变一次,框架更频繁地遇到比它们捆绑的 class 文件库更新的 class 文件。这种版本偏差会导致应用程序开发人员可见的错误,或者更糟的是,框架开发人员试图编写代码来解析未来的 class 文件,并寄希望于不会发生太严重的变化。框架开发人员需要一个可以信任的、与正在运行的 JDK 保持同步的 class 文件库。
JDK 在 javac 编译器内部有自己的 class 文件库。它还捆绑了 ASM 来实现 jar 和 jlink 等工具,并支持在运行时实现 lambda 表达式。不幸的是,JDK 使用第三方库导致了整个生态系统在采用新的 class 文件特性方面出现了令人厌烦的延迟。JDK N 的 ASM 版本直到 JDK N 最终确定后才能最终确定,因此 JDK N 中的工具无法处理 JDK N 中新增的 class 文件特性,这意味着 javac 直到 JDK N+1 才能安全地发出 JDK N 中新增的 class 文件特性。当 JDK N 是一个备受期待的版本(例如 JDK 21)并且开发人员渴望编写需要使用新 class 文件特性的程序时,这个问题尤其突出。
Java 平台应该定义并实现一个与 class 文件格式共同演进的标准 class 文件 API。平台的组件将能够仅依赖此 API,而不是永远依赖第三方开发人员更新和测试其 class 文件库的意愿。使用标准 API 的框架和工具将自动支持最新 JDK 的 class 文件,这样,在 class 文件中有表示的新语言和 VM 特性就可以快速轻松地被采用。
描述
我们为 Class-File API 采用了以下设计目标和原则。
- Class 文件实体由不可变对象表示 — 所有 class 文件实体,例如字段、方法、属性、字节码指令、注解等,都由不可变对象表示。这有助于在转换 class 文件时实现可靠的共享。
- 树状结构表示 — Class 文件具有树状结构。一个类有一些元数据(名称、超类等),以及可变数量的字段、方法和属性。字段和方法本身具有元数据,并进一步包含属性,包括
Code属性。Code属性进一步包含指令、异常处理程序等。用于导航和构建 class 文件的 API 应反映这种结构。 - 用户驱动的导航 — 我们遍历 class 文件树的路径由用户选择驱动。如果用户只关心字段上的注解,那么我们只需要解析到
field_info结构内部的 annotation attributes;我们不必查看任何类属性或方法体,或字段的其他属性。用户应该能够根据需要将复合实体(例如方法)作为单个单元处理,或者作为其组成部分的流处理。 - 惰性 — 用户驱动的导航可以显着提高效率,例如,仅解析满足用户需求的 class 文件部分。如果用户不打算深入研究方法的内容,那么我们就不需要解析
method_info结构中超出确定下一个 class 文件元素起始位置所需的部分。当用户请求时,我们可以惰性地填充(并缓存)完整的表示。 - 统一的流式和物化视图 — 与 ASM 类似,我们希望支持 class 文件的流式视图和物化视图。流式视图适用于大多数用例,而物化视图更通用,因为它支持随机访问。通过惰性(由不可变性实现),我们可以比 ASM 更经济地提供物化视图。此外,我们可以统一流式视图和物化视图,使它们使用共同的词汇表,并可以根据每个用例的方便性协调使用。
- 涌现式转换 — 如果 class 文件解析和生成 API 足够一致,那么转换可以成为一种涌现属性,不需要自己特殊的模式或重要的新的 API 表面。(ASM 通过为读取器和写入器使用通用的访问者结构来实现这一点。)如果类、字段、方法和代码体可以作为元素流读取和写入,那么转换可以被视为对该流的
flat-map操作,由 lambda 定义。 - 隐藏细节 — class 文件的许多部分(常量池、引导方法表、堆栈映射等)是从 class 文件的其他部分派生出来的。要求用户直接构建这些部分是没有意义的;这对用户来说是额外的工作,并增加了出错的可能性。API 将根据添加到 class 文件中的字段、方法和指令,自动生成与其他实体紧密耦合的实体。
- 拥抱语言 — 在 2002 年,ASM 使用的访问者方法似乎很聪明,而且肯定比之前的更易于使用。然而,从那时起,Java 编程语言已经取得了巨大的进步——引入了 lambdas、records、sealed classes 和 pattern matching——并且 Java 平台现在有一个用于描述 class 文件常量的标准 API (
java.lang.constant)。我们可以利用这些特性来设计一个更灵活、更易于使用、更少冗长且更不容易出错的 API。
Elements, builders, 和 transforms
Class-File API 位于 java.lang.classfile 包及其子包中。它定义了三个主要抽象:
element是 class 文件某个部分的不可变描述;它可以是指令、属性、字段、方法或整个 class 文件。一些element,例如方法,是复合element;除了是element之外,它们还包含自己的element,并且可以整体处理或进一步分解。- 每种复合
element都有一个对应的builder,它具有特定的构建方法(例如ClassBuilder::withMethod),并且也是相应element类型的Consumer。 - 最后,
transform表示一个函数,它接受一个element和一个builder,并调节该element如何(如果需要的话)转换为其他element。
我们通过展示如何使用该 API 来解析 class 文件、生成 class 文件以及将解析和生成组合成转换来介绍该 API。
使用模式解析 class 文件
ASM 的 class 文件流式视图是基于访问者的。访问者既笨重又不灵活;访问者模式通常被描述为库对语言中缺少模式匹配的一种变通方法。既然 Java 语言有了模式匹配,我们就可以更直接、更简洁地表达事物。例如,如果我们想遍历一个 Code 属性并为一个类依赖图收集依赖关系,我们可以简单地迭代指令并匹配我们感兴趣的指令。CodeModel 描述了一个 Code 属性;我们可以迭代它的 CodeElements 并处理那些包含对其他类型的符号引用的元素:
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 ...
}
}
使用 builder 生成 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();
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_();
}));
这更具体、更透明——builder 有许多方便的方法,例如 aload(n)——但尚未更简洁或更高级。然而,已经存在一个强大的隐藏好处:通过在 lambda 中捕获操作序列,我们获得了重放的可能性,这使得库能够完成以前客户端必须完成的工作。例如,分支偏移量可以是短的或长的。如果客户端命令式地生成指令,他们必须在生成分支时计算每个分支偏移量的大小,这既复杂又容易出错。但是,如果客户端提供一个接受 builder 的 lambda,库可以乐观地尝试使用短偏移量生成方法,如果失败,则丢弃生成的状态并使用不同的代码生成参数重新调用 lambda。
将 builder 与访问解耦还使我们能够提供更高级别的便利来管理块作用域和局部变量索引计算,并允许我们消除手动标签管理和分支:
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 中的解析和生成方法排列整齐,使得转换无缝衔接。上面的解析示例遍历了一系列 CodeElements,让客户端可以匹配单个元素。builder 接受 CodeElements,因此典型的转换习语自然而然地出现了。
假设我们想要处理一个 class 文件并保持所有内容不变,除了移除名称以 "debug" 开头的方法。我们将获取一个 ClassModel,创建一个 ClassBuilder,迭代原始 ClassModel 的元素,并将除我们想要删除的方法之外的所有元素传递给 builder:
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 文件实体、获取相应的 builder、检查实体的每个元素并可能用其他元素替换它的常见模式可以通过 transforms 来表达,这些 transforms 由 transformation methods 应用。
transform 接受一个 builder 和一个 element。它要么用其他 element 替换该 element,要么删除该 element,要么将该 element 传递给 builder。transform 是函数式接口,因此转换逻辑可以封装在 lambda 中。
转换方法将相关元数据(名称、标志等)从复合 element 复制到 builder,然后通过应用 transform 来处理复合 element 的元素,处理重复的分解和迭代。
使用转换,我们可以将前面的示例重写为:
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 来简化这一点:
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);
}
};
然后我们可以将这个对代码元素的 transform 提升为一个对方法元素的 transform。当提升后的 transform 看到一个 Code 属性时,它会使用代码 transform 对其进行转换,并将所有其他方法元素原封不动地传递:
MethodTransform methodTransform = MethodTransform.transformingCode(codeTransform);
我们可以再次这样做,将生成的对方法元素的 transform 提升为一个对类元素的 transform:
ClassTransform classTransform = ClassTransform.transformingMethods(methodTransform);
现在我们的示例变得很简单:
ClassFile cf = ClassFile.of();
byte[] newBytes = cf.transformClass(cf.parse(bytes), classTransform);
变更
以下是自第二次预览以来的详细变更列表:
-
Enum 值重命名:
-
字段移动和重命名:
AttributesProcessingOption.DROP_UNSTABLE_ATRIBUTES→DROP_UNSTABLE_ATTRIBUTESClassFile.AEV_*→AnnotationValue.TAG_*ClassFile.CRT_*→CharacterRange.FLAG_*ClassFile.TAG_*→PoolEntry.TAG_*ClassFile.TAT_*→TypeAnnotation.TARGET_*ClassFile.VT_*→StackMapFrameInfo.VerificationTypeInfo.ITEM_*StackMapFrameInfo.SimpleVerificationTypeInfo.ITEM_*→*
-
字段因不必要的暴露或冗余而被移除:
-
方法添加:
-
方法重载添加:
-
方法重命名:
-
方法从一个接口移动到另一个接口:
-
方法返回类型更改:
-
超接口移除:
-
接口签名更改:
-
接口因不必要的实现内部暴露而被移除:
-
方法因不必要的实现内部暴露或作为冗余替代方案而被移除:
AccessFlags::ofClass(AccessFlag ...)AccessFlags::ofClass(int)AccessFlags::ofField(AccessFlag ...)AccessFlags::ofField(int)AccessFlags::ofMethod(AccessFlag ...)AccessFlags::ofMethod(int)BufWriter::copyTo(byte[], int)BufWriter::writeBytes(BufWriter)BufWriter::writeListIndices(List<? extends PoolEntry>)ClassBuilder::original()ClassFileBuilder::canWriteDirect(ConstantPool)ClassFileTransform::resolve(B)ClassReader::readClassEntry(int)ClassReader::readMethodHandleEntry(int)ClassReader::readModuleEntry(int)ClassReader::readNameAndTypeEntry(int)ClassReader::readPackageEntry(int)ClassReader::readUtf8Entry(int)ClassReader::readUtf8EntryOrNull(int)ClassTransform::resolve(ClassBuilder)CodeBuilder::loadConstant(Opcode, ConstantDesc)CodeBuilder::original()CodeRelabeler::relabel(Label, CodeBuilder)CodeTransform::resolve(CodeBuilder)CompoundElement::elements()ConstantPoolBuilder::annotationConstantValueEntry(ConstantDesc)ConstantPoolBuilder::writeBootstrapMethods(BufWriter)FieldBuilder::original()FieldTransform::resolve(FieldBuilder)MethodBuilder::original()MethodTransform::resolve(MethodBuilder)ModuleAttributeBuilder::build()Opcode::constantValue()Opcode::isUnconditionalBranch()Opcode::primaryTypeKind()Opcode::secondaryTypeKind()Opcode::slot()TypeKind::descriptor()TypeKind::typeName()
测试
Class-File API 的接口范围很大,并且必须生成符合 Java 虚拟机规范的类,因此需要进行大量的质量和一致性测试。此外,在我们用 Class-File API 替换 JDK 中 ASM 的使用时,我们将比较使用这两个库的结果以检测回归,并进行广泛的性能测试以检测和避免性能回归。
替代方案
一个显而易见的想法是"仅仅"将 ASM 合并到 JDK 中并承担其持续维护的责任,但这并非正确的选择。ASM 是一个旧的代码库,带有许多历史包袱。它难以演进,并且指导其架构的设计优先级可能不是我们今天会选择的。此外,自 ASM 创建以来,Java 语言已经有了实质性的改进,因此在 2002 年可能是最佳 API 用法的,但二十年后可能不同了。
1462

被折叠的 条评论
为什么被折叠?



