JEP 484:类文件 API
概括
提供解析、生成和转换Java类文件的标准API。
历史
Class -File API最初由JDK 22中的JEP 457作为预览功能提出,并由JDK 23中的JEP 466完善。我们在此建议根据进一步的经验和反馈,在JDK 24中对该 API 进行细微更改(详情如下)。
目标
-
提供用于处理类文件的 API,该 API 跟踪Java 虚拟机规范
class
定义的文件格式。 -
使 JDK 组件迁移到标准 API,并最终删除 JDK 内部的第三方 ASM 库副本。
非目标
-
我们的目标并不是淘汰现有的处理类文件的库,也不是成为世界上最快的类文件库。
-
扩展核心反射 API来访问已加载类的字节码并不是我们的目标。
-
我们的目标不是提供代码分析功能;它可以通过第三方库置于 Class-File 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。
元素、构建器和转换
Class-File API 位于java.lang.classfile
包和子包中。它定义了三个主要抽象:
-
元素是类文件某个部分的不可变描述;它可能是指令、属性、字段、方法或整个类文件。有些元素(例如方法)是_复合__元素_;除了作为元素之外,它们还包含自己的元素,可以整体处理或进一步分解。
-
每种复合元素都有相应的_构建器_,该构建器具有特定的构建方法(例如
ClassBuilder::withMethod
),并且也是Consumer
适当元素类型的。 -
最后,_转换_表示一个函数,该函数接受一个元素和一个构建器,并调解如何将该元素转换为其他元素(如果有的话)。
我们通过展示如何使用它来解析类文件、生成类文件以及将解析和生成结合到转换中来介绍 API。
使用模式解析类文件
ASM 的类文件流视图是基于访问者的。访问者庞大且不灵活;访问者模式通常被描述为语言中缺乏模式匹配的库解决方法。现在 Java 语言具有模式匹配功能,我们可以更直接、更简洁地表达事物。例如,如果我们想遍历属性Code
并收集类依赖关系图的依赖关系,那么我们可以简单地遍历指令并匹配我们认为有趣的指令。ACodeModel
描述一个Code
属性;我们可以遍历它的CodeElement
s 并处理那些包含对其他类型的符号引用的属性:
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
。Class MethodVisitor
-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-File API 中的解析和生成方法排列整齐,因此转换无缝衔接。上面的解析示例遍历了一系列CodeElement
s,让客户端与各个元素进行匹配。构建器接受CodeElement
s,这样典型的转换习惯用法就自然而然地出现了。
假设我们要处理一个类文件,并保持所有内容不变,除了删除名称以 开头的方法"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);
}
});
通过将实体分解为元素并检查每个元素来浏览类文件树涉及一些在多个级别重复的样板。这个习语对于所有遍历来说都是通用的,因此库应该对此有所帮助。获取类文件实体、获取相应的构建器、检查实体的每个元素并可能将其替换为其他元素的常见模式可以通过_转换来表达,这些转换由__转换方法_应用。
转换接受一个构建器和一个元素。它可以用其他元素替换该元素、删除该元素或将元素传递给构建器。转换是函数式接口,因此转换逻辑可以用 lambda 捕获。
转换方法将相关元数据(名称、标志等)从复合元素复制到构建器,然后通过应用转换来处理复合元素,处理重复的爆炸和迭代。
通过变换,我们可以将前面的例子重写为:
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);
}
};
然后,我们可以将代码元素上的转换_提升_Code
为方法元素上的转换。当提升后的转换看到一个属性时,它会使用代码转换对其进行转换,并让所有其他方法元素保持不变:
MethodTransform methodTransform = MethodTransform.transformingCode(codeTransform);
我们可以再次执行相同操作,将方法元素上的结果转换提升为类元素上的转换:
ClassTransform classTransform = ClassTransform.transformingMethods(methodTransform);
现在我们的例子变得简单了:
ClassFile cf = ClassFile.of();
byte[] newBytes = cf.transformClass(cf.parse(bytes), classTransform);
更改
以下是自第二次预览以来更改的详细列表:
-
枚举值重命名:
-
移动并重命名的字段:
AttributesProcessingOption.DROP_UNSTABLE_ATRIBUTES
→DROP_UNSTABLE_ATTRIBUTES
ClassFile.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 习语在二十年后可能并不理想。