JDK 24正式支持Class-File API

英文原文地址

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 文件库,例如 ASMBCELJavassist。然而,对于 class 文件库来说,一个重要的问题是,由于 JDK 的六个月发布节奏class 文件格式的演变速度比过去更快。近年来,class 文件格式已经演变以支持 Java 语言特性(如 sealed classes),并公开 JVM 特性(如 dynamic constantsnestmates)。随着即将推出的特性(如 value classes 和 generic method specialization),这种趋势将继续下去。

由于 class 文件格式可能每六个月演变一次,框架更频繁地遇到比它们捆绑的 class 文件库更新的 class 文件。这种版本偏差会导致应用程序开发人员可见的错误,或者更糟的是,框架开发人员试图编写代码来解析未来的 class 文件,并寄希望于不会发生太严重的变化。框架开发人员需要一个可以信任的、与正在运行的 JDK 保持同步的 class 文件库。

JDK 在 javac 编译器内部有自己的 class 文件库。它还捆绑了 ASM 来实现 jarjlink 等工具,并支持在运行时实现 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 来表达,这些 transformstransformation methods 应用。

transform 接受一个 builder 和一个 element。它要么用其他 element 替换该 element,要么删除该 element,要么将该 element 传递给 buildertransform 是函数式接口,因此转换逻辑可以封装在 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);

变更

以下是自第二次预览以来的详细变更列表:

测试

Class-File API 的接口范围很大,并且必须生成符合 Java 虚拟机规范的类,因此需要进行大量的质量和一致性测试。此外,在我们用 Class-File API 替换 JDK 中 ASM 的使用时,我们将比较使用这两个库的结果以检测回归,并进行广泛的性能测试以检测和避免性能回归。

替代方案

一个显而易见的想法是"仅仅"将 ASM 合并到 JDK 中并承担其持续维护的责任,但这并非正确的选择。ASM 是一个旧的代码库,带有许多历史包袱。它难以演进,并且指导其架构的设计优先级可能不是我们今天会选择的。此外,自 ASM 创建以来,Java 语言已经有了实质性的改进,因此在 2002 年可能是最佳 API 用法的,但二十年后可能不同了。

Java 22 引入了 **Class-File API(JEP 457)**,这是一个全新的、官方的、用于读写 Java `.class` 文件的标准 API。它的目标是提供一种结构化、安全且易于使用的方式来解析和生成 JVM 类文件,而无需依赖第三方库(如 ASM、Javassist 或 BCEL)。 --- ### ✅ Class-File API 是用来干什么的? Class-File API 允许你在不加载类到 JVM 的情况下: - **读取** `.class` 文件的结构(如类名、方法、字段、注解、字节码指令等) - **修改** 类的某些部分(比如方法体、注解等) - **生成或重新写入** 修改后的 `.class` 文件 它基于“抽象语法树”(AST)模型来表示类文件内容,而不是直接操作字节流,因此更安全、更易用。 #### 核心功能包括: - 解析 `.class` 文件为高层对象模型 - 遍历类结构(字段、方法、属性等) - 修改方法的字节码(通过 `CodeModel` 接口) - 重新序列化为有效的 `.class` 文件 --- ### 🔧 示例:使用 Class-File API 读取一个类的方法名 ```java import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; import java.util.List; import jdk.internal.classfile.ClassFile; import jdk.internal.classfile.ClassModel; import jdk.internal.classfile.MethodModel; public class ReadClassExample { public static void main(String[] args) throws IOException { Path path = Path.of("HelloWorld.class"); // 替换为实际路径 ClassModel classModel = ClassFile.of().parse(Files.readAllBytes(path)); System.out.println("类名: " + classModel.thisClass().asSymbol().displayName()); List<MethodModel> methods = classModel.methods(); for (var method : methods) { System.out.println("方法: " + method.methodName().stringValue()); } } } ``` > ⚠️ 注意:目前(Java 22)该 API 在 `jdk.internal.classfile` 包下,虽然是公开 API,但仍在孵化阶段(incubating),需启用 incubator 模块。 编译和运行需要加上模块支持: ```bash javac --add-modules jdk.incubator.classfile ReadClassExample.java java --add-modules jdk.incubator.classfile ReadClassExample ``` --- ### ❓可以用 Class-File API 实现 Java 混淆器吗? **答案是:可以,但有限制。** #### ✅ 可以实现的基础混淆功能: 1. **重命名类、方法、字段名** - 虽然不能直接修改常量池中的名字(API 尚未完全开放所有转换能力),但你可以读取类结构 → 构造新结构 → 写出新的 class 文件。 2. **删除调试信息(如行号、局部变量表)** - 这些属于 `CodeAttribute`,可以通过过滤移除。 3. **控制流混淆(简单版本)** - 如果你能访问并修改 `CodeModel` 中的字节码指令,就可以插入无意义跳转或 dummy 指令。 4. **字符串加密雏形** - 扫描 ldc 指令中的字符串常量,替换为调用解密函数的字节码(如果 API 支持细粒度操作)。 #### 🚫 当前限制(阻碍完整混淆器开发): - **不可变性设计**:Class-File API 多数对象是不可变的,你必须重建整个结构才能修改。 - **缺少完整的字节码编辑能力**:虽然能访问 `CodeModel`,但构建自定义字节码指令序列仍较繁琐。 - **性能不如 ASM**:作为新 API,尚未经过大规模生产验证,性能和灵活性暂时不及 ASM。 - **仅限于单个 class 文件处理**:没有内置跨类分析能力(比如确定哪些方法没人调用),这在高级混淆中很重要。 --- ### ✅ 如何用它做简单的“类名混淆”示例? 下面是一个简化的思路演示:将所有类名改为 `A`, `B`, `C` 等形式(仅示意流程): ```java // 伪代码逻辑(当前 API 不直接支持完整重写类名引用) // 步骤: // 1. 读取所有 .class 文件 // 2. 建立原始类名 -> 混淆名映射表(如 com.example.Foo -> A) // 3. 使用 Class-File API 遍历每个类,尝试修改 thisClass 和常量池引用 // 4. 重新生成 .class 文件 ``` 但由于 Class-File API 目前 **不允许随意修改常量池中的符号引用**,尤其是涉及继承关系、方法调用等跨类引用时,你需要手动维护这些引用一致性——这非常复杂。 所以目前更适合用于: - 工具开发(如静态分析器、字节码查看器) - 教学用途 - 简单的代码变换插件 而 **ProGuard、Allatori、DashO** 等成熟混淆器仍然基于 ASM 或自研字节码引擎,因为它们需要极致控制力和高性能。 --- ### 总结 | 功能 | 是否可用 | |------|----------| | 读取 class 结构 | ✅ 完全支持 | | 修改方法字节码 | ✅ 有限支持 | | 重命名类/方法 | ⚠️ 可实现局部,但跨引用难 | | 删除调试信息 | ✅ 支持 | | 实现完整 Java 混淆器 | ❌ 当前不现实,未来可能 | > 🔮 展望:随着 Class-File API 成熟(脱离孵化状态),未来可能会成为标准工具链的一部分,甚至替代 ASM 在某些场景下的地位。但对于混淆器这类高精度操作,短期内仍依赖 ASM 更合适。 ---
评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值