skip to content
everything.meta.classFileApi

the class-file api

generate and transform .class files with a real api.

chapter
chapter 11.07
reading time
9 min read
java 24+

since java 24, java.lang.classfile can parse, generate and transform .class files (JEP 484) java 24+. a class file is modelled as a tree of immutable elements. you read it with pattern matching, write it with builders (lambdas all the way down), and change it with transforms that copy the tree with edits along the way. it computes the fiddly parts (stack maps, max stack) for you.

it's what the JDK itself uses now. jdk 25 doesn't ship its private copy of ASM anymore.

the picture

the class file chapter took one apart byte by byte. you don't want to write one byte by byte: constant pool indexes, jump offsets, the verifier's stack map frames, all of it has to agree.

think of a class file as a document outline: a class contains methods, a method contains code, code contains instructions. reading is walking the outline. building is dictating an outline to someone who handles the numbering. transforming is photocopying it with a marker in hand: most lines go through untouched, and you strike out or rewrite the few you care about.

elements (click one)
javap -v Gen (real)
public class Gen  minor version: 0  major version: 69  flags: (0x0021) ACC_PUBLIC, ACC_SUPER  this_class: #2              // Gen  super_class: #6             // java/lang/Object  interfaces: 0, fields: 0, methods: 1, attributes: 0{  public static int sum(int);    descriptor: (I)I    flags: (0x0009) ACC_PUBLIC, ACC_STATIC    Code:      stack=2, locals=2, args_size=1         0: iconst_0         1: istore_1         2: iload_0         3: ifle          16         6: iload_1         7: iload_0         8: iadd         9: istore_1        10: iinc          0, -1        13: goto          2        16: iload_1        17: ireturn      StackMapTable: number_of_entries = 2        frame_type = 252 /* append */          offset_delta = 2          locals = [ int ]        frame_type = 13 /* same */}
reading it, inside a switch over the elements
case CodeModel code -> ...
click an element. the amber lines are the part of the real javap listing it models. the green one is the bit you never write: the API computes stack maps and max stack when it builds.

every element is a record-like interface: MethodModel, CodeModel, LoadInstruction, BranchInstruction, and so on. compound ones (class, method, code) are iterable, so reading a class is a for loop and a switch with type patterns. labels aren't byte offsets anymore, they're objects: LabelTarget marks where one is bound, and a branch points at a Label, not at "16".

build one

the class in that figure was generated by this, and loaded, and called:

Main.java
import java.lang.classfile.*;
import java.lang.constant.*;
import java.lang.invoke.*;
import static java.lang.constant.ConstantDescs.CD_int;
 
void main() throws Throwable {
    byte[] bytes = ClassFile.of().build(ClassDesc.of("Gen"), cls -> cls
        .withMethodBody("sum", MethodTypeDesc.of(CD_int, CD_int),
            ClassFile.ACC_PUBLIC | ClassFile.ACC_STATIC, code -> {
                Label loop = code.newLabel(), done = code.newLabel();
                code.iconst_0().istore(1)              // total = 0
                    .labelBinding(loop)
                    .iload(0).ifle(done)               // while (n > 0) {
                    .iload(1).iload(0).iadd().istore(1) //   total += n
                    .iinc(0, -1)                       //   n--
                    .goto_(loop)                       // }
                    .labelBinding(done)
                    .iload(1).ireturn();               // return total
            }));
 
    Class<?> gen = MethodHandles.lookup().defineClass(bytes);
    MethodHandle sum = MethodHandles.lookup().findStatic(gen, "sum", MethodType.methodType(int.class, int.class));
    IO.println(bytes.length + " bytes");
    IO.println((int) sum.invokeExact(100));
}
output, from jdk 25
148 bytes
5050

this one needs a real JVM (reflection), so it doesn't run in the browser. copy it into java Main.java to try it.

types are described with ClassDesc and MethodTypeDesc (from java.lang.constant), not Class objects, because the class you're generating, and the ones it refers to, usually aren't loaded. the builder hands out labels, picks the short instruction forms (iconst_0, istore_1), fills in the constant pool, and, when build returns, has computed stack=2, locals=2 and the two StackMapTable frames the verifier needs. that last part used to be the most error-prone thing about generating bytecode.

MethodHandles.lookup().defineClass(bytes) loads the result into the same package and class loader as the caller. that's the supported way to define a class at runtime since java 9, no setAccessible on ClassLoader.defineClass needed (reflection explains why that stopped working).

transform one

reading and building combine into transforms. this takes a class javac compiled, deletes every method whose name starts with debug, turns every iadd into an imul, and saves the result under a new name:

predict the output
java
class Prices {
    static int total(int a, int b) { return a + b; }
    static int debugTotal(int a, int b) { return -1; }
}
// after the transform below: Times.total(6, 7) == ?
Main.java
import java.lang.classfile.*;
import java.lang.constant.ClassDesc;
import java.lang.invoke.MethodHandles;
 
class Prices {
    static int total(int a, int b) { return a + b; }
    static int debugTotal(int a, int b) { return -1; }
}
 
void main() throws Exception {
    byte[] original;
    try (var in = Prices.class.getResourceAsStream("Main$Prices.class")) {
        original = in.readAllBytes();
    }
    ClassFile cf = ClassFile.of();
    ClassModel model = cf.parse(original);
 
    ClassTransform noDebug = ClassTransform.dropping(e ->
        e instanceof MethodModel m && m.methodName().stringValue().startsWith("debug"));
    ClassTransform addToMul = ClassTransform.transformingMethodBodies((code, el) -> {
        if (el instanceof Instruction i && i.opcode() == Opcode.IADD) code.imul();
        else code.with(el);
    });
    byte[] changed = cf.transformClass(model, ClassDesc.of("Times"), noDebug.andThen(addToMul));
 
    Class<?> times = MethodHandles.lookup().defineClass(changed);
    for (var m : times.getDeclaredMethods()) IO.println("method: " + m.getName());
    IO.println("Prices.total(6, 7) = " + Prices.total(6, 7));
    IO.println("Times.total(6, 7)  = " + times.getDeclaredMethod("total", int.class, int.class).invoke(null, 6, 7));
}
output, from jdk 25
method: total
Prices.total(6, 7) = 13
Times.total(6, 7)  = 42

this one needs a real JVM (reflection), so it doesn't run in the browser. copy it into java Main.java to try it.

a transform is a function (builder, element) → void. pass the element through with with(el), replace it by emitting something else, or drop it by doing nothing. transformingMethodBodies lifts a code-level transform up to the class level, and andThen chains them. everything you don't touch is copied as is, which is what makes transforms cheap to write: you describe the change, not the whole class.

that's the whole shape of a java agent that instruments code (add a timer to every method), an obfuscator, a shrinker, or a framework that enhances entity classes at load time.

hover a stage.

hover a stage. the round trip every bytecode tool does, now with nothing but the JDK.

versus ASM

for twenty years the answer to "generate bytecode" was ASM (or ByteBuddy and cglib, built on it). here's the same Gen class both ways. both produce 148 bytes, and both print 5050 (i ran the ASM one with ASM 9.9.1):

java
ClassWriter cw = new ClassWriter(ClassWriter.COMPUTE_FRAMES);
cw.visit(V25, ACC_PUBLIC, "Gen", null, "java/lang/Object", null);
MethodVisitor mv = cw.visitMethod(ACC_PUBLIC | ACC_STATIC, "sum", "(I)I", null, null);
mv.visitCode();
Label loop = new Label(), done = new Label();
mv.visitInsn(ICONST_0);
mv.visitVarInsn(ISTORE, 1);
mv.visitLabel(loop);
mv.visitVarInsn(ILOAD, 0);
mv.visitJumpInsn(IFLE, done);
mv.visitVarInsn(ILOAD, 1);
mv.visitVarInsn(ILOAD, 0);
mv.visitInsn(IADD);
mv.visitVarInsn(ISTORE, 1);
mv.visitIincInsn(0, -1);
mv.visitJumpInsn(GOTO, loop);
mv.visitLabel(done);
mv.visitVarInsn(ILOAD, 1);
mv.visitInsn(IRETURN);
mv.visitMaxs(0, 0);   // ignored with COMPUTE_FRAMES, but still required
mv.visitEnd();
cw.visitEnd();
byte[] bytes = cw.toByteArray();

the instructions are the same, of course: bytecode is bytecode. the difference is the shape. ASM is a visitor API, where you call visitX in exactly the right order and nothing stops you calling visitCode twice. the JDK API is nested lambdas, so structure is enforced by scope: you can't emit an instruction outside a code builder.

the real reason the JDK built its own, though, is versions. ASM has to ship a new release for every new class file version, and it refuses anything newer. ASM 9.9.1 read my class with its version byte set to 70 (java 26), and on 71 (java 27) said:

text
java.lang.IllegalArgumentException: Unsupported class file major version 71

the JDK carried an internal copy of ASM for lambdas, proxies and tools like jar and jlink, and that copy always lagged a release behind, so javac couldn't use a new class file feature until the next JDK. java.lang.classfile ships with the JDK, so it always understands exactly the JDK it's in. on jdk 25, its version ceiling is 69:

text
java.lang.IllegalArgumentException: Unsupported class file version: 70
major version68 (24)69 (25)70 (26)71 (27)jdk 25 java.lang.classfilereadsreadsrefusesrefusesASM 9.9.1readsreadsreadsrefuses
which class file versions each library could read, tested by patching the version byte of the same class. green parsed, red refused. each one stops at a different place for a different reason.

same coin, other side: a library that wants to read classes from newer JDKs than it runs on still needs ASM. the JDK API isn't trying to replace it for everyone.

one level deeper:what moved onto it inside the JDK+

i grepped javap -c output on jdk 25 for references to java/lang/classfile: the lambda metafactory's class spinning (InnerClassLambdaMetafactory, see lambda internals), java.lang.reflect.ProxyGenerator (dynamic proxies), the hidden-class strategy inside StringConcatFactory, and jlink's plugins all use it. and in the runtime image itself, jimage list lib/modules shows no org/objectweb classes at all: the internal ASM copy is gone. JFR's own bytecode instrumentation goes through internal "components" built on the same API (jdk.internal.classfile.components, exported only to jdk.jfr).

keylines
  • java.lang.classfile (java 24) parses, builds and transforms class files with no library.
  • a class file is a tree of immutable elements. read with switch, write with builders.
  • the builder computes constant pool indexes, max stack and stack maps for you.
  • a transform keeps, replaces or drops elements and copies the rest.
  • it reads exactly what its JDK understands. ASM is still for reading newer versions than you run.