Every framework that weaves in a proxy, rewrites a call site, or scans annotations at build time bundles a class-file library. Usually ASM, sometimes Byte Buddy or Javassist on top of it.

That bundling has a cost you feel on every upgrade. The class-file format now changes every six months, and a library released for JDK N cannot ship until JDK N is out — so your tooling is structurally one release behind the JDK your users just installed.

Java 24 makes class-file processing part of the platform with the Class-File API, finalized by JEP 484. The library ships with the runtime that defines the format, so there is no version skew to manage.

ClassFile cf = ClassFile.of();
ClassModel model = cf.parse(Path.of("out/Greeter.class"));
byte[] rewritten = cf.transformClass(model, someTransform);

The problem it solves

The JDK hit this first. jar and jlink use a bundled copy of ASM, and that copy cannot update until after ASM releases, which is after the JDK releases — so javac could not safely emit a brand-new class-file feature until the following release.

For your code the symptom is a stack trace during a build or an agent premain:

java.lang.IllegalArgumentException: Unsupported class file major version 68
	at org.objectweb.asm.ClassReader.<init>(ClassReader.java:200)

The usual fixes are all bad: pin users to an older JDK, bump a transitive ASM version and hope, or pass a “read this as an older version” flag and hope harder. Guessing at a future class-file format is not a strategy.

java.lang.classfile removes the skew by construction — the API is versioned with the JVM specification it implements.

When it shipped

ReleaseStatusSpec
Java 22First previewJEP 457
Java 23Second previewJEP 466
Java 24FinalJEP 484

No preview flag on Java 24+. The API lives in java.base, so there is no module to require and no dependency to declare.

Note: The API renamed and removed a fair number of members between the second preview and final. Code written against JDK 23 preview will not compile unchanged on JDK 24 — check the “Changes” section of JEP 484 before porting.

Mental model

The API has exactly three concepts, and everything else is a specialization of one of them.

ConceptWhat it isExamples
ElementAn immutable description of one part of a class fileMethodModel, InvokeInstruction, SignatureAttribute
BuilderA Consumer of elements that produces a new structureClassBuilder, MethodBuilder, CodeBuilder
TransformA function of (builder, element) that keeps, drops, or replacesClassTransform, MethodTransform, CodeTransform

Elements are also a tree. A ClassModel contains ClassElements, some of which are MethodModels; a MethodModel contains MethodElements, one of which is a CodeModel; a CodeModel contains CodeElements, most of which are instructions.

Two properties are worth internalizing early. Everything is immutable — you never mutate a ClassModel, you read one and build another. And parsing is lazy: parse() reads only enough to find structure boundaries, so scanning a jar for one annotation never inflates a method body.

Compound elements are both a single element and a stream of their parts. That is what makes transformation fall out for free: a transform is just a flat-map over the element stream.

Lab setup

The whole lab runs on one JDK 24 install and one throwaway class. Start with a class that has something worth removing and something worth rewriting:

// Greeter.java
public class Greeter {

    public String greet(String name) {
        return "Hello, " + audit(name);
    }

    String audit(String name) {
        return "[audit] " + name;
    }

    String trace(String name) {
        return "[trace] " + name;
    }

    void debugDump() {
        System.out.println("Greeter internals");
    }
}

Compile it into an output directory so the original source stays untouched:

javac -d out Greeter.java

Parse a class file

ClassFile.of() returns a context holding your processing options; it is the entry point for everything. Parse bytes or a Path into a ClassModel, then use the accessors for random access.

import java.lang.classfile.ClassFile;
import java.lang.classfile.ClassModel;
import java.lang.classfile.MethodModel;
import java.nio.file.Path;

public class Inspect {
    public static void main(String[] args) throws Exception {
        ClassFile cf = ClassFile.of();
        ClassModel model = cf.parse(Path.of("out/Greeter.class"));

        System.out.println(model.thisClass().asInternalName()
                + " major=" + model.majorVersion());

        for (MethodModel m : model.methods()) {
            System.out.println("  " + m.methodName().stringValue()
                    + m.methodType().stringValue());
        }
    }
}

Run it directly from source — no build tool needed:

java Inspect.java
Greeter major=68
  <init>()V
  greet(Ljava/lang/String;)Ljava/lang/String;
  audit(Ljava/lang/String;)Ljava/lang/String;
  trace(Ljava/lang/String;)Ljava/lang/String;
  debugDump()V

Major version 68 is Java 24, available as the constant ClassFile.JAVA_24_VERSION. Names come back as constant-pool entries, so methodName() returns a Utf8Entry and you call stringValue() on it; when you want a symbolic descriptor instead, methodTypeSymbol() gives you a MethodTypeDesc.

Drop a method

Removing an element is the simplest transform, and the API has a factory for exactly that shape. ClassTransform.dropping passes everything through except elements matching your predicate.

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 DropDebug {
    public static void main(String[] args) throws Exception {
        ClassFile cf = ClassFile.of();
        Path target = Path.of("out/Greeter.class");
        ClassModel model = cf.parse(target);

        ClassTransform dropDebug = ClassTransform.dropping(
                element -> element instanceof MethodModel m
                        && m.methodName().stringValue().startsWith("debug"));

        Files.write(target, cf.transformClass(model, dropDebug));
    }
}

transformClass returns the new class-file bytes; writing them back is an ordinary Files.write.

java DropDebug.java && javap -p out/Greeter.class

debugDump is gone, and nothing else moved:

public class Greeter {
  public Greeter();
  public java.lang.String greet(java.lang.String);
  java.lang.String audit(java.lang.String);
  java.lang.String trace(java.lang.String);
}

You did not touch the constant pool, recompute method offsets, or copy attributes by hand. Anything the transform passes through is copied structurally.

Rewrite one call site

Rewriting instructions means reaching one level deeper, into the Code attribute. Write a CodeTransform that matches the instruction you care about and passes everything else through, then lift it to the class level.

import java.lang.classfile.ClassFile;
import java.lang.classfile.ClassModel;
import java.lang.classfile.ClassTransform;
import java.lang.classfile.CodeTransform;
import java.lang.classfile.instruction.InvokeInstruction;
import java.nio.file.Files;
import java.nio.file.Path;

public class RetargetCall {
    public static void main(String[] args) throws Exception {
        ClassFile cf = ClassFile.of();
        Path target = Path.of("out/Greeter.class");
        ClassModel model = cf.parse(target);

        CodeTransform auditToTrace = (codeBuilder, element) -> {
            switch (element) {
                case InvokeInstruction i when i.name().stringValue().equals("audit") ->
                        codeBuilder.invoke(i.opcode(), i.owner().asSymbol(),
                                "trace", i.typeSymbol(), i.isInterface());
                default -> codeBuilder.with(element);
            }
        };

        Files.write(target, cf.transformClass(model,
                ClassTransform.transformingMethodBodies(auditToTrace)));
    }
}

Three details carry the weight here. The switch is a pattern switch over CodeElement, so matching an instruction type replaces ASM’s visitor callbacks. codeBuilder.with(element) is the pass-through case and is what you must reach for in default. And transformingMethodBodies lifts a code-level transform into a class-level one, so you never write the loop that explodes classes into methods into code.

Verify the swap in the bytecode:

java RetargetCall.java && javap -c -p out/Greeter.class
  public java.lang.String greet(java.lang.String);
    Code:
       0: aload_0
       1: aload_1
       2: invokevirtual #7  // Method trace:(Ljava/lang/String;)Ljava/lang/String;
       5: invokedynamic #13,  0  // InvokeDynamic #0:makeConcatWithConstants:...
      10: areturn

The invocation now targets trace, and the constant pool entry, stack map, and branch offsets were regenerated for you — that is the JEP’s “detail hiding” principle doing its job.

Generate a class from scratch

The same builders work with no input class. ClassFile.build takes the class name as a ClassDesc and hands you a ClassBuilder, and types come from java.lang.constant descriptors rather than raw descriptor strings.

ClassDesc printStream = ClassDesc.of("java.io.PrintStream");

byte[] bytes = ClassFile.of().build(ClassDesc.of("Hello"), classBuilder ->
        classBuilder.withMethodBody("main",
                MethodTypeDesc.of(CD_void, CD_String.arrayType()),
                ClassFile.ACC_PUBLIC | ClassFile.ACC_STATIC,
                code -> code
                        .getstatic(ClassDesc.of("java.lang.System"), "out", printStream)
                        .ldc("generated at runtime")
                        .invokevirtual(printStream, "println",
                                MethodTypeDesc.of(CD_void, CD_String))
                        .return_()));

Files.write(Path.of("out/Hello.class"), bytes);

CD_void and CD_String are static imports from java.lang.constant.ConstantDescs. Write the bytes out and the class runs like any other:

java -cp out Hello
generated at runtime

Gotchas

Five things that will bite you the first week.

Transforms are one-shot unless you say otherwise. A lambda transform that closes over mutable state will misbehave, because the library may replay a build — for example when a short branch offset turns out to be too small. Use ClassTransform.ofStateful when the transform genuinely needs per-traversal state.

parse() throwing late is normal. Laziness means a malformed method body surfaces as an IllegalArgumentException from an accessor, not from parse(). Wrap the traversal, not just the parse call.

Verify before you write. The builders prevent many structural mistakes but not all semantic ones. cf.verify(bytes) returns a List<VerifyError> — an empty list means clean.

List<VerifyError> errors = ClassFile.of().verify(bytes);
if (!errors.isEmpty()) {
    throw new IllegalStateException("bad class file", errors.getFirst());
}

Options belong on the context, not on each call. ClassFile.of(...) takes options that apply to every parse and build made through it. Dropping debug pseudo-instructions when you do not need them makes bulk transforms measurably cheaper:

ClassFile fast = ClassFile.of(ClassFile.DebugElementsOption.DROP_DEBUG);

Dropping debug elements discards local-variable names, which downstream debuggers and stack-trace tooling may want. Use it for throughput work, not for classes a human will step through.

It is not the fastest library, by design. The JEP is explicit that peak performance is a non-goal. If ASM is measurably faster in your hot path and you have already accepted the version-skew maintenance burden, that trade is still yours to make.

Cheat sheet

Java 24 / JEP 484: final, no preview flag, in java.base

Packages: java.lang.classfile + .attribute, .constantpool, .instruction

Entry point
  ClassFile cf = ClassFile.of();
  ClassFile cf = ClassFile.of(ClassFile.DebugElementsOption.DROP_DEBUG);
Parse
  ClassModel m = cf.parse(bytes);        // or cf.parse(Path)
  m.thisClass() / m.methods() / m.fields() / m.majorVersion()
Generate
  byte[] b = cf.build(ClassDesc.of("Hello"), cb -> ...);
  cb.withMethodBody(name, MethodTypeDesc, flags, code -> ...)
Transform
  byte[] b = cf.transformClass(model, transform);
  ClassTransform.dropping(Predicate<ClassElement>)
  ClassTransform.transformingMethods(MethodTransform)
  ClassTransform.transformingMethodBodies(CodeTransform)
  MethodTransform.transformingCode(CodeTransform)
  builder.with(element)                  // pass through — never forget this
Verify
  List<VerifyError> errors = cf.verify(bytes);

Names are constant-pool entries: methodName().stringValue()
Types are descriptors: ClassDesc, MethodTypeDesc (java.lang.constant)
Models are immutable; you read one and build another

Do:

  • Use ClassFile.of() once per tool and share the configured context.
  • Reach for dropping / transformingMethodBodies before writing manual traversals.
  • Pass unmatched elements through with with() in every transform’s default branch.
  • Run verify() in tests over every class your tool emits.

Don’t:

  • Capture mutable state in a transform lambda without ofStateful.
  • Assume parse() has validated the whole file — accessors throw too.
  • Drop debug elements on classes people debug.
  • Hand-manage the constant pool, stack maps, or branch offsets; the builders own those.

Wrap-up

The Class-File API is a maintenance fix disguised as a new library. Class files change every six months and a bundled third-party parser can only catch up after the fact, so the JDK now ships the parser alongside the format it parses.

The API itself is small: parse into immutable models, build with lambdas that receive builders, and treat transformation as a flat-map over elements. dropping, transformingMethods, and transformingMethodBodies cover most of what tools actually do, and the constant pool, stack maps, and offsets are handled underneath.

Reach for it in build plugins, java.lang.instrument agents, and test fixtures that need synthetic classes. Keep a working ASM integration if you are staying on an older JDK — but for anything new on Java 24+, the standard API is the one that will still compile after the next release.