JEP 484: Class-File API
概要
提供用于解析、生成和转换 Java 类文件的标准 API。
历史
目标
-
提供一个用于处理类文件的 API,该 API 跟踪由 Java 虚拟机规范定义的
class
文件格式。 -
使 JDK 组件能够迁移到标准 API,并最终移除 JDK 的内部第三方 ASM 库副本。
非目标
-
不以淘汰现有的处理类文件的库为目标,也不以成为世界上最快的类文件库为目标。
-
不以扩展核心反射 API 以访问已加载类的字节码为目标。
-
不提供代码分析功能;这可以通过第三方库在 Class-File API 之上实现。
动机
类文件是 Java 生态系统中的通用语言。解析、生成和转换类文件是无处不在的,因为它允许独立的工具和库检查和扩展程序,而不会危及源代码的可维护性。例如,框架使用即时字节码转换来透明地添加功能,这些功能对于应用程序开发者来说,如果不是不可能的话,也很难在源代码中包含。
因为类文件格式可以每六个月演进一次,框架越来越频繁地遇到比它们捆绑的类文件库更新的类文件。这种版本偏差导致应用程序开发者可见的错误,或者更糟糕的是,框架开发者试图编写解析未来类文件的代码,并且抱着一种信念,即不会发生太严重的改变。框架开发者需要一个类文件库,他们可以信任这个库是与运行中的 JDK 保持同步更新的。
JDK 在 javac
编译器内部有自己的类文件库。它还捆绑了 ASM 来实现诸如 jar
和 jlink
之类的工具,并支持在运行时实现 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,而不是永远依赖第三方开发人员更新和测试他们的类文件库。使用标准 API 的框架和工具将自动支持来自最新 JDK 的类文件,这样具有类文件表示的新语言和虚拟机特性就可以快速轻松地被采用。
描述
我们为 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。
使用模式解析类文件
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();
MethodVisitor
在 ASM 中既充当访问者又充当构建器。客户端可以直接创建一个 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 也可以选择性地管理局部变量的块作用域分配,使客户端不必再进行局部变量槽位的记录工作。
转换类文件
类文件 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);
}
});
遍历类文件树,通过将实体分解为元素并检查每个元素,这涉及到一些在多个层级重复的样板代码。这种惯用法在所有遍历中都很常见,因此库应该在这方面提供帮助。对于类文件实体,获取相应的构建器,检查实体的每个元素并可能用其他元素替换它的这种通用模式,可以通过转换来表达,这些转换由转换方法应用。
转换接受一个构建器和一个元素。它可以用其他元素替换该元素,删除该元素,或者将该元素传递给构建器。转换是函数式接口,因此转换逻辑可以使用 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()
测试
类文件 API 的覆盖面很广,并且必须生成符合 Java 虚拟机规范的类,因此需要进行大量的质量和一致性测试。此外,在我们将 JDK 中对 ASM 的使用替换为对类文件 API 的使用时,我们将比较使用这两个库的结果以检测回归,并进行广泛的性能测试以检测并避免性能回归。
替代方案
一个显而易见的想法是“仅仅”将 ASM 合并到 JDK 中,并承担其持续维护的责任,但这不是正确的选择。ASM 是一个古老的代码库,带有大量的遗留负担。它很难进化,而且影响其架构的设计优先级可能不是我们今天会选择的。此外,自从 ASM 创建以来,Java 语言已经有了实质性的改进,因此在 2002 年可能是最好的 API 习惯用法在二十年后可能并不理想。