Java 24 Class-File API Tutorial with Examples

Introduction Much of the Java ecosystem reads and writes bytecode - the compiled .class files the JVM runs. Frameworks generate proxy classes, mocking libraries create test doubles, agents instrument code for profiling, and the JDK itself generates classes for lambdasClick to read: lambdas. For years this was done with third-party libraries, mostly ASM, which the JDK also bundled internally. But the class file format changes with every six-monthly Java release, and a library like ASM can only support a new format after that release ships - so frameworks always lagged behind the newest Java. The Class-File API, previewed in Java 22 and 23 and final in Java 24Click to read tutorial: Overview of New Features in Java 24 (JEP 484), is a standard API in java.lang.classfile which always supports the class file format of the JDK it ships with. This tutorial explains the Class-File API with examples - parsing a class file, generating a class from scratch and running it, and transforming an existing class. Parsing a class file ClassFile.of().parse(bytes) returns a ClassModel, an immutable tree of the class's elements - fields, methods, attributes, and the bytecode instructions inside each method -
A class to inspect
//Greeting.java
public class Greeting {
  private final String name;
  public Greeting(String name) { this.name = name; }
  public String hello() { return "Hello, " + name; }
  public void debug() { System.out.println("debug: " + name); }
}
Listing the members of a class file
//ParseClassFile.java
import java.lang.classfile.ClassFile;
import java.lang.classfile.ClassModel;
import java.lang.classfile.CodeModel;
import java.lang.classfile.FieldModel;
import java.lang.classfile.MethodModel;
import java.nio.file.Files;
import java.nio.file.Path;

public class ParseClassFile {
  public static void main(String[] args) throws Exception {
    byte[] bytes = Files.readAllBytes(Path.of("classes", "Greeting.class"));
    ClassModel model = ClassFile.of().parse(bytes);

    System.out.println("Class: " + model.thisClass().asInternalName()
        + " (class file version " + model.majorVersion() + ")");
    for (FieldModel f : model.fields()) {
      System.out.println("  field  " + f.fieldName() + " : " + f.fieldTypeSymbol().displayName());
    }
    for (MethodModel m : model.methods()) {
      int instructions = m.code().map(c -> (int) c.elementStream().filter(e -> e instanceof java.lang.classfile.Instruction).count()).orElse(0);
      System.out.println("  method " + m.methodName() + m.methodTypeSymbol().displayDescriptor()
          + " - " + instructions + " instructions");
    }
  }
}
 OUTPUT of the above code
Class: Greeting (class file version 68)
  field  name : String
  method <init>(String)void - 6 instructions
  method hello()String - 4 instructions
  method debug()void - 6 instructions
The class file version 68 corresponds to Java 24, the release the code was compiled for. Names are returned as their own types, such as Utf8Entry for constant pool strings and ClassDesc and MethodTypeDesc for types - the descriptor types of the JVM Constants APIClick to read: JVM Constants API added in Java 12.
Generating a class ClassFile.of().build() creates a class file from a description written with builders, down to individual bytecode instructions. The example generates a class with a static greet() method which prints a message, loads it into the running JVM, and calls it -
Generating, loading and running a class
//GenerateClass.java
import static java.lang.constant.ConstantDescs.CD_String;
import static java.lang.constant.ConstantDescs.CD_void;

import java.lang.classfile.ClassFile;
import java.lang.constant.ClassDesc;
import java.lang.constant.MethodTypeDesc;
import java.lang.invoke.MethodHandles;

public class GenerateClass {
  public static void main(String[] args) throws Throwable {
    ClassDesc system = ClassDesc.of("java.lang.System");
    ClassDesc printStream = ClassDesc.of("java.io.PrintStream");

    byte[] bytes = ClassFile.of().build(ClassDesc.of("GeneratedHello"), clazz -> clazz
        .withFlags(ClassFile.ACC_PUBLIC)
        .withMethodBody("greet", MethodTypeDesc.of(CD_void), ClassFile.ACC_PUBLIC | ClassFile.ACC_STATIC,
            code -> code
                .getstatic(system, "out", printStream)                      // System.out
                .ldc("Hello from a class generated at runtime!")            // the message
                .invokevirtual(printStream, "println", MethodTypeDesc.of(CD_void, CD_String))
                .return_()));

    System.out.println("Generated " + bytes.length + " bytes of bytecode");
    Class<?> generated = MethodHandles.lookup().defineClass(bytes);         // load it
    generated.getMethod("greet").invoke(null);                              // run it
  }
}
 OUTPUT of the above code
Generated 299 bytes of bytecode
Hello from a class generated at runtime!
Each builder method maps to a bytecode instruction - getstatic, ldc, invokevirtual, return_. The API computes the details that make hand-written bytecode error-prone, such as the constant pool, the maximum stack size and the stack map frames which the JVM's verifier requires.
Transforming a class Most real tools do not build classes from scratch but transform existing ones - adding logging, removing methods, renaming things. The API models a transformation as a function from elements to elements. The example removes the debug() method from Greeting -
Removing a method from a class
//TransformClass.java
import java.lang.classfile.ClassFile;
import java.lang.classfile.ClassModel;
import java.lang.classfile.ClassTransform;
import java.lang.classfile.MethodModel;
import java.nio.file.Files;
import java.nio.file.Path;

public class TransformClass {
  public static void main(String[] args) throws Exception {
    ClassFile cf = ClassFile.of();
    ClassModel original = cf.parse(Files.readAllBytes(Path.of("classes", "Greeting.class")));

    byte[] transformed = cf.transformClass(original, ClassTransform.dropping(
        element -> element instanceof MethodModel m && m.methodName().equalsString("debug")));

    ClassModel result = cf.parse(transformed);
    System.out.println("Methods before: " + original.methods().stream().map(m -> m.methodName().stringValue()).toList());
    System.out.println("Methods after : " + result.methods().stream().map(m -> m.methodName().stringValue()).toList());
  }
}
 OUTPUT of the above code
Methods before: [<init>, hello, debug]
Methods after : [<init>, hello]
ClassTransform.dropping() drops matching elements and keeps everything else unchanged. Transforms can also replace elements, add new ones, and be composed with andThen(); method and code transforms work the same way at lower levels, down to individual instructions. Summary In this tutorial we saw how the Class-File API, final in Java 24, provides a standard way to read, write and transform class files which always keeps up with the JDK's own class file format. We parsed a class file into a ClassModel and listed its fields and methods, generated a class with a method body built from bytecode instructions, loaded and ran it, and removed a method with a ClassTransform. Next, read about the AOT cache for faster startupClick to read tutorial: AOT Cache in Java 24 and 25: Faster Startup.