If you’re new to the Class-File API, a good place to start is my JVM Hello World or the JDK documentation.

When generating bytecode, the Java Class-File API automatically generates stack map frames to satisfy the JVM’s verification requirements. These frames, required since Java 7, help the JVM verify the bytecode’s type safety by describing the types of values on the operand stack and in local variables at certain points in the code.

However, if the generator itself has a bug, it can emit bad stack maps that lead to runtime VerifyError failures even if the bytecode is valid. Recently, while building a bytecode transformation tool built with the Class-File API, I hit a verification error when the class was loaded:

java.lang.VerifyError: Bad local variable type
Exception Details:
  Location:
    Test.m(Ljava/lang/Object;)I @9: aload_0
  Reason:
    Type top (current frame, locals[0]) is not assignable to reference type

The verifier complained that slot 0 (the method’s Object argument) was typed as top rather than a reference in the stack map table, so the aload_0 that reads it is rejected. In the verifier’s type system, top is the catch-all “no usable type”: it is what a slot becomes when it holds nothing meaningful, or when two incompatible types merge, and nothing can be read back out of it. The bytecode instructions themselves were valid; only the generated stack map was wrong.

Let’s take a look at reproducing the bug, dive into what the bug actually is with the help of an interactive simulation you can step through round by round, and then see how the fix was actually just a few lines.


The Minimal Reproducer

The original code where this error occurred was a transformation of a large and complex, real-world class. To isolate the bug, I tasked Claude with finding a minimal reproducer by iteratively removing instructions and running the verifier; it was able to distill the control flow down to the following valid 12-instruction method. The complete reproducer below builds the method with the Class-File API builder and then asks the API to verify the result; the bytecode offsets are shown in the comments:

import java.lang.classfile.ClassFile;
import java.lang.constant.ClassDesc;
import java.lang.constant.MethodTypeDesc;
import static java.lang.classfile.ClassFile.*;
import static java.lang.constant.ConstantDescs.*;

// Reproducer for JDK-8386700. Optional arg is a Java feature version (default 8).
void main(String[] args) throws Exception {
    int javaVersion = args.length > 0 ? Integer.parseInt(args[0]) : 8;
    int major = 44 + javaVersion;

    var cf = ClassFile.of();
    byte[] bytes = cf.build(ClassDesc.of("Test"), clb -> clb
        .withVersion(major, 0)
        .withMethodBody("m", MethodTypeDesc.of(CD_int, CD_Object),
            ACC_PUBLIC | ACC_STATIC, cob -> {
                var a = cob.newLabel();
                var b = cob.newLabel();
                var d = cob.newLabel();
                cob
                    .iconst_0()       //  0
                    .istore(1)        //  1
                    .iload(1)         //  2
                    .ifeq(a)          //  3
                    .goto_(d)         //  6
                    .labelBinding(a)
                    .aload(0)         //  9
                    .pop()            // 10
                    .labelBinding(b)
                    .bipush(42)       // 11
                    .ireturn()        // 13
                    .labelBinding(d)
                    .lconst_0()       // 14
                    .lstore(0)        // 15
                    .goto_(b);        // 16
            }));

    // v51+ (Java 7+) is checked by the mandatory split verifier. On an affected JDK
    // the bad table makes verify report the corrupt frame, so we stop there; once
    // fixed it verifies cleanly and we fall through to run it. v50 and below use the
    // inference verifier, which ignores the stack maps, so they always run.
    if (major >= 51) {
        var errors = cf.verify(bytes);
        if (!errors.isEmpty()) {
            System.out.println(errors);
            return;
        }
    }

    // The class loads and runs: a fixed (or pre-Java-7) JDK returns 42.
    var loaded = new ClassLoader() {
        Class<?> define(byte[] b) { return defineClass("Test", b, 0, b.length); }
    }.define(bytes);
    Object result = loaded.getMethod("m", Object.class).invoke(null, new Object[]{ null });
    System.out.println("Java " + javaVersion + " (v" + major +
        "): links & runs, m(null) = " + result);
}

It is a single-file source program, so we can run it directly. It takes an optional Java feature version (default 8) to target a different class-file version. On an affected JDK, verify returns the bad frame instead of an empty list:

$ java Minimal.java
[java.lang.VerifyError: Bad local variable type in Test::m(Object) @9 (reference_check is not assignable from bogus_type)]

On a fixed JDK the same command verifies cleanly, so the reproducer goes on to load the class and run it, printing the method’s result:

$ java Minimal.java
Java 8 (v52): links & runs, m(null) = 42

Target an older class-file version (≤ Java 6) and the inference verifier ignores the stack maps entirely, recomputing the types itself, so the bad table never matters and the class links and runs:

$ java Minimal.java 6
Java 6 (v50): links & runs, m(null) = 42

To trigger the bug, three elements must be present:

  • An early write to a neighbouring slot: An integer is written to slot 1 (istore_1) at bci 1.
  • An overlapping category-2 write: A 64-bit long is written to slot 0 (lstore_0) at bci 15. This type occupies both slot 0 (typed LONG) and slot 1 (typed LONG2 as the second half of the long).
  • A control-flow conflict forcing a second pass: The back-edge 16: goto 11 merges a frame where slot 0 is a long with a frame where slot 0 is an Object. This forces the generator to run a second fixed-point round.

Under Java 7 and later, the split verifier is mandatory, and the bad table results in a fatal VerifyError. Inspecting the generated stack maps for a v52 class file shows:

@9   [ TOP | INTEGER ]    <-- Error: Slot 0 should be java/lang/Object
@11  [ ]
@14  [ TOP | INTEGER ]

Root Cause: Stale State in Recycled Frames

To avoid allocations across the fixed-point iteration rounds, the internal StackMapGenerator.Frame recycles a single currentFrame object. At the start of each round setLocalsFromArg resets the locals to the method’s arguments, but it sets localsSize to the argument count without clearing the rest of locals[]. So a properly recycled frame’s slot 1 should read as TOP; instead it still holds the LONG2 left by Round 1’s lstore_0.

That stale value causes problems because of how the generator deals with category-2 types: a long or double occupies two slots, so before writing a slot the generator checks whether it is overwriting half of one, and if so invalidates the other half. When istore_1 writes slot 1, that check reads slot 1’s old type via getLocalRawInternal. That returns the stale LONG2 rather than the TOP a cleared frame would hold. The generator then invalidates slot 0 because it thinks the first half of a long is stored there, wiping the live Object to TOP:

void setLocalsFromArg(MethodInfo methodInfo, boolean isStatic) {
    localsSize = 0;
    // ... writes arguments to locals[0..argSlots-1] ...
    localsSize = argSlots;   // tail of locals[] left untouched
}

The clearest way to see this is the simulation below. Step through the generator’s rounds to watch the stale state corrupt the frame, then toggle to the fixed version to compare:


The Fix

The fix (JDK-8386700) is to clear the tail of the backing array during the frame reset, matching the cleanup logic already used by copyFrom:

 void setLocalsFromArg(MethodInfo methodInfo, boolean isStatic) {
     localsSize = 0;
     // ... writes arguments ...
     localsSize = argSlots;
+    if (locals != null && localsSize < locals.length) {
+        Arrays.fill(locals, localsSize, locals.length, Type.TOP_TYPE);
+    }
 }

Clearing the array tail prevents the stale LONG2 marker from being read by getLocalRawInternal in subsequent passes, ensuring the live Object parameter type is preserved.


Conclusion

What surfaced as a single VerifyError in a real-world transformation turned out to be the same valid bytecode getting two different verdicts: it links and runs on Java 6 and below, but every JDK from Java 7 onward rejects it.

The bytecode was never wrong; the stack map metadata was. And it went wrong for the smallest of reasons: a single frame reused across the generator’s fixed-point rounds, with one slot of its backing array left uncleared between them. A three-line Arrays.fill (JDK-8386700) is all it takes to put right.