Skip to main content

Command Palette

Search for a command to run...

JDK 24 Class-File API — The New Standard

Published
9 min read

For over two decades, JVM bytecode manipulation relied on third-party libraries such as ASM and ByteBuddy. While these libraries powered the Java ecosystem admirably, they suffered from the "ASM Tax" — the unavoidable delay between a new JVM feature landing and the library catching up to support it. Every new class file version, every new bytecode instruction required the library maintainers to release an update before the rest of the ecosystem could move forward.

JDK 24 (JEP 484) eliminates this problem entirely by introducing a native, standard Class-File API built directly into the java.base module. No external dependencies. No version lag. A first-class citizen of the JDK itself.


Internals: The Architecture

The biggest performance bottleneck in bytecode manipulation is parsing bytes into objects. ASM solves this by streaming (never creating objects unless forced). ByteBuddy solves it by wrapping. JDK 24 uses Lazy Parsing via byte-offset pointers.

When you call ClassFile.of().parse(bytes), the JVM does not process the entire file. It only scans the header and the constant pool.

[!NOTE] This is a "Tree of Proxies." The API only pays the cost for what you actually touch. This allows the JDK 24 API to match the performance of ASM's streaming model while providing the convenience of a Tree API.


The Pool: Constant Pool Mastery

In traditional bytecode libraries like ASM, indices into the Constant Pool are raw integers — a breeding ground for "off-by-one" and "invalid reference" bugs. The JDK 24 API replaces these with specialised objects called PoolEntry, making pool manipulation type-safe by design.

Constant Pool Builder logic

ConstantPoolBuilder b = ConstantPoolBuilder.of();
Utf8Entry className = b.utf8Entry("com/byte/Greeter");
ClassEntry classRef = b.classEntry(className);

// The API handles the double-entry requirement for Long/Double automatically!

The Core: Elements and Models

Every component in the new API is a sealed interface and follows the Data-Oriented Programming principles.

  • ClassModel: A static snapshot of a class file.

  • CodeModel: A static snapshot of a method's instruction stream.

  • ClassBuilder / CodeBuilder: Functional interfaces used during creation.


Mastery Example: Custom Attribute Implementation

JDK 24 is the first API to make "Custom Attributes" (non-standard metadata used by frameworks like Quarkus or Micronaut) a first-class feature.

Step 1: Define the Attribute Model

public record MyTraceAttribute(String traceId) implements CustomAttribute<MyTraceAttribute> {
    public static final String NAME = "com.byte.TraceID";

    // AttributeMapper is the "Serializer" for the binary segment
    public static final AttributeMapper<MyTraceAttribute> MAPPER = AttributeMapper.of(
        NAME,
        (model, payload, pool) -> new MyTraceAttribute(pool.utf8Entry(payload.readU2()).stringValue()),
        (pool, builder, attr) -> builder.writeU2(pool.utf8Entry(attr.traceId()).index())
    );

    @Override public String attributeName() { return NAME; }
    @Override public AttributeMapper<MyTraceAttribute> mapper() { return MAPPER; }
}

Step 2: Register and Access

ClassFile cf = ClassFile.of(ClassFile.AttributeMapperOption.of(Map.of(MyTraceAttribute.NAME, MyTraceAttribute.MAPPER)));
ClassModel model = cf.parse(bytes);
Optional<MyTraceAttribute> attr = model.findAttribute(MyTraceAttribute.NAME);

Transformation Case Study: The Try-Finally Wrapper

Modifying method bodies is a common bytecode manipulation task. Let's tackle something truly complex with the new API: wrapping an entire method in a try-finally block for execution timing.

The Transformation Strategy

We intercept the code and wrap it using CodeBuilder.block(...), which handles the stack map frames and labels automatically.

ClassFile cf = ClassFile.of();
byte[] transformed = cf.transform(original, ClassTransform.transformingMethodBodies(
    mb -> mb.methodName().equalsString("process"), 
    (codeBuilder, element) -> {
        codeBuilder.block(blockBuilder -> {
            // 1. "Try" Start logic
            blockBuilder.invokestatic(ClassDesc.of("java.lang.System"), "nanoTime", MethodTypeDesc.of(long.class));
            blockBuilder.astore(10); // Store start time in local slot 10
            
            // 2. Original instructions
            blockBuilder.with(element);
            
            // 3. "Finally" Logic
            blockBuilder.invokestatic(ClassDesc.of("java.lang.System"), "nanoTime", MethodTypeDesc.of(long.class));
            blockBuilder.lload(10);
            blockBuilder.lsub();
            // ... print logic ...
        });
    }));

Side-by-Side: ASM vs. JDK 24 Pattern Matching

Pattern matching for instructions is the "killer feature" that makes the JDK 24 API safer than ASM.

Feature ASM (Visitor Pattern) JDK 24 (Pattern Matching)
Parsing Logic Spread across hundreds of visitX methods Consolidated in a single switch
Type Safety Casting from AbstractInsnNode Strongly typed via Sealed Interfaces
Context Need manual state flags CodeModel provides full context

Example: Log all method calls

JDK 24 Pattern Matcher:

codeModel.forEach(element -> {
    if (element instanceof InvokeInstruction inv) {
        System.out.println("Switching to: " + inv.name());
    }
});

Bytecode Challenge

The Challenge: Implement a Method Rename Transformer. Given a class file, find all private methods and prefix their names with "obfuscated_".

Warning: You must update both the method declaration and all internal invokespecial calls that target those private methods!

Hint: This requires a two-pass transformation: first gather the names, then update the calls.

Solution
import java.lang.classfile.*;
import java.lang.classfile.attribute.CodeAttribute;
import java.lang.classfile.constantpool.*;
import java.lang.classfile.instruction.InvokeInstruction;
import java.lang.constant.ClassDesc;
import java.lang.constant.MethodTypeDesc;
import java.lang.reflect.AccessFlag;
import java.util.HashSet;
import java.util.Set;

/**

  • Bytecode Challenge Solution: Method Rename Transformer

  • ======================================================

  • Problem:

  • Given a class file, find ALL private methods and prefix their names

  • with "obfuscated_". Both the method declaration AND all internal

  • invokespecial calls targeting those methods must be updated.

  • Strategy (Two-Pass):

  • Pass 1 — Scan the ClassModel to collect the names of all private methods.

  • Pass 2 — Transform the class:

  (a) Rename matching MethodModels.
  (b) Rewrite InvokeInstruction elements whose target matches a
      collected name (and whose owner is the class itself).
  • Run (JDK 24+):

  • javac MethodRenameTransformer.java

  • java MethodRenameTransformer
    */
    public class MethodRenameTransformer {

private static final String PREFIX = "obfuscated_";

// ───────────────────────────────────────────── // The target class we'll transform at runtime // ───────────────────────────────────────────── static class Target { // public — should NOT be renamed public void greet() { System.out.println("[greet] calling secret()..."); secret(); System.out.println("[greet] calling helper()..."); helper(); }

// private — SHOULD be renamed to obfuscated_secret
private void secret() {
    System.out.println("[secret] top-secret logic");
}

// private — SHOULD be renamed to obfuscated_helper
private void helper() {
    System.out.println("[helper] utility logic");
}

}

// ══════════════════════════════════════════════ // Core transformation logic // ══════════════════════════════════════════════

/**

  • Transforms a class file's bytes, renaming every private method

  • and patching all invokespecial call-sites that reference them. */ public static byte[] transform(byte[] originalBytes) {

    ClassFile cf = ClassFile.of(); ClassModel classModel = cf.parse(originalBytes); String className = classModel.thisClass().asInternalName();

    // ── Pass 1: Collect all private method names ────────────── Set&lt;String&gt; privateMethodNames = new HashSet&lt;&gt;();

    for (MethodModel method : classModel.methods()) { boolean isPrivate = method.flags().has(AccessFlag.PRIVATE); String name = method.methodName().stringValue(); // Skip constructors and static initializers — renaming // &lt;init&gt; or &lt;clinit&gt; would break the class file. if (isPrivate &amp;&amp; !name.startsWith("&lt;")) { privateMethodNames.add(name); } }

    System.out.println("Pass 1 complete — private methods found: " + privateMethodNames);

    if (privateMethodNames.isEmpty()) { System.out.println("Nothing to rename. Returning original bytes."); return originalBytes; }

    // ── Pass 2: Transform ───────────────────────────────────── byte[] transformed = cf.transform(classModel, (classBuilder, classElement) -&gt; {

     if (classElement instanceof MethodModel method) {
         String name = method.methodName().stringValue();
    
         if (privateMethodNames.contains(name)) {
             // ─── 2a. Rename the method declaration ───
             String newName = PREFIX + name;
             System.out.println("  Renaming method: " + name + " → " + newName);
    
             // Rebuild the method with the new name, preserving
             // flags, descriptor, and transforming the code body.
             classBuilder.withMethod(
                 newName,
                 method.methodType(),
                 method.flags().flagsMask(),
                 methodBuilder -&amp;gt; {
                     for (MethodElement me : method) {
                         if (me instanceof CodeModel codeModel) {
                             // Rewrite instructions inside the renamed method
                             methodBuilder.withCode(codeBuilder -&amp;gt;
                                 rewriteCode(codeBuilder, codeModel, className, privateMethodNames)
                             );
                         } else {
                             methodBuilder.with(me);
                         }
                     }
                 }
             );
    
         } else {
             // Non-private method — still need to patch call-sites
             // inside its body that may invoke private methods.
             classBuilder.withMethod(
                 name,
                 method.methodType(),
                 method.flags().flagsMask(),
                 methodBuilder -&amp;gt; {
                     for (MethodElement me : method) {
                         if (me instanceof CodeModel codeModel) {
                             methodBuilder.withCode(codeBuilder -&amp;gt;
                                 rewriteCode(codeBuilder, codeModel, className, privateMethodNames)
                             );
                         } else {
                             methodBuilder.with(me);
                         }
                     }
                 }
             );
         }
    
     } else {
         // Fields, attributes, etc. — pass through unchanged
         classBuilder.with(classElement);
     }
    

    });

    return transformed;

}

/**

  • Rewrites the code body of a method, patching any invokespecial
  • (or invokevirtual/invokestatic — for robustness) call whose
  • target is one of the renamed private methods. */ private static void rewriteCode( CodeBuilder codeBuilder, CodeModel codeModel, String ownerInternalName, Set&lt;String&gt; privateMethodNames

) { for (CodeElement element : codeModel) { if (element instanceof InvokeInstruction invoke) { String targetOwner = invoke.owner().asInternalName(); String targetName = invoke.name().stringValue();

        // Only patch calls to *this* class's private methods
        if (targetOwner.equals(ownerInternalName)
                &amp;amp;&amp;amp; privateMethodNames.contains(targetName)) {

            String newName = PREFIX + targetName;
            System.out.println("    Patching call-site: "
                    + invoke.opcode().name() + " "
                    + targetName + " → " + newName);

            // Emit a replacement invoke instruction with the new name
            codeBuilder.invoke(
                invoke.opcode(),
                ClassDesc.ofInternalName(targetOwner),
                newName,
                MethodTypeDesc.ofDescriptor(invoke.type().stringValue()),
                invoke.isInterface()
            );
        } else {
            // Invoke to a different class or non-private method — keep as-is
            codeBuilder.with(element);
        }
    } else {
        // All other instructions — keep as-is
        codeBuilder.with(element);
    }
}

}

// ══════════════════════════════════════════════ // Verification: dump method names + call-sites // ══════════════════════════════════════════════

/**

  • Prints every method and the invoke instructions inside it,

  • so we can verify the transformation worked. */ public static void dump(byte[] classBytes) { ClassModel model = ClassFile.of().parse(classBytes); System.out.println("╔══════════════════════════════════════════════════╗"); System.out.println("║ Class: " + model.thisClass().asInternalName()); System.out.println("╚══════════════════════════════════════════════════╝");

    for (MethodModel method : model.methods()) { String flags = method.flags().has(AccessFlag.PRIVATE) ? "private" : method.flags().has(AccessFlag.PUBLIC) ? "public" : "package"; System.out.println("\n ● " + flags + " " + method.methodName().stringValue() + method.methodType().stringValue()); // Walk the code looking for invoke instructions method.findAttribute(Attributes.code()).ifPresent(code -&gt; { for (CodeElement ce : code) { if (ce instanceof InvokeInstruction inv) { System.out.println(" ↳ " + inv.opcode().name() + " " + inv.owner().asInternalName() + "." + inv.name().stringValue() + inv.type().stringValue()); } } }); } System.out.println();

}

// ══════════════════════════════════════════════ // Main: run the demo // ══════════════════════════════════════════════

public static void main(String[] args) throws Exception {

// 1. Load the raw bytes of the Target class from the classpath
String resourcePath = Target.class.getName().replace('.', '/') + ".class";
byte[] original;
try (var in = ClassLoader.getSystemResourceAsStream(resourcePath)) {
    if (in == null) throw new RuntimeException("Cannot find " + resourcePath);
    original = in.readAllBytes();
}

System.out.println("━━━━━ BEFORE TRANSFORMATION ━━━━━");
dump(original);

// 2. Transform
System.out.println("━━━━━ TRANSFORMING ━━━━━");
byte[] transformed = transform(original);

// 3. Verify
System.out.println("\n━━━━━ AFTER TRANSFORMATION ━━━━━");
dump(transformed);

// 4. Prove it works: load and execute the transformed class
System.out.println("━━━━━ EXECUTING TRANSFORMED CLASS ━━━━━");
var loader = new ClassLoader(MethodRenameTransformer.class.getClassLoader()) {
    Class&amp;lt;?&amp;gt; load(byte[] b) {
        return defineClass(null, b, 0, b.length);
    }
};
Class&amp;lt;?&amp;gt; clazz = loader.load(transformed);
Object instance = clazz.getDeclaredConstructor().newInstance();
clazz.getMethod("greet").invoke(instance);

}

}