Home | Send Feedback | Share on Bluesky |

Getting started with the Java Class-File API (JEP 484)

Published: 9. March 2026  •  java

The Class-File API gives Java a standard way to parse, generate, and transform .class files directly in the JDK. The API was introduced in JDK 24. So make sure to use JDK 24 or later if you want to experiment with the API.

Bytecode tooling has been a foundational part of the Java ecosystem for years. For example, Hibernate and Spring use bytecode generation to create proxies. They use libraries like ASM, Byte Buddy, and Javassist to do it.

There is nothing wrong with these libraries; even the JEP states that making them obsolete is not a goal. If nothing is wrong with them, why did the JDK need its own API? The central problem is version skew. The class-file format now evolves more quickly than it did in the past because the JDK ships every six months. New language and JVM features can introduce new class-file structures far more often than in the old multi-year release model. So every time you want to use a new JDK, you need to wait for the bytecode library to support the new class-file version and for the frameworks you use to update their dependencies to the new library version.

The Class-File API addresses this by moving class-file processing into the Java standard library. That gives the JDK a standard API that evolves in sync with the class-file format.

The API lives in the java.lang.classfile package and has three main areas of functionality: generation, parsing, and transformation.

In this blog post, we will look at a few examples that show how to use the API for these three use cases. The examples are not meant to be comprehensive, but they should give you a good starting point to explore the API on your own.

Generation

The first example emits a class whose main method prints the string "Hello, world!". This is equivalent to the following Java code:

public class Hello {
  public static void main(String[] args) {
    System.out.println("Hello, world!");
  }
}

The generator starts by creating symbolic descriptors for the generated class and the JDK classes it will reference. The example needs to define its own class name (Hello) and also reference java.lang.System and java.io.PrintStream to call System.out.println.

Then we need two method signatures: one for the main method, and one for the println method that the generated code will call. The main method has the signature void main(String[]), and the println method has the signature void println(String).

    ClassDesc helloClass = ClassDesc.of("Hello");
    ClassDesc systemClass = ClassDesc.of("java.lang.System");
    ClassDesc printStreamClass = ClassDesc.of("java.io.PrintStream");
    MethodTypeDesc mainType = MethodTypeDesc.of(ConstantDescs.CD_void, ConstantDescs.CD_String.arrayType());
    MethodTypeDesc printlnType = MethodTypeDesc.of(ConstantDescs.CD_void, ConstantDescs.CD_String);

GenerateHelloWorldClass.java

The code then builds the class file using ClassFile.of().build(...). The builder takes care of creating the constant pool and other derived class-file structures.

The ClassBuilder creates a public class, then defines the constructor and a main method. Note that the constructor in the generated bytecode is a method named <init> with the descriptor ()V. This is a JVM convention. The API provides constants for these special method names and descriptors in the ConstantDescs class.

The constructor simply calls Object.<init>, because every class in Java inherits from Object and must call the superclass constructor.

The main method is defined as public and static.

getstatic(...) emits a GETSTATIC instruction that fetches System.out and puts it on the operand stack. out is a static field of type PrintStream in the System class.

ldc("Hello, world!") emits an LDC instruction that loads the string constant "Hello, world!" onto the stack.

invokevirtual(printStreamClass, "println", printlnType) emits an INVOKEVIRTUAL instruction that calls PrintStream.println(String). The instruction consumes (pop) the PrintStream receiver and the String argument from the operand stack, invokes the method, and because the return type is void, it does not push a value back onto the stack.

Finally, return_() emits a RETURN instruction that returns void from the method.

    byte[] bytes = ClassFile.of()
      .build(helloClass, classBuilder -> classBuilder.withFlags(ClassFile.ACC_PUBLIC)
        .withMethodBody(ConstantDescs.INIT_NAME, ConstantDescs.MTD_void, ClassFile.ACC_PUBLIC,
            codeBuilder -> codeBuilder.aload(0)
              .invokespecial(ConstantDescs.CD_Object, ConstantDescs.INIT_NAME, ConstantDescs.MTD_void)
              .return_())
        .withMethodBody("main", mainType, ClassFile.ACC_PUBLIC | ClassFile.ACC_STATIC,
            codeBuilder -> codeBuilder.getstatic(systemClass, "out", printStreamClass)
              .ldc("Hello, world!")
              .invokevirtual(printStreamClass, "println", printlnType)
              .return_()));

GenerateHelloWorldClass.java

ClassFile.of().build(...) returns the generated class file as a byte array. At this point the API has only emitted bytecode; it has not executed anything. The example then uses a trivial ClassLoader to load the class from the byte array. Because the generated main method is static, it can be invoked without creating an instance of the class.

  private static void runGeneratedClass(byte[] bytes) throws ReflectiveOperationException {
    GeneratedClassLoader classLoader = new GeneratedClassLoader();
    Class<?> generatedClass = classLoader.define(bytes);
    Method mainMethod = generatedClass.getMethod("main", String[].class);
    mainMethod.invoke(null, (Object) new String[0]);
  }

GenerateHelloWorldClass.java

This code is not part of the Class-File API; it uses a custom ClassLoader and standard reflection to load and run the generated class.

As a last step, the example writes the generated class to disk. We will use this class file in the next examples to show how to parse and transform an existing class.

    Path output = Path.of("target", "generated-classes", "Hello.class");
    Files.createDirectories(output.getParent());
    Files.write(output, bytes);

GenerateHelloWorldClass.java

Parsing

Generation is one use case of the API. Another is parsing an existing class file into a structured model that can be inspected. This can be useful for tools that need to analyze class files, such as static analysis tools, documentation generators, or even IDEs.

The following example reads the generated Hello.class from disk, parses it into a ClassModel with parse, and prints out the class name, method names, method signatures, and the code elements in each method body.

    Path classFile = Path.of("target", "generated-classes", "Hello.class");
    ClassModel classModel = ClassFile.of().parse(classFile);

    System.out.println("Class: " + classModel.thisClass().asSymbol().displayName());
    System.out.println("Methods:");

    for (MethodModel method : classModel.methods()) {
      System.out.println(
          "- " + method.methodName().stringValue() + " " + method.methodTypeSymbol().displayDescriptor());

      for (MethodElement methodElement : method) {
        if (methodElement instanceof CodeModel codeModel) {
          System.out.println("  Code:");
          for (CodeElement codeElement : codeModel) {
            System.out.println("  - " + codeElement);
          }
        }
      }
    }

ParseHelloWorldClass.java

This example shows that the API provides a structured model of the class file. The model is hierarchical:

Running the code above produces:

Class: Hello
Methods:
- <init> ()void
  Code:
  - Load[OP=ALOAD_0, slot=0]
  - Invoke[OP=INVOKESPECIAL, m=java/lang/Object.<init>()V]
  - Return[OP=RETURN]
- main (String[])void
  Code:
  - Field[OP=GETSTATIC, field=java/lang/System.out:Ljava/io/PrintStream;]
  - LoadConstant[OP=LDC, val=Hello, world!]
  - Invoke[OP=INVOKEVIRTUAL, m=java/io/PrintStream.println(Ljava/lang/String;)V]
  - Return[OP=RETURN]

Transformation

Another use case of the Class-File API is transforming an existing class file. The API provides a way to specify transformations at the class level, method level, and code level.

The following example reads the class file generated in the first example. It then applies a transformation to the main method body that emits additional bytecode at the beginning and end of the method.

As in the previous example, the class file is parsed into a ClassModel.

    Path originalClass = Path.of("target", "generated-classes", "Hello.class");
    ClassFile classFile = ClassFile.of();
    ClassModel classModel = classFile.parse(originalClass);

TransformHelloWorldClass.java

A CodeTransform is a function that takes a CodeBuilder and a CodeElement. During transformation, the API calls this transformer for each CodeElement. The transformer can inspect the element and decide to pass it through unchanged, drop it, replace it, or emit additional elements before or after it by writing to the builder.

In this example, the transformer checks if it is the first element in the method body, and if so, it emits a System.out.println("Start of main") before the original element.

If the code element is a return instruction, it emits a System.out.println("End of main") before the return instruction.

builder.with(...) is used to pass the original element through unchanged.

  private static CodeTransform addLoggingAspect() {
    ClassDesc systemClass = ClassDesc.of("java.lang.System");
    ClassDesc printStreamClass = ClassDesc.of("java.io.PrintStream");
    MethodTypeDesc printlnType = MethodTypeDesc.of(ConstantDescs.CD_void, ConstantDescs.CD_String);
    boolean[] entryInjected = { false };

    return (builder, element) -> {
      if (!entryInjected[0]) {
        emitPrintln(builder, systemClass, printStreamClass, printlnType, "Start of main");
        entryInjected[0] = true;
      }

      if (element instanceof ReturnInstruction) {
        emitPrintln(builder, systemClass, printStreamClass, printlnType, "End of main");
      }

      builder.with(element);
    };
  }

TransformHelloWorldClass.java

  private static void emitPrintln(CodeBuilder builder, ClassDesc systemClass, ClassDesc printStreamClass,
      MethodTypeDesc printlnType, String message) {
    builder.getstatic(systemClass, "out", printStreamClass)
      .ldc(message)
      .invokevirtual(printStreamClass, "println", printlnType);
  }

TransformHelloWorldClass.java

The application then wires this code transformer into a class-level transformation using ClassTransform.transformingMethodBodies. This method takes a predicate that identifies which method bodies to transform, in this case the main method, and a transformer. The transformation returns the transformed class file as a byte array; it does not execute the transformed code.

    CodeTransform addLoggingAspect = addLoggingAspect();

    byte[] transformedBytes = classFile.transformClass(classModel,
        ClassTransform.transformingMethodBodies(TransformHelloWorldClass::isMainMethod, addLoggingAspect));

TransformHelloWorldClass.java

When those transformed class bytes are loaded and run, the output is:

Start of main
Hello, world!
End of main

The ClassTransform API provides a variety of methods for specifying transformations at different levels of the class file. For example, you can transform entire methods, fields, or class-level attributes. The API also allows you to specify filters to target specific methods or fields for transformation. Check out the Javadoc for ClassTransform for more details.

Implementing an interface at runtime

In the last example, we look at another code-generation scenario. Here, we use the Class-File API at runtime to emit the bytecode for a class that implements an interface.

The idea is to create a JSON serializer. A caller can invoke JSONSerializer.from(MyRecord.class) to get a JSONSerializer implementation for the given class.

public interface JSONSerializer<T> {

  String serialize(T value);

  static <T> JSONSerializer<T> from(Class<T> recordClass) {
    // emits and loads a JSONSerializer implementation class for the given record class at runtime
  }

}

To keep this example simple, the factory method will only support Java records with String fields.

Like in the first example, the implementation starts by declaring symbolic descriptors for the generated class and the JDK classes it will reference. The generated class implements the JSONSerializer interface, so we need a descriptor for that. The generated serialize method will use StringBuilder, so we need a descriptor for that as well, and we also need one for the record class that we want to serialize. implementationClass is the name of the class this code will generate.

Then we also need a few method signatures: one for the constructor of the generated class, one for the serialize method, one for StringBuilder.append(String), and one for StringBuilder.toString(). MTD_void is a constant method type descriptor for a method that takes no arguments and returns void.

  private static byte[] buildImplementation(Class<?> recordClass) {
    RecordComponent[] components = recordClass.getRecordComponents();
    ClassDesc implementationClass = ClassDesc.of(implementationClassName(recordClass));
    ClassDesc recordClassDesc = ClassDesc.of(recordClass.getName());
    ClassDesc serializerInterface = ClassDesc.of(JSONSerializer.class.getName());
    ClassDesc stringBuilderClass = ClassDesc.of("java.lang.StringBuilder");

    MethodTypeDesc constructorType = ConstantDescs.MTD_void;
    MethodTypeDesc serializeType = MethodTypeDesc.of(ConstantDescs.CD_String, ConstantDescs.CD_Object);
    MethodTypeDesc appendType = MethodTypeDesc.of(stringBuilderClass, ConstantDescs.CD_String);
    MethodTypeDesc toStringType = MethodTypeDesc.of(ConstantDescs.CD_String);

    return ClassFile.of()
      .build(implementationClass,
          classBuilder -> classBuilder.withFlags(ClassFile.ACC_PUBLIC | ClassFile.ACC_FINAL)
            .withInterfaceSymbols(serializerInterface)
            .withMethodBody(ConstantDescs.INIT_NAME, constructorType, ClassFile.ACC_PUBLIC,
                codeBuilder -> buildConstructor(codeBuilder, constructorType))
            .withMethodBody("serialize", serializeType, ClassFile.ACC_PUBLIC,
                codeBuilder -> buildSerializeMethod(codeBuilder, components, recordClassDesc,
                    stringBuilderClass, constructorType, appendType, toStringType)));
  }

JSONSerializer.java

The generated class will be public and final, and it will implement the JSONSerializer interface (withInterfaceSymbols(serializerInterface)). It contains a public constructor and a public serialize method.

The code emitted for the constructor is straightforward. It just calls the superclass constructor (Object.<init>) and returns.

  private static void buildConstructor(CodeBuilder codeBuilder, MethodTypeDesc constructorType) {
    codeBuilder.aload(codeBuilder.receiverSlot())
      .invokespecial(ConstantDescs.CD_Object, ConstantDescs.INIT_NAME, constructorType)
      .return_();
  }

JSONSerializer.java

The code generated for the serialize method is a bit more involved. It first checks if the given record class has any record components. These components define the state exposed by the record's accessor methods. If there are no components, it just returns the string for an empty JSON object ({}). ldc emits an LDC instruction that loads a constant string onto the stack, and areturn emits an ARETURN instruction to return a reference type from the method.

The method then reserves a local-variable slot with allocateLocal. This will be used to store the record instance that the caller passed to the serialize method.

Next, the generator emits bytecode that loads the method's first explicit parameter (parameterSlot(0)) onto the stack, casts it to the record class, and stores it in the local variable. parameterSlot(0) refers to the first declared parameter, value. The generator then emits bytecode that instantiates a StringBuilder by emitting a NEW instruction, duplicates the reference on the stack with DUP, and calls the constructor of StringBuilder with INVOKESPECIAL.

DUP is an instruction that duplicates the top value on the stack. In this case it's the reference to the new StringBuilder instance. This is needed because the constructor call will consume (pop) the reference, but we still need it on the stack to call methods on it later.

  private static void buildSerializeMethod(CodeBuilder codeBuilder, RecordComponent[] components,
      ClassDesc recordClassDesc, ClassDesc stringBuilderClass, MethodTypeDesc constructorType,
      MethodTypeDesc appendType, MethodTypeDesc toStringType) {
    if (components.length == 0) {
      codeBuilder.ldc("{}").areturn();
      return;
    }
    int recordSlot1 = codeBuilder.allocateLocal(TypeKind.REFERENCE);

    codeBuilder.aload(codeBuilder.parameterSlot(0))
      .checkcast(recordClassDesc)
      .astore(recordSlot1)
      .new_(stringBuilderClass)
      .dup()
      .invokespecial(stringBuilderClass, ConstantDescs.INIT_NAME, constructorType);

JSONSerializer.java

The code generator now loops over all record components and emits code to append the component name and value to the StringBuilder. The first ldc emits a LDC instruction that loads the component name as a string constant onto the stack. The componentPrefix method is a helper that returns the component name formatted as a JSON key, with the appropriate prefix (either {" for the first component, or ," for subsequent components).

  private static String componentPrefix(RecordComponent component, int index) {
    return index == 0 ? "{\"" + component.getName() + "\":\"" : ",\"" + component.getName() + "\":\"";
  }

JSONSerializer.java

At that point, the operand stack contains two values: the reference to the StringBuilder and the string generated by componentPrefix. invokevirtual then emits an INVOKEVIRTUAL instruction that pops the argument string and the StringBuilder reference from the stack, calls StringBuilder.append(String), and pushes the return value, which is the same StringBuilder reference, back onto the stack. This allows us to chain multiple append calls together.

Next, the code generator emits bytecode that loads the record instance from the local variable onto the stack and calls the component accessor method, for example person.firstName(), to get the value of the component. Because the accessor methods do not take parameters, invokevirtual only pops the reference to the record from the stack, calls the accessor method, and pushes the returned component value onto the stack.

So at that point we have the StringBuilder reference and the component value on the stack. We call StringBuilder.append(String) again to append the component value to the JSON string. This pops both the StringBuilder reference and the component value from the stack and pushes the StringBuilder reference back onto the stack as the return value of append.

Finally, it appends a closing quote (") after the component value.

After all components have been processed, the generator emits bytecode that appends a closing curly brace (}) to the JSON string, and then calls toString on the StringBuilder to get the final JSON string, which is returned from the method with areturn.

    int recordSlot = recordSlot1;

    for (int index = 0; index < components.length; index++) {
      RecordComponent component = components[index];
      codeBuilder.ldc(componentPrefix(component, index))
        .invokevirtual(stringBuilderClass, "append", appendType)
        .aload(recordSlot)
        .invokevirtual(recordClassDesc, component.getName(), MethodTypeDesc.of(ConstantDescs.CD_String))
        .invokevirtual(stringBuilderClass, "append", appendType)
        .ldc("\"")
        .invokevirtual(stringBuilderClass, "append", appendType);
    }

    codeBuilder.ldc("}")
      .invokevirtual(stringBuilderClass, "append", appendType)
      .invokevirtual(stringBuilderClass, "toString", toStringType)
      .areturn();

JSONSerializer.java

When working with the Class-File API, one important aspect is keeping track of the operand stack and always being aware of which values are on it at any given time.


When we now run the example with a record like this

record Person(String firstName, String lastName) {}

the code generator emits bytecode that looks similar to this Java code.

public String serialize(Object value) {
    Person person = (Person) value;
    return new StringBuilder()
            .append("{\"firstName\":\"")
            .append(person.firstName())
            .append("\",\"lastName\":\"")
            .append(person.lastName())
            .append("\"}")
            .toString();
}

Loading the emitted implementation class and invoking it with this code produces the output {"firstName":"Duke","lastName":"Java"}.

    JSONSerializer<Person> serializer = JSONSerializer.from(Person.class);
    String json = serializer.serialize(new Person("Duke", "Java"));
    System.out.println(json);

GenerateJsonSerializer.java

Wrapping Up

The Class-File API brings bytecode-manipulation capabilities into the JDK. This is significant because the API is designed to evolve in lockstep with the class-file format, which means it will support the latest language and JVM features as the platform evolves. That is a big improvement over the situation where bytecode libraries can lag behind new JDK releases.