JEP 466:类文件 API(第二预览版)
概括
提供用于解析、生成和转换 Java 类文件的标准 API。这是一个预览 API。
历史
类文件 API是由JDK 22中的JEP 457提议作为预览功能的。我们在此提出第二个预览,并根据经验和反馈进行改进。在此预览中,我们有:
-
精简了
CodeBuilder
课堂。该类具有三种字节码指令的工厂方法:低级工厂、中级工厂和基本块的高级构建器。根据反馈,我们删除了与低级方法重复或不经常使用的中级方法,并重命名了剩余的中级方法以提高可用性。 -
改进了
ClassSignature
类以更准确地建模超类和超接口的通用签名。
目标
-
提供用于处理类文件的 API,该 API 跟踪Java 虚拟机规范
class
定义的文件格式。 -
使JDK组件能够迁移到标准API,并 最终删除第三方ASM库的JDK内部副本。
非目标
-
它的目标不是废弃处理类文件的现有库,也不是成为世界上最快的类文件库。
-
扩展Core Reflection 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 之前无法安全地发出 JDK N 中新的类文件功能+1。当 JDK N 是一个备受期待的版本(例如 JDK 21),并且开发人员渴望编写需要使用新的类文件功能的程序时,这个问题尤其严重。
Java 平台应该定义并实现一个标准的类文件 API,该 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,默认禁用
要在 JDK 23 中尝试以下示例,您必须启用预览功能,如下所示:
-
使用 编译程序
javac --release 23 --enable-preview Main.java
并使用java --enable-preview Main
;运行它或者, -
使用源代码启动器时,运行程序
java --source 23 --enable-preview Main.java
使用模式解析类文件
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 中的ASMMethodVisitor
既是访客又是构建者。客户可以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 中的解析和生成方法一致,因此转换是无缝的。上面的解析示例遍历了 s 序列CodeElement
,让客户端与各个元素进行匹配。构建器接受CodeElement
s,以便典型的转换习惯用法自然而然地出现。
假设我们要处理一个类文件并保持所有内容不变,除了删除名称以"debug"
.我们将获取 a ClassModel
,创建 a 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.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);
测试
类文件 API 具有很大的表面积,并且必须生成符合 Java 虚拟机规范的类,因此需要进行大量的质量和一致性测试。此外,就我们用 Class-File API 替换 JDK 中 ASM 的使用而言,我们将比较使用这两个库来检测回归的结果,并进行广泛的性能测试以检测和避免性能回归。
备择方案
一个明显的想法是“仅仅”将 ASM 合并到 JDK 中并承担其持续维护的责任,但这不是正确的选择。 ASM 是一个旧的代码库,有很多遗留包袱。它很难发展,而且其架构的设计优先级可能不是我们今天所选择的。此外,自从 ASM 创建以来,Java 语言已经有了很大的改进,因此 2002 年最好的 API 习惯在二十年后可能不再理想。