thepointman.dev_
ClassLoader Pipeline

Linking — Bytecode Verification

Why the JVM can't trust bytecode it didn't compile, what the verifier checks, and what happens when you turn it off.

Lesson 55 min read

After loading, a class enters the linking phase. Linking has three sub-phases: verification, preparation, and resolution. This lesson covers verification — the most security-critical step in the entire classloading pipeline.

#The Problem: You Can't Trust Bytecode

When javac compiles your .java source, it produces correct bytecode. The types align, the stack frames balance, no method is called with the wrong number of arguments. You can trust javac's output.

But the JVM doesn't load bytecode only from javac. It loads bytecode from:

  • JARs you downloaded from the internet
  • JARs generated by other compilers (Kotlin, Scala, Groovy)
  • Bytecode generated at runtime by frameworks (Spring, Hibernate, cglib, ASM)
  • Bytecode that has been deliberately tampered with

Any of these can produce malformed or malicious bytecode. Without a safety net, the JVM would execute it. A crafted .class file could corrupt the JVM's internal state, read arbitrary memory, or escape the sandbox entirely.

The verifier is that safety net.

#What Verification Checks

The verifier performs static analysis on the bytecode — it does not execute it. It walks through every instruction in every method and checks:

Type safety. Every value on the operand stack must be of the expected type at every instruction. If iadd (integer add) is called but the top of the stack holds a reference, verification fails.

Stack consistency. The depth and types of the operand stack must be consistent at every point a control flow path can reach — including after branches, loops, and exception handlers. The verifier tracks all possible paths simultaneously.

Method call correctness. Every invokevirtual, invokespecial, and invokestatic instruction must reference a method that exists on the target class, with the correct descriptor (argument types and return type).

Field access correctness. Every getfield and putfield instruction must reference a field that actually exists, with the correct type.

Jump targets. Every branch instruction must jump to a valid instruction offset within the same method. No jumping into the middle of an instruction, no jumping outside the method body.

No uninitialised values. You cannot use a local variable before assigning it, and you cannot call instance methods on this before calling a superclass constructor.

#StackMapTable: The Verification Shortcut

Before Java 6, the verifier had to infer all of this by doing a full dataflow analysis — computing the set of possible stack states at every point. For complex methods with many branches, this was expensive.

Java 6 introduced StackMapTable attributes (mandatory from Java 7 class files onward). Compilers embed explicit "checkpoints" in each method — annotations saying "at this bytecode offset, the operand stack has this shape and the local variables have these types." The verifier reads these checkpoints instead of computing them from scratch.

You can see them in javap output:

bash
javap -verbose MyClass.class
plaintext
StackMapTable: number_of_entries = 2
  frame_type = 252 /* append */
    offset_delta = 14
    locals = [ int ]
  frame_type = 250 /* chop */
    offset_delta = 11

This is the verifier's roadmap. It makes verification fast enough to be imperceptible at startup.

#Seeing Verification Fail

Let's manufacture a verification failure. We'll use the ASM bytecode library to generate an intentionally broken class — a method that tries to add a String as if it were an int:

java
// Using ASM to generate malformed bytecode
ClassWriter cw = new ClassWriter(ClassWriter.COMPUTE_FRAMES);
cw.visit(V17, ACC_PUBLIC, "Broken", null, "java/lang/Object", null);
 
MethodVisitor mv = cw.visitMethod(ACC_PUBLIC | ACC_STATIC, "go", "()V", null, null);
mv.visitCode();
mv.visitLdcInsn("hello");   // push a String
mv.visitLdcInsn("world");   // push another String
mv.visitInsn(IADD);         // try to integer-add them — WRONG TYPE
mv.visitInsn(RETURN);
mv.visitMaxs(2, 0);
mv.visitEnd();
 
byte[] bytes = cw.toByteArray();
// Load with a ByteArrayClassLoader and call Broken.go()

When you load and run this:

plaintext
java.lang.VerifyError: Bad type on operand stack
Exception Details:
  Location:
    Broken.go()V @2: iadd
  Reason:
    Type 'java/lang/String' (current frame, stack[0]) is not assignable to integer
  Current Frame:
    bci: @2
    flags: { }
    locals: { }
    stack: { 'java/lang/String', 'java/lang/String' }

The JVM tells you exactly where the problem is, what it found, and what it expected. VerifyError is a subclass of LinkageError, meaning the class failed during the linking phase — not loading, and not execution.

#Turning Off Verification (and Why You Shouldn't)

You can disable the verifier:

bash
java -Xverify:none MyApp

On modern JVMs this flag is deprecated and may be removed. When it was commonly used, it was typically for two reasons:

  1. Startup speed — skipping verification shaved a few milliseconds off cold start. With CDS and StackMapTable, this is no longer meaningful.
  2. Bytecode manipulation tools that produced technically-invalid intermediate bytecode for performance tricks.

On any production system: don't do it. The verifier is your last line of defence against malformed bytecode causing JVM crashes or security escapes.

#What Verification Doesn't Check

Verification is static analysis. It cannot catch:

  • NullPointerException — requires knowing runtime values
  • ArrayIndexOutOfBoundsException — same reason
  • Infinite loops — the Halting Problem
  • Semantic correctness — a method that returns the wrong answer but uses correct types passes verification happily

Verification guarantees the bytecode is structurally sound, not correct. Those are very different things.


Key Takeaway

Bytecode verification is a mandatory static analysis pass that prevents malformed or malicious bytecode from corrupting the JVM. It checks type safety, stack consistency, valid jump targets, and correct method/field access — all without executing a single instruction. A VerifyError means a class failed this check during linking. The StackMapTable attribute (mandatory since Java 7) makes verification efficient by pre-computing stack frame shapes at every branch target.