JEP draft: Class-File API (Preview)

OwnerBrian Goetz
TypeFeature
ScopeJDK
StatusSubmitted
Componentcore-libs
Discussionclassfile dash api dash dev at openjdk dot org
EffortM
DurationM
Reviewed byPaul Sandoz
Created2022/01/20 14:51
Updated2023/04/10 16:59
Issue8280389

Summary

Provide a standard API for parsing, generating, and transforming Java class files. This is a preview API.

Goals

Non-Goals

Motivation

Class-file generation, parsing, and instrumentation is ubiquitous in the Java ecosystem. Many tools and libraries process class files, and frameworks often perform on-the-fly bytecode instrumentation, transformation, and generation.

The Java ecosystem has many different libraries for class-file parsing and generation, each with different design goals, strengths and weaknesses. In the last decade the JDK has made extensive use of the ASM library in its implementation, for tasks such as lambda proxy generation. However, there are a number of reasons why it makes sense for the JDK to include its own authoritative class-file library.

Description

We have adopted the following design goals and principles for the API.

This is preview API, disabled by default

To try the examples below in JDK 21 you must enable preview features as follows:

Elements, builders, and transforms

We construct the API from three key abstractions.

We introduce the API by showing how it can be used to parse class files, generate class files, and combine parsing and generation into transformation. The draft API specification is available here.

Reading, with patterns

ASM's streaming view of class files is visitor-based. Visitors are bulky and inflexible; the visitor pattern is often characterized as a library workaround for the lack of pattern matching in the language. Now that we have pattern matching we can express things more directly and concisely. For example, if we want to traverse a Code attribute and collect dependencies for a class dependency graph then we can simply iterate through the instructions and match on the ones we find interesting. A CodeModel describes a Code attribute; we can iterate over its CodeElements and handle those that include symbolic references to other types:

CodeModel code = ...
HashSet<ClassDesc> deps = new HashSet<>();
for (CodeElement e : code) {
    switch (e) {
        case FieldInstruction f -> deps.add(f.owner());
        case InvokeInstruction i -> deps.add(i.owner());
        // similar for instanceof, cast, etc
    }
}

Writing, with builders

Consider the following snippet of code:

void fooBar(boolean z, int x) {
    if (z)
        foo(x);
    else
        bar(x);
}

With ASM we could generate this method as follows:

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();

The MethodVisitor in ASM doubles as both a visitor and a builder. Clients can create a ClassWriter directly and then can ask the ClassWriter for a MethodVisitor. However, there is value in inverting this API idiom: Instead of the client creating a builder with a constructor or factory, it can provide a lambda which accepts a builder:

ClassBuilder classBuilder = ...
classBuilder.withMethod("fooBar", MethodTypeDesc.of(CD_void, CD_boolean, CD_int), flags,
                        methodBuilder -> methodBuilder.withCode(codeBuilder -> {
    Label label1 = new Label();
    Label label2 = new Label();
    codeBuilder.iload(1)
        .branch(IFEQ, label1)
        .aload(0)
        .iload(2)
        .invokevirtual(ClassDesc.of("Foo"), "foo", MethodTypeDesc.of(CD_void, CD_int))
        .branch(GOTO, label2)
        .labelBinding(label1)
        .aload(0)
        .iload(2)
        .invokevirtual(ClassDesc.of("Foo"), "bar", MethodTypeDesc.of(CD_void, CD_int))
        .labelBinding(label2);
        .return_();
});

This is more specific and transparent — the builder has lots of convenience methods such as aload(n) — but not yet any more concise or higher-level. Yet there is already a powerful hidden benefit: By capturing the sequence of operations in a lambda we get the possibility of replay, which enables the library to do work that previously the client had to do. For example, branch offsets can be either short or long. If clients generate instructions imperatively then they have to compute the size of each branch's offset when generating the branch, which is complex and error prone. But if the client provides a lambda that takes a builder then the library can optimistically try to generate the method with short offsets and, if that fails, discard the generated state and re-invoke the lambda with different code generation parameters.

Decoupling builders from visitation also lets us provide higher-level conveniences to manage block scoping and local-variable index calculation, and allows us to eliminate manual label management and branching:

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_();
});

Because block scoping is managed by the library, we did not have to generate labels or branch instructions — the library inserted them for us. Similarly, the library can optionally manage block-scoped allocation of local variables, freeing clients of the bookkeeping for local-variable slots as well.

Transformation

The reading and writing APIs line up so that transformation is seamless. The reading example above traversed a sequence of CodeElements, letting the client match against the individual elements. The builder accepts CodeElements so that typical transformation idioms fall out naturally.

Suppose we want to process a class file and keep everything unchanged except for removing methods whose names start with "debug". We would get a ClassModel, create a ClassBuilder, iterate the elements of the original ClassModel, and pass all of them through to the builder except for the methods we want to drop:

ClassModel classModel = Classfile.parse(bytes);
byte[] newBytes = Classfile.build(classModel.thisClass().asSymbol(),
        classBuilder -> {
            for (ClassElement ce : classModel) {
                if (!(ce instanceof MethodModel mm
                        && mm.methodName().stringValue().startsWith("debug"))) {
                    classBuilder.with(ce);
                }
            }
        });

Transforming method bodies is slightly more complicated since we have to explode classes into their parts (fields, methods, and attributes), select the method elements, explode the method elements into their parts (including the code attribute), and then explode the code attribute into its elements (i.e., instructions). The following transformation swaps invocations of methods on class Foo to invocations of methods on class Bar:

ClassModel classModel = Classfile.parse(bytes);
byte[] newBytes = Classfile.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.invokeInstruction(i.opcode(), 
                                                                                      ClassDesc.of("Bar"),
                                                                                      i.name(), i.type());
                                                        default -> codeBuilder.with(e);
                                                }
                                            }
                                        });
                                    }
                                    else
                                        methodBuilder.with(me);
                                }
                            });
                }
                else
                    classBuilder.with(ce);
            }
        });

Navigating the class-file tree by exploding entities into elements and examining each element involves some boilerplate which is repeated at multiple levels. This idiom is common to all traversals, so it is something the library should help with. The common pattern of taking a class-file entity, obtaining a corresponding builder, examining each element of the entity and possibly replacing it with other elements can be expressed by transforms, which are applied by transformation methods.

A transform accepts a builder and an element. It either replaces the element with other elements, drops the element, or passes the element through to the builder. Transforms are functional interfaces, so transformation logic can be captured in lambdas.

A transformation method copies the relevant metadata (names, flags, etc.) from a composite element to a builder and then processes the composite's elements by applying a transform, handling the repetitive exploding and iteration.

Using transformation we can rewrite the previous example as:

byte[] newBytes = classModel.transform((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.invokeInstruction(i.opcode(), ClassDesc.of("Bar"), 
                                                          i.name().stringValue(),
                                                          i.typeSymbol(), i.isInterface());
                            default -> codeBuilder.with(e);
                    }
                });
            }
            else
                methodBuilder.with(me);
        });
    }
    else
        classBuilder.with(ce);
});

Now the library is managing the iteration boilerplate, but the deep nesting of lambdas just to get access to the instructions is still somewhat intimidating. We can simplify this by factoring out the instruction-specific activity into a CodeTransform:

CodeTransform codeTransform = (codeBuilder, e) -> {
    switch (e) {
        case InvokeInstruction i
                when i.owner().asInternalName().equals("Foo") ->
            codeBuilder.invokeInstruction(i.opcode(), ClassDesc.of("Bar"),
                                          i.name().stringValue(),
                                          i.typeSymbol(), i.isInterface());
            default -> codeBuilder.accept(e);
    }
};

We can then lift this transform on code elements into a transform on method elements. When the lifted transform sees a Code attribute, it transforms it with the code transform, passing all other method elements through unchanged:

MethodTransform methodTransform = MethodTransform.transformingCode(codeTransform);

We can do the same again to lift the resulting transform on method elements into a transform on class elements:

ClassTransform classTransform = ClassTransform.transformingMethods(methodTransform);

Now our example becomes just:

byte[] newBytes = ClassModel.of(bytes).transform(classTransform);

Testing

As this library has a large surface area and must generate classes in conformance with the Java Virtual Machine Specification, significant quality and conformance testing will be required. Further, to the degree that we replace existing uses of ASM with the new library we will compare the results of using both libraries to detect regressions, and do extensive performance testing to detect and avoid performance regressions.