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.classfileis 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 -vonClassFilefinds 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
- JVMS chapter 4, The class File Format — the layout every tool in this post implements
- javap tool reference
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.
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
- JEP 484: Class-File API — goals, motivation and the preview history (JEP 457, JEP 466)
- ASM release history
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.
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:
| Job | ASM 9.11.1 | java.lang.classfile |
|---|---|---|
| Read | ClassReader.accept(ClassVisitor, flags) pushes events into your visitor | ClassFile.of().parse(bytes) returns a ClassModel you iterate or navigate |
| Write | ClassWriter plus visitMethod, visitInsn, … | cf.build(desc, clb -> ...) with ClassBuilder and CodeBuilder |
| Stack-map frames | ClassWriter.COMPUTE_FRAMES | generated when required by default (StackMapsOption) |
| Rewrite | chain of ClassVisitor / MethodVisitor subclasses | ClassTransform, MethodTransform, CodeTransform passed to transformClass |
| Check | CheckClassAdapter (asm-util) | cf.verify(bytes) returns a list of VerifyError |
Going deeper
- java.lang.classfile package Javadoc — the lazy-inflation and immutability notes quoted above come from its overview
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
- ParseDemo.java — also lists fields, access flags and the constant-pool size
- ClassModel Javadoc
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).
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
- GenerateDemo.java — also writes
Generated.classso you can runjavapon it yourself - Lookup.defineHiddenClass
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:verifyreports it, and defining the class throwsVerifyError. The same code with default options verifies. ireturnin a method declared to returnString:buildhappily returns 126 bytes; onlyverify(or the JVM) notices.- Stack underflow:
builditself throwsIllegalArgumentException. 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 threeLabels, and they are all the same type. I passed(handler, start, end)while writing the timing transform.buildsucceeded,verify()found 0 problems, and the JVM refused the class withClassFormatError: Illegal exception table range(last two lines of the output above). Moral: a passingverifyis necessary, not sufficient — always load and run what you generate. Source:BrokenGenerate.java.
Going deeper
- BrokenGenerate.java — all five cases
- ClassFile.StackMapsOption
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:
- a
ClassTransformlooks at class elements; when it meets a method it hands it to - a
MethodTransform, which forwards the method’sCodeto - a
CodeTransform, which sees every instruction and can emit extra ones before or after it.atStartandatEndbracket 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.
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 (ananoTimepair around a tiny method costs more than the method), handlesynchronizedand constructors (code beforesuper()is restricted), and register itself throughjava.lang.instrumentinstead of a hand-made class loader. None of that is shown here; I only tested the loader route.
Going deeper
- TimingTransform.java and TransformDemo.java — the custom loader and the assertions
- Timings.java — the sink the injected code calls; it must be public because it is called across loaders
- ClassTransform Javadoc
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 passed0as the reader flags and died withLocalVariablesSorter only accepts expanded frames (see ClassReader.EXPAND_FRAMES)—AdviceAdapterextendsLocalVariablesSorter. The transcript isoutput/15-asm-expand-frames.txt(sourceAsmGotcha.java). The ClassFile API has no flag to forget here, but it has its own trap: the three-Labelargument 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
- AsmParse.java, AsmGenerate.java, AsmInstrument.java — the ASM side of every comparison, each with assertions against the ClassFile result
- ASM project site and the ASM guide (PDF)
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
- spring-boot issue #47687 — the metadata-reader discussion quoted above
- SonarSource: Java 24, parsing class files — static-analysis view of the API (August 2025); it does not say Sonar’s own analyzer has adopted it
- spring-core-probe.sh — takes a version argument, so you can run it against any spring-core release
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,JDK25andJDK26
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 forjava.lang.classfilewhen 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 needAdviceAdapter-style helpers that the JDK API does not provide. Whatever you choose,verifyis not enough: load the class and run it, as the swapped-labels case showed.
Further reading
- Companion module: javademos/classfile-api — run
scripts/run-all.shto regenerate every transcript - JEP 484: Class-File API; previews JEP 457 and JEP 466
- java.lang.classfile Javadoc (JDK 25)
- ASM release history
- JVMS chapter 4: the class File Format
- On ankurm.com: JDK vs JRE vs JVM, Lambda Expressions in Java, Mockito 5 Deep Dive (a library that generates classes at runtime) and Key Derivation Function API in Java (JEP 510) (another API that became final in 25)
No Comments yet!