Skip to main content

The Java ClassFile API (JEP 484): Read, Generate and Transform Bytecode Without ASM

java.lang.classfile has been final since JDK 24 (JEP 484). Parse a class, generate one at runtime and load it as a normal or hidden class, and insert timing around every method with ClassTransform, MethodTransform and CodeTransform, all run on JDK 25. The same three jobs are repeated in ASM 9.11.1 and compared, including the traps in both, the class-file versions each reads, a JDK 24/25/26 API diff and what spring-core 7.0.10 actually ships.

A .class file is just a byte array with a strict layout, and a surprising amount of the Java world reads or writes one: Spring scans classes for annotations without loading them, Mockito and Hibernate generate subclasses, profilers and APM agents inject timing code, and jlink and the compiler itself take classes apart. For about twenty years the answer to “how do I do that from Java?” has been ASM. Since JDK 24 there is a second answer that ships inside the JDK: java.lang.classfile, finalised by JEP 484.

This post is for someone who has never opened a class file. It reads one, generates one from scratch and loads it, then rewrites an existing class so every method reports how long it took — and then does the same three jobs again with ASM, compiled and run side by side, so the comparison is between code that executed rather than between marketing claims. Every number below comes from a transcript committed in the companion module, and every code block links to the file it came from.

Versions, all checked against the artefact itself. Everything ran on Temurin 25.0.5+8 (JDK 25, the current LTS). java.lang.classfile is final since JDK 24 (JEP 484, GA 18 March 2025; it was a preview in JDK 22 and 23 as JEPs 457 and 466) — javap -v on ClassFile finds no preview marker on JDK 24.0.2, 25.0.5 or 26.0.2.1 (output/07-api-diff-24-25-26.txt). The comparison library is ASM 9.11.1 (released 17 October 2026, the latest GA on Maven Central); the framework probe read spring-core 7.0.10. JDK 24 and JDK 26 were used only to diff the public API; no demo was run on them.

What is a class file, and why would anyone touch one?

When you compile Target.java the compiler writes Target.class: a table of constants (names, strings, numbers) followed by methods whose bodies are bytecode, the instruction set of the JVM. Bytecode is a stack machine — instructions push values onto an operand stack and pop them again. Here is the real bytecode of the recursive fib method from the demo class, as the JDK’s own javap prints it:

  public static int fib(int);
    Code:
         0: iload_0
         1: iconst_2
         2: if_icmpge     9
         5: iload_0
         6: goto          22
         9: iload_0
        10: iconst_1
        11: isub
        12: invokestatic  #13                 // Method fib:(I)I
        15: iload_0
        16: iconst_2
        17: isub
        18: invokestatic  #13                 // Method fib:(I)I
        21: iadd
        22: ireturn

Output from output/06-javap-instrumented.txt (the original method, before any transform). Read it top to bottom: iload_0 pushes the argument, iconst_2 pushes 2, if_icmpge pops both and jumps if the first is greater or equal, and so on until ireturn.

Why would you read or write this yourself? Because programs that work on programs need it. A framework that wants to know which classes carry @Component can read the annotation from the file without loading the class. A mocking library has to invent a subclass at runtime. A profiler wants to wrap every method in a stopwatch without you editing the source. If you want the background on how .java becomes bytecode and then machine code, the JDK vs JRE vs JVM article walks through it, and Lambda Expressions in Java shows one place where javac emits something you did not write (invokedynamic).

What the three verbs mean in this post. Read (parse) turns bytes into an object you can walk. Generate (build) makes new bytes from nothing. Transform reads existing bytes and writes changed ones. ASM and java.lang.classfile both do all three; the rest of the post is about how.

Going deeper

Why the JDK grew its own library when ASM already existed

The JEP’s argument is about timing, not quality. The class-file format changes with every JDK release, and the JEP describes the JDK as bundling its own copy of ASM for tools such as jar and jlink and for lambda support at run time. JEP 484 says plainly that this “causes a tiresome delay in the uptake of new class-file features”: the JDK cannot emit a new feature until its bundled ASM understands it. A standard API that lives in the JDK moves in lock-step with the JVM that has to run the output. The same text rejects the obvious alternative of simply merging ASM into the JDK as “not the right choice”, because ASM is an old code base with legacy baggage.

Highest class-file major version each reader accepts (measured on JDK 25)656667686970717273Java 212223242526272829?java.lang.classfile on JDK 25: stops at 69ASM 9.11.1: reads up to 72 (Java 28)JDK 25 itself cannot run any of these (and 73 is rejected by both)The JDK API can never be older than the JVM it runs on; ASM can read further ahead, but only once someone bumps the jar.

The chart shows what I measured by patching the version bytes of Target.class (source VersionLimits.java, output output/11-version-limits.txt). The honest reading is a trade-off, not a victory: java.lang.classfile on JDK 25 refuses class-file version 70 with IllegalArgumentException: Unsupported class file version: 70, while ASM 9.11.1 happily reads up to version 72. ASM got there by shipping new Opcodes constants (9.9 for Java 26, 9.10 for Java 27, 9.11 for Java 28, per the ASM release list) — so ASM is ahead of the JDK today. What the JDK API guarantees is different: whatever JVM you run on, the reader understands every class file that JVM can load, with no jar to bump.

running on Java 25; ClassFile.latestMajorVersion() = 69; ASM Opcodes.V25=69, V28=72
major 69:
  java.lang.classfile : parsed, 8 methods
  ASM 9.11.1          : parsed, 8 methods
major 70:
  java.lang.classfile : java.lang.IllegalArgumentException: Unsupported class file version: 70
  ASM 9.11.1          : parsed, 8 methods

Output from output/11-version-limits.txt, trimmed to the first three versions.

When this matters. If a build tool runs on an older JDK and has to read class files compiled by a newer one (multi-release jars, dependencies built with a newer --release), the JDK API cannot help and a current ASM can. If your tool runs on the same JDK it analyses, the JDK API removes a moving part. That is the whole decision, and it is why the framework section below looks the way it does.

Going deeper

The smallest mental model: models, elements, builders and transforms

Four nouns cover the whole API. A model is something you read: ClassModel, MethodModel, CodeModel. An element is one item inside a model, seen as a flat sequence you can pattern-match on — a method, a field, an attribute, and inside a method body each instruction. A builder is something you write to: ClassBuilder, MethodBuilder, CodeBuilder. A transform is a function that receives each element together with a builder and decides what to write. The Javadoc adds two properties worth knowing: models and elements are immutable, and they are inflated lazily, so parsing a class does not decode every method body until you ask.

One vocabulary for all three jobsReadbyte[]ClassFile.of().parse(bytes)ClassModelMethodModelCodeModelInstructionGenerateClassBuilder → CodeBuildercf.build(desc, builder)byte[]defineClass / hidden classTransformClassModel+ClassTransform → MethodTransform → CodeTransformcf.transformClass(model, transform)byte[]A model you read, a builder you write to, a transform that connects them. Models are immutable and inflated lazily (Javadoc).

The diagram is the whole article in one picture: the top lane is the next section, the middle lane the one after, the bottom lane the section on timing. Anyone who knows ASM can map the nouns directly:

JobASM 9.11.1java.lang.classfile
ReadClassReader.accept(ClassVisitor, flags) pushes events into your visitorClassFile.of().parse(bytes) returns a ClassModel you iterate or navigate
WriteClassWriter plus visitMethod, visitInsn, …cf.build(desc, clb -> ...) with ClassBuilder and CodeBuilder
Stack-map framesClassWriter.COMPUTE_FRAMESgenerated when required by default (StackMapsOption)
Rewritechain of ClassVisitor / MethodVisitor subclassesClassTransform, MethodTransform, CodeTransform passed to transformClass
CheckCheckClassAdapter (asm-util)cf.verify(bytes) returns a list of VerifyError

Going deeper

Read a class: ClassFile.of().parse

The smallest working program is two lines: get a ClassFile (it carries options, and of() gives the defaults) and parse bytes. Everything after that is accessor calls. The demo reads Target.class from the class path; Bytes.of is a three-line helper that does exactly that.

byte[] bytes = Bytes.of(Target.class);
ClassModel cm = ClassFile.of().parse(bytes);

System.out.println("== header ==");
System.out.println("bytes            : " + bytes.length);
System.out.println("this             : " + cm.thisClass().asInternalName());
System.out.println("super            : " + cm.superclass().orElseThrow().asInternalName());
System.out.println("version          : " + cm.majorVersion() + "." + cm.minorVersion()
        + "  (ClassFile.JAVA_25_VERSION = " + ClassFile.JAVA_25_VERSION + ")");
System.out.println("flags            : " + flags(cm.flags().flags()));
System.out.println("constant pool    : " + cm.constantPool().size() + " slots");

System.out.println("\n== fields ==");

Excerpt from ParseDemo.java.

== header ==
bytes            : 1488
this             : demo/Target
super            : java/lang/Object
version          : 69.0  (ClassFile.JAVA_25_VERSION = 69)
flags            : public,super 
constant pool    : 69 slots

== fields ==
final,private name Ljava/lang/String;
private calls I

== methods ==
public <init>(Ljava/lang/String;)V
public,static fib(I)I

Output from output/01-parse.txt. The version line is the proof the file is what --release 25 says: major 69.

The method list there is just cm.methods(), but the more idiomatic way to process a whole class is the element stream: iterate the model and pattern-match. This is where the API shows it was designed for modern Java — the element types are sealed interfaces, so a switch over them is checked by the compiler. The method below counts what each method body calls, which is how a framework finds dependencies without loading classes.

static TreeMap<String, Integer> invokeCounts(ClassModel cm) {
    TreeMap<String, Integer> counts = new TreeMap<>();
    for (MethodModel m : cm.methods()) {
        m.code().ifPresent(code -> {
            for (CodeElement e : code) {
                switch (e) {
                    case InvokeInstruction i -> counts.merge(
                            i.owner().asInternalName() + "." + i.name().stringValue(), 1, Integer::sum);
                    case InvokeDynamicInstruction i -> counts.merge(
                            "indy:" + i.name().stringValue(), 1, Integer::sum);
                    default -> { }
                }
            }
        });
    }
    return counts;
}

From ParseDemo.java.

== invoke targets in the whole class ==
  2 x demo/Target.fib
  1 x indy:makeConcatWithConstants
  1 x java/lang/IllegalStateException.<init>
  1 x java/lang/Object.<init>
  1 x java/lang/Thread.sleep

Output from output/01-parse.txt. indy:makeConcatWithConstants is the "Hello, " + who in greet: javac compiles string concatenation to an invokedynamic, which the model exposes as its own instruction type.

Going deeper: the element stream, one instruction at a time, and parsing a JDK class

Iterating cm yields class elements in file order — flags, version, superclass, interfaces, then fields and methods, then attributes. Iterating a method’s CodeModel yields instructions interleaved with pseudo-elements such as labels and line numbers, which is why the demo filters with instanceof Instruction.

== class elements, in file order (the model is a stream of elements) ==
  AccessFlags
  ClassFileVersion
  Superclass
  Interfaces
  field  name
  field  calls
  method <init>
  method fib
  method sumTo
  method greet
  method pause
  method boom
  method explode
  method calls
  SourceFile=Target.java
  BoundInnerClassesAttribute

Output from output/01-parse.txt.

== a JDK class from the runtime image: java.lang.String ==
bytes 50116, major 69, methods 171, fields 11, interfaces 5

Output from output/01-parse.txt: java.lang.String read from the runtime image through the jrt: file system, 171 methods, no problem.

The Javadoc warns that accessors on a lazily inflated model may throw IllegalArgumentException for a malformed file at the moment you touch the broken part, not at parse time. I did not test a corrupted file in this module, so that sentence is the documentation’s, not mine.

Going deeper

Generate a class from scratch, then load it

ClassFile.build takes the name of the class you want and a lambda that receives a ClassBuilder. Inside it, withMethodBody declares a method and hands you a CodeBuilder whose methods are the JVM instructions by name. The class below has a constructor, add, hello, a loop (sumTo) and a branch (classify). Note what is missing: no stack-map frames and no max-stack or max-locals — the library works those out.

static byte[] generate(ClassFile cf) {
    return cf.build(GENERATED, clb -> clb
        .withFlags(ClassFile.ACC_PUBLIC | ClassFile.ACC_SUPER)
        .withMethodBody(INIT_NAME, MTD_void, ClassFile.ACC_PUBLIC, cob -> cob
            .aload(0)
            .invokespecial(CD_Object, INIT_NAME, MTD_void)
            .return_())
        // static int add(int a, int b) { return a + b; }
        .withMethodBody("add", MethodTypeDesc.of(CD_int, CD_int, CD_int), ClassFile.ACC_PUBLIC | ClassFile.ACC_STATIC, cob -> cob
            .iload(0).iload(1).iadd().ireturn())
        // static String hello() { return "Hello from generated bytecode"; }
        .withMethodBody("hello", MethodTypeDesc.of(CD_String), ClassFile.ACC_PUBLIC | ClassFile.ACC_STATIC, cob -> cob
        ...

First half of generate in GenerateDemo.java.

    // static long sumTo(int n) { long s = 0; for (int i = 1; i <= n; i++) s += i; return s; }
    .withMethodBody("sumTo", MethodTypeDesc.of(CD_long, CD_int), ClassFile.ACC_PUBLIC | ClassFile.ACC_STATIC, cob -> {
        int s = cob.allocateLocal(TypeKind.LONG);
        int i = cob.allocateLocal(TypeKind.INT);
        Label loop = cob.newLabel(), end = cob.newLabel();
        cob.lconst_0().lstore(s)
           .iconst_1().istore(i)
           .labelBinding(loop)
           .iload(i).iload(0).if_icmpgt(end)
           .lload(s).iload(i).i2l().ladd().lstore(s)
           .iinc(i, 1)
           .goto_(loop)
           .labelBinding(end)
           .lload(s).lreturn();
    })

The loop and the branch, from GenerateDemo.java. Labels mark jump targets; ifThenElse builds the branch with nested lambdas.

The result is a normal class file. This is the real javap of the generated bytes, trimmed to the loop method. The StackMapTable at the bottom was written by the library, not by me:

  public static long sumTo(int);
    descriptor: (I)J
    flags: (0x0009) ACC_PUBLIC, ACC_STATIC
    Code:
      stack=4, locals=4, args_size=1
         0: lconst_0
         1: lstore_1
         2: iconst_1
         3: istore_3
         4: iload_3
         5: iload_0
         6: if_icmpgt     20
         9: lload_1
        10: iload_3
        11: i2l
        12: ladd
        13: lstore_1
        14: iinc          3, 1
        17: goto          4
        20: lload_1
        21: lreturn
      StackMapTable: number_of_entries = 2
        frame_type = 253 /* append */
          offset_delta = 4
          locals = [ long, int ]
        frame_type = 15 /* same */

Output from output/03-javap-generated.txt, which also has the full constant pool and all five methods.

Now load it. Lookup.defineClass defines the class in the same package and loader as the lookup class; defineHiddenClass defines one that nothing can find by name, which is what you want for generated helpers (lambdas use the same mechanism). The demo does both with the same bytes and calls the methods through MethodHandles.

// 1. Verify without loading: returns a list of VerifyError, empty if the class is well formed.
List<VerifyError> problems = cf.verify(bytes);
System.out.println("ClassFile.verify(bytes) -> " + problems.size() + " problem(s)");
Check.that(problems.isEmpty(), "verify() finds nothing wrong with the generated class");

// 2. Define it as an ordinary class in this package (Lookup.defineClass needs the same package as the lookup class).
MethodHandles.Lookup lookup = MethodHandles.lookup();
Class<?> normal = lookup.defineClass(bytes);
System.out.println("defineClass       -> " + normal.getName() + "  hidden=" + normal.isHidden()
        + "  findable by name=" + (Class.forName("demo.Generated") == normal));
exercise(normal, "defineClass");

// 3. Define the same bytes as a hidden class: no name anyone can resolve, unloadable with its defining loader's GC.
MethodHandles.Lookup hiddenLookup = lookup.defineHiddenClass(bytes, true);
Class<?> hidden = hiddenLookup.lookupClass();

From GenerateDemo.java.

generated 501 bytes, major version 69 (wrote target/gen/demo/Generated.class)
ClassFile.verify(bytes) -> 0 problem(s)
defineClass       -> demo.Generated  hidden=false  findable by name=true
[defineClass] add(2,3)=5  hello()=Hello from generated bytecode  sumTo(100)=5050  classify(-4)=negative  classify(4)=non-negative
defineHiddenClass -> name starts with 'demo.Generated/': true  hidden=true  distinct from the normal class=true
[hidden] add(2,3)=5  hello()=Hello from generated bytecode  sumTo(100)=5050  classify(-4)=negative  classify(4)=non-negative

Output from output/02-generate.txt (the PASS assertion lines are in the file).

Three ways to turn bytes into a running classLookup.defineClasssame package and loaderas the lookup classhidden=falseClass.forName finds it:findable by name=truedefineHiddenClass(bytes, true)name is demo.Generated/0x…hidden=trueClass.forName(name) throwsClassNotFoundExceptionnothing can link to ityour own ClassLoaderused for the instrumenteddemo.Target in TransformDemoa second Class object withthe same name, side by sidewith the originalPick by who needs to find the class afterwards: nobody (hidden), the application (defineClass), or code that must replace an existing name (own loader).

The three columns show what each loading route did in this run, and the third is used later: the timing demo defines its rewritten demo.Target in a class loader of its own, so the instrumented class and the original coexist under the same name. Two ASM-flavoured habits do not carry over: you never compute frames yourself, and you rarely need a custom loader just to run generated code.

Going deeper: why hidden classes, and what verify is for

A hidden class cannot be referenced from other classes’ bytecode and has a name with a suffix that varies per run (the demo therefore prints only that the name starts with demo.Generated/). It can be unloaded independently when its defining loader is collected, and that is why the JDK uses it for lambda proxies. cf.verify(bytes) runs the JVM’s verifier logic on bytes without defining anything, which makes it a cheap assertion in a code generator’s unit tests — the demos use it exactly that way.

Going deeper

What a bad class looks like

A code generator is a program that writes programs, so the interesting question is what happens when it writes a wrong one. I produced five wrong classes on purpose. The builder protects you from some mistakes and not from others, and the difference is worth knowing before you ship a transform.

DROP_STACK_MAPS: verify() -> 1 problem(s)
  Expecting a stackmap frame at branch target 8 in demo/Broken::pick(int) @1
DROP_STACK_MAPS: defineClass -> java.lang.VerifyError
  Expecting a stackmap frame at branch target 8
  Exception Details:
    Location:
ireturn in a String method: build() succeeded (126 bytes)
  verify: Bad return type in demo/Wrong::bad() @1 (java/lang/String is not assignable from integer_type)
underflow: build() threw java.lang.IllegalArgumentException
  Operand stack underflow at bytecode offset 0 of method u()
swapped labels: build() succeeded (173 bytes), verify() -> 0 problem(s)
swapped labels: defineClass -> java.lang.ClassFormatError: Illegal exception table range in class file demo/Swapped

Output from output/04-broken-generate.txt.

  • Stack maps dropped on request (StackMapsOption.DROP_STACK_MAPS) on a method with a branch: verify reports it, and defining the class throws VerifyError. The same code with default options verifies.
  • ireturn in a method declared to return String: build happily returns 126 bytes; only verify (or the JVM) notices.
  • Stack underflow: build itself throws IllegalArgumentException. That is because the method has a branch, so the library had to compute stack maps and walked the stack. I did not test a straight-line underflow, so I will not claim it is caught there.
The trap that cost me a compile-run cycle. exceptionCatchAll(start, end, handler) takes three Labels, and they are all the same type. I passed (handler, start, end) while writing the timing transform. build succeeded, verify() found 0 problems, and the JVM refused the class with ClassFormatError: Illegal exception table range (last two lines of the output above). Moral: a passing verify is necessary, not sufficient — always load and run what you generate. Source: BrokenGenerate.java.

Going deeper

Transform: insert timing around every method

This is the job the API was really built for. The goal: take Target.class as javac produced it, and rewrite it so that every method (except constructors) records how long it ran into Timings.record(String, long). A transform is not a rewrite of the whole file; it is a function that sees each element and either forwards it or writes something else. Three layers, each narrower than the last:

  1. a ClassTransform looks at class elements; when it meets a method it hands it to
  2. a MethodTransform, which forwards the method’s Code to
  3. a CodeTransform, which sees every instruction and can emit extra ones before or after it. atStart and atEnd bracket the whole body.
public static byte[] instrument(byte[] original, boolean alsoOnException) {
    ClassFile cf = ClassFile.of();
    ClassModel model = cf.parse(original);
    String owner = model.thisClass().asInternalName();

    ClassTransform perClass = (ClassBuilder cb, ClassElement ce) -> {
        if (ce instanceof MethodModel mm && mm.code().isPresent() && !mm.methodName().equalsString("<init>")) {
            String label = owner.substring(owner.lastIndexOf('/') + 1) + "." + mm.methodName().stringValue();
            cb.transformMethod(mm, MethodTransform.transformingCode(
                    CodeTransform.ofStateful(() -> new Timer(label, alsoOnException))));
        } else {
            cb.with(ce);       // everything else is copied unchanged
        }
    };
    return cf.transformClass(model, perClass);
}

The entry point from TimingTransform.java. CodeTransform.ofStateful makes a fresh Timer for every method body, so the local-variable slot and labels are never shared.

private static final class Timer implements CodeTransform {
    private final String label;
    private final boolean alsoOnException;
    private int startSlot;
    private Label tryStart, tryEnd;

    Timer(String label, boolean alsoOnException) {
        this.label = label;
        this.alsoOnException = alsoOnException;
    }

    @Override
    public void atStart(CodeBuilder b) {
        startSlot = b.allocateLocal(TypeKind.LONG);                 // a fresh local; max_locals is recomputed for us
        b.invokestatic(SYSTEM, "nanoTime", NANO_TIME).lstore(startSlot);
        if (alsoOnException) {
            tryStart = b.newLabel();
            tryEnd = b.newLabel();
            b.labelBinding(tryStart);
        }
    }

    @Override
    public void accept(CodeBuilder b, CodeElement e) {
        if (e instanceof ReturnInstruction) {
            emitRecord(b);                                           // the return value is on the stack; our code is stack-neutral
        }
        b.with(e);                                                   // forward the original element
    }

    @Override
    public void atEnd(CodeBuilder b) {
        if (!alsoOnException) return;
        b.labelBinding(tryEnd);
        Label handler = b.newLabel();
        b.labelBinding(handler);
        int ex = b.allocateLocal(TypeKind.REFERENCE);
        b.astore(ex);
        emitRecord(b);
        b.aload(ex).athrow();
        b.exceptionCatchAll(tryStart, tryEnd, handler);              // (start, end, handler): catch-any over the original body
    }

    private void emitRecord(CodeBuilder b) {
        b.ldc(label)
         .invokestatic(SYSTEM, "nanoTime", NANO_TIME)
         .lload(startSlot)
         .lsub()
         .invokestatic(TIMINGS, "record", RECORD);
    }
}

The Timer, from TimingTransform.java. allocateLocal reserves a fresh long slot and the library recomputes max_locals and the stack maps.

fib(int) before and after the exception-safe timing transformjavac output: 23 bytes0..21 bodyiload_0 … iadd22 ireturnafter: 50 bytes0..3 nanoTime; lstore_14..25 original body,same instructions, +4 shift26..33 ldc; nanoTime; lsub; record36 ireturn37..49 astore_3; record;aload_3; athrowException tablefrom 4to 37goto 37type anyEvery return gets the record call inserted just before it; any exception leaving instructions 4..36 lands in the handler at 37.

The diagram uses the numbers from the real javap output below. A four-byte prologue (two instructions), the original body untouched (every offset shifted by four), a five-instruction record call placed immediately before the ireturn, and a catch-all handler at offset 37 covering offsets 4 to 36. That handler is the part a first attempt leaves out.

  public static int fib(int);
    Code:
         0: invokestatic  #74                 // Method java/lang/System.nanoTime:()J
         3: lstore_1
         4: iload_0
         5: iconst_2
         6: if_icmpge     13
         9: iload_0
        10: goto          26
        13: iload_0
        14: iconst_1
        15: isub
        16: invokestatic  #13                 // Method fib:(I)I
        19: iload_0
        20: iconst_2
        21: isub
        22: invokestatic  #13                 // Method fib:(I)I
        25: iadd
        26: ldc           #76                 // String Target.fib
        28: invokestatic  #74                 // Method java/lang/System.nanoTime:()J
        31: lload_1
        32: lsub
        33: invokestatic  #82                 // Method demo/Timings.record:(Ljava/lang/String;J)V
        36: ireturn
        37: astore_3
        38: ldc           #76                 // String Target.fib
        40: invokestatic  #74                 // Method java/lang/System.nanoTime:()J
        43: lload_1
        44: lsub
        45: invokestatic  #82                 // Method demo/Timings.record:(Ljava/lang/String;J)V
        48: aload_3
        49: athrow
      Exception table:
         from    to  target type
             4    37    37   any

Output from output/06-javap-instrumented.txt: fib after the exception-safe transform (the file has the original and the naive version too).

I wrote the naive version first — record only at ReturnInstruction — and kept it in the module because the difference is the lesson. Both were loaded into their own class loader, driven with the same calls, and the recorded counts read back:

-- naive: time normal returns only (1799 bytes; verify() problems: 0)
  boom() threw java.lang.IllegalStateException: boom
  explode() threw ArrayIndexOutOfBoundsException
  fib      recorded 177 call(s)
  sumTo    recorded   1 call(s)
  greet    recorded   1 call(s)
  pause    recorded   1 call(s)
  boom     recorded   0 call(s)
  explode  recorded   0 call(s)

Naive transform, from output/05-transform.txt (lines 3 to 12).

-- full: also time when an exception leaves the method (2155 bytes; verify() problems: 0)
  boom() threw java.lang.IllegalStateException: boom
  explode() threw ArrayIndexOutOfBoundsException
  fib      recorded 177 call(s)
  sumTo    recorded   1 call(s)
  greet    recorded   1 call(s)
  pause    recorded   1 call(s)
  boom     recorded   1 call(s)
  explode  recorded   1 call(s)

Exception-safe transform, from output/05-transform.txt (lines 15 to 24). boom throws explicitly; explode fails with an ArrayIndexOutOfBoundsException raised inside the JVM, so there is no athrow in its own bytecode at all — the catch-all handler is the only thing that sees it.

Three facts hold in the transcript, each asserted by the demo. fib(10) makes 177 calls and 177 timings were recorded, so recursion is timed per call (and an outer call’s time includes its inner calls). The 50 ms pause was recorded as at least 50,000,000 ns. And fib(20) and sumTo(12345) return the same answers before and after, so the instrumented class still behaves. The class grew from 1,488 to 1,799 bytes (naive) and 2,155 bytes (with handlers).

What this toy leaves out. A production agent also has to decide what not to time (a nanoTime pair around a tiny method costs more than the method), handle synchronized and constructors (code before super() is restricted), and register itself through java.lang.instrument instead of a hand-made class loader. None of that is shown here; I only tested the loader route.

Going deeper

The same three jobs in ASM, compiled and run

To compare fairly I wrote the ASM version of each job in the same module against ASM 9.11.1 (asm, asm-commons, asm-util; the build file is pom.xml), ran it, and asserted that the results match the ClassFile API ones. Parsing lists the same eight methods and the same invoke targets. Generation produces a 501-byte class from both libraries that passes ClassFile.verify. Instrumentation records the same counts.

ASM generated 501 bytes; java.lang.classfile generated 501 bytes
[ASM-generated] add(2,3)=5  hello()=Hello from generated bytecode  sumTo(100)=5050  classify(-4)=negative  classify(4)=non-negative
PASS ASM-generated: generated methods return the right values
PASS both generators emit the same method list

Output from output/09-asm-generate.txt.

public static byte[] instrument(byte[] original) {
    ClassReader cr = new ClassReader(original);
    ClassWriter cw = new ClassWriter(cr, ClassWriter.COMPUTE_FRAMES | ClassWriter.COMPUTE_MAXS);
    String simple = cr.getClassName().substring(cr.getClassName().lastIndexOf('/') + 1);
    cr.accept(new ClassVisitor(Opcodes.ASM9, cw) {
        @Override public MethodVisitor visitMethod(int access, String name, String desc, String sig, String[] ex) {
            MethodVisitor mv = super.visitMethod(access, name, desc, sig, ex);
            if (name.equals("<init>") || (access & (Opcodes.ACC_ABSTRACT | Opcodes.ACC_NATIVE)) != 0) return mv;
            return new AdviceAdapter(Opcodes.ASM9, mv, access, name, desc) {
                final Label tryStart = new Label(), tryEnd = new Label(), handler = new Label();
                int startSlot;

                @Override protected void onMethodEnter() {
                    startSlot = newLocal(Type.LONG_TYPE);
                    invokeStatic(Type.getType(System.class), new org.objectweb.asm.commons.Method("nanoTime", "()J"));
                    storeLocal(startSlot);
                    visitLabel(tryStart);
                }

                @Override protected void onMethodExit(int opcode) {
                    if (opcode != ATHROW) emitRecord();       // athrow is covered by the catch-all handler below
                }

                @Override public void visitMaxs(int maxStack, int maxLocals) {
                    visitLabel(tryEnd);
                    visitLabel(handler);
                    int ex = newLocal(Type.getType(Throwable.class));
                    storeLocal(ex);
                    emitRecord();
                    loadLocal(ex);
                    visitInsn(ATHROW);
                    visitTryCatchBlock(tryStart, tryEnd, handler, null);
                    super.visitMaxs(maxStack, maxLocals);
                }

                private void emitRecord() {
                    visitLdcInsn(simple + "." + name);
                    invokeStatic(Type.getType(System.class), new org.objectweb.asm.commons.Method("nanoTime", "()J"));
                    loadLocal(startSlot);
                    visitInsn(LSUB);
                    invokeStatic(Type.getObjectType("demo/Timings"), new org.objectweb.asm.commons.Method("record", "(Ljava/lang/String;J)V"));
                }
            };
        }
    }, readerFlags);
    return cw.toByteArray();
}

Timing instrumentation with ASM’s AdviceAdapter, from AsmInstrument.java.

The ASM version is where it gets interesting. AdviceAdapter (from asm-commons) already knows how to call you at method entry and at every exit, so the entry and return halves are shorter than in the ClassFile version; I still had to add the catch-all handler by hand in visitMaxs. Here is the size of the code that does each job, counted mechanically by the run script:

Lines of code in the method(s) that do the job (non-blank, non-comment), counted by scripts/run-all.sh:
generate demo.Generated    java.lang.classfile  32   ASM  63
timing instrumentation     java.lang.classfile  61   ASM  43

Output from output/12-line-counts.txt.

So the picture is mixed, and I would not summarise it as “less code”. Generating a class took about half the lines with java.lang.classfile (no visitMaxs, no visitEnd, ifThenElse instead of hand-placed labels, descriptors as MethodTypeDesc instead of strings). Instrumenting took more lines, because AdviceAdapter is a ready-made helper for exactly this task and the ClassFile API has no equivalent in the JDK.

An ASM trap I hit, and the equivalent in the new API. My first ASM version passed 0 as the reader flags and died with LocalVariablesSorter only accepts expanded frames (see ClassReader.EXPAND_FRAMES) — AdviceAdapter extends LocalVariablesSorter. The transcript is output/15-asm-expand-frames.txt (source AsmGotcha.java). The ClassFile API has no flag to forget here, but it has its own trap: the three-Label argument order in the previous section.
Going deeper: speed, measured honestly (indicative only)

The JEP says the JDK team compares both libraries and runs performance tests as ASM uses are replaced. I wanted a number of my own, so Bench parses the first 2,000 classes of java.base (about 7 MB), visits every instruction, and separately parses, copies and writes each class back. It times the benchmark thread’s own CPU time, because the sandbox is a shared 2-vCPU machine whose wall-clock time swung wildly. It is not JMH, and JIT and GC threads are not counted.

--- run 1 of 3 ---
running on Java 25.0.5+8-LTS, ASM 9.11.1; INDICATIVE numbers, thread CPU time, shared 2-vCPU sandbox, no JMH
2000 classes from java.base, 7001 KiB; 4 warm-up rounds, median of 9
instructions seen: java.lang.classfile 478741, ASM 478741
parse + visit every instruction : java.lang.classfile   119.4 ms   ASM   166.1 ms   (ASM / classfile = 1.39)
parse + copy + write back       : java.lang.classfile    38.6 ms   ASM    78.9 ms   (ASM / classfile = 2.05)
--- run 2 of 3 ---
running on Java 25.0.5+8-LTS, ASM 9.11.1; INDICATIVE numbers, thread CPU time, shared 2-vCPU sandbox, no JMH
2000 classes from java.base, 7001 KiB; 4 warm-up rounds, median of 9
instructions seen: java.lang.classfile 478741, ASM 478741
parse + visit every instruction : java.lang.classfile    63.9 ms   ASM   166.2 ms   (ASM / classfile = 2.60)
parse + copy + write back       : java.lang.classfile    26.8 ms   ASM    88.1 ms   (ASM / classfile = 3.29)
--- run 3 of 3 ---
running on Java 25.0.5+8-LTS, ASM 9.11.1; INDICATIVE numbers, thread CPU time, shared 2-vCPU sandbox, no JMH
2000 classes from java.base, 7001 KiB; 4 warm-up rounds, median of 9
instructions seen: java.lang.classfile 478741, ASM 478741
parse + visit every instruction : java.lang.classfile    48.8 ms   ASM    44.2 ms   (ASM / classfile = 0.90)
parse + copy + write back       : java.lang.classfile    18.8 ms   ASM   101.6 ms   (ASM / classfile = 5.39)

Output from output/13-bench.txt: three separate JVM runs.

What the three runs support: both libraries saw exactly the same number of instructions (478,741), and the parse-copy-write round trip was faster with java.lang.classfile in every run (ASM / classfile ratios 2.05, 3.29 and 5.39). What they do not support is a verdict on plain instruction visiting: that ratio was 1.39, 2.60 and 0.90 — ASM ahead in the third run. I would not publish either library as faster at that task from this evidence. Treat it as “same order of magnitude” and measure your own workload with JMH.

Going deeper

Who is actually moving over?

The honest answer is: the clearest case I could verify is Spring Framework, and even there ASM stays. The JEP’s goal is that tools built on the standard API pick up new class-file formats without waiting for a library release, and a Spring Boot issue (#47687) states the practical point: “Spring Framework can now use the ClassFile API if a Java 24+ runtime is used.” Rather than trust that sentence, I opened the published jar:

$ unzip -l spring-core-7.0.10.jar | grep 'META-INF/versions/24/.*\.class' | wc -l
9

$ unzip -l spring-core-7.0.10.jar | grep -c 'org/springframework/asm/.*\.class'     (the repackaged ASM is still there)
37

$ javap -v -cp <jar>!/META-INF/versions/24 ...ClassFileMetadataReader | grep java/lang/classfile   (does it really call the JDK API?)
java/lang/classfile/ClassFile
java/lang/classfile/ClassModel

$ unzip -p spring-core-7.0.10.jar META-INF/MANIFEST.MF | grep -i multi-release
Multi-Release: true

Output from output/14-spring-core-probe.txt, produced by spring-core-probe.sh.

Read it as follows. spring-core 7.0.10 is a multi-release jar; under META-INF/versions/24 it carries nine classes, including a ClassFileMetadataReader whose constant pool references java/lang/classfile/ClassFile and ClassModel. The multi-release layout means a Java 24+ runtime picks that reader up, while older runtimes keep using the repackaged ASM (37 classes under org/springframework/asm), which is still in the jar. I inspected the jar; I did not run a Spring application to watch the switch happen. That is the multi-release pattern in practice: the new API where the runtime has it, the old library where it does not. I did not check Mockito, Byte Buddy, Hibernate or any other framework, so I make no claim about them.

Going deeper

What changed after JDK 24

Because the API is final, changes since 24 are additions. I diffed the public signatures of every java.lang.classfile type on Temurin 24.0.2, 25.0.5 and 26.0.2.1 with a small script (api_diff.py) rather than rely on release notes:

=== JDK 24 -> JDK 25 ===
+ java.lang.classfile.ClassFile: public static final int JAVA_25_VERSION;
~ java.lang.classfile.attribute.UnknownAttribute
    was: public interface java.lang.classfile.attribute.UnknownAttribute extends java.lang.classfile.Attribute<java.lang.classfile.attribute.UnknownAt ...
    now: public interface java.lang.classfile.attribute.UnknownAttribute extends java.lang.classfile.Attribute<java.lang.classfile.attribute.UnknownAt ...
+ java.lang.classfile.constantpool.ClassEntry: public abstract boolean matches(java.lang.constant.ClassDesc);
+ java.lang.classfile.constantpool.MethodTypeEntry: public abstract boolean matches(java.lang.constant.MethodTypeDesc);
+ java.lang.classfile.constantpool.ModuleEntry: public abstract boolean matches(java.lang.constant.ModuleDesc);
+ java.lang.classfile.constantpool.PackageEntry: public abstract boolean matches(java.lang.constant.PackageDesc);
+ java.lang.classfile.constantpool.StringEntry: public abstract boolean equalsString(java.lang.String);
+ java.lang.classfile.constantpool.Utf8Entry: public abstract boolean isFieldType(java.lang.constant.ClassDesc);
+ java.lang.classfile.constantpool.Utf8Entry: public abstract boolean isMethodType(java.lang.constant.MethodTypeDesc);
239 classes in old, 239 in new, 9 difference(s)
=== JDK 25 -> JDK 26 ===
+ java.lang.classfile.ClassFile: public static final int JAVA_26_VERSION;
239 classes in old, 239 in new, 1 difference(s)

Output from output/07-api-diff-24-25-26.txt (two very long lines shortened here; the file has them in full).

JDK 25 adds a JAVA_25_VERSION constant, matches(...) and equalsString/isFieldType/isMethodType helpers on constant-pool entries, and lets UnknownAttribute appear inside a Code body. JDK 26 adds JAVA_26_VERSION and nothing else in this package. Nothing was removed or changed incompatibly in either step, which is what “final” is supposed to mean. (The listing excludes the internal PackageSnippets documentation classes that exist only in the JDK 24 image.)

Going deeper

  • run-all.sh — builds the three API dumps when you set JDK24, JDK25 and JDK26

Should you even do this?

Usually not directly — but now you can read what your tools do. Most application code should never touch a class file; use a library that does (a mocking framework, an agent, a framework scanner) and let it pick its bytecode backend. Reach for java.lang.classfile when you are writing the tool: a build step that inspects or rewrites classes on the same JDK it runs on, a code generator, or a test that asserts something about compiled output. Stay on (a current) ASM when your tool must read class files newer than the JDK it runs on, or when you need AdviceAdapter-style helpers that the JDK API does not provide. Whatever you choose, verify is not enough: load the class and run it, as the swapped-labels case showed.

Further reading

No Comments yet!

Leave a Reply

This site uses Akismet to reduce spam. Learn how your comment data is processed.