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.
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 */}
case CodeModel code -> ...
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:
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));
}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:
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) == ?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));
}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.
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):
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();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)
.labelBinding(loop)
.iload(0).ifle(done)
.iload(1).iload(0).iadd().istore(1)
.iinc(0, -1)
.goto_(loop)
.labelBinding(done)
.iload(1).ireturn();
}));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:
java.lang.IllegalArgumentException: Unsupported class file major version 71the 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:
java.lang.IllegalArgumentException: Unsupported class file version: 70same 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.
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).
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.