Guide contents

Lesson 38 of 71

Bytecode and Class Loading

Java source compiles to class files that hold bytecode, the instruction set of the JVM. The JVM loads those classes on demand, links them, and runs their static initializers on first use. This lesson shows how to read bytecode and how loading and initialization order affect real programs.

Topic Tool or rule
Class file layout magic number, version, constant pool, members, attributes
Reading bytecode javap -c -p
Calls invokestatic, invokespecial, invokevirtual, invokeinterface, invokedynamic
Class lifecycle loading, linking (verify, prepare, resolve), initialization
Loaders bootstrap, platform, application; parent-first delegation

Examples use JDK 25. Compile with javac Main.java and run with java Main (the single-file launcher java Main.java keeps classes in memory, which breaks the examples that read .class files). javap output is a representative excerpt: constant pool indexes and some comments vary by JDK build.

Example 1: the class file header

A class file starts with the magic number 0xCAFEBABE, then a minor and a major version. The major version names the release the file targets: 52 is Java 8, 61 is Java 17, 65 is Java 21, and 69 is Java 25. A JVM refuses a class whose major version is newer than it supports, with UnsupportedClassVersionError.

Java
import java.io.DataInputStream;
import java.io.InputStream;

public class Main {
    public static void main(String[] args) throws Exception {
        try (InputStream in = Main.class.getResourceAsStream("Main.class");
             DataInputStream data = new DataInputStream(in)) {
            int magic = data.readInt();
            int minor = data.readUnsignedShort();
            int major = data.readUnsignedShort();
            int constantPoolCount = data.readUnsignedShort();
            System.out.println("magic: " + Integer.toHexString(magic));
            System.out.println("minor: " + minor + ", major: " + major);
            System.out.println("constant pool has entries: " + (constantPoolCount > 1));
        }
    }
}
Output
magic: cafebabe
minor: 0, major: 69
constant pool has entries: true

After the version comes the constant pool: a table of strings, class names, method references, and numeric constants that instructions refer to by index. Then come the access flags, this class, the superclass, interfaces, fields, methods, and attributes. Method bodies live in each method's Code attribute. Compiling with --release 17 would print major 61, and that file runs on JDK 17 and later.

Example 2: stack-based bytecode

The JVM is a stack machine: instructions push operands onto an operand stack and pop them. Each method frame also has an array of local variable slots; for a static method, the parameters occupy the first slots.

Java
public class Main {
    static int add(int a, int b) {
        return a + b;
    }

    static int scaled(int x) {
        int factor = 3;
        return x * factor + 1;
    }

    public static void main(String[] args) {
        System.out.println(add(2, 3));
        System.out.println(scaled(4));
    }
}
Output
5
13

javap -c -p Main shows the instructions (excerpt):

Text
static int add(int, int);
  Code:
     0: iload_0        // push local 0 (a)
     1: iload_1        // push local 1 (b)
     2: iadd           // pop two ints, push the sum
     3: ireturn

static int scaled(int);
  Code:
     0: iconst_3
     1: istore_1       // factor = 3
     2: iload_0
     3: iload_1
     4: imul
     5: iconst_1
     6: iadd
     7: ireturn

The number before each instruction is its byte offset, called the bytecode index (bci). The i prefix means int; l, f, d, and a mean long, float, double, and reference. javap -v adds stack=2, locals=2 lines, which give the maximum operand stack depth and the number of local slots the frame needs. The JIT compiler later turns this into register-based machine code, so the stack is a format, not a cost at run time.

Example 3: the four classic invoke instructions

Each call site compiles to one invoke instruction. The instruction tells the JVM how to find the target method.

Java
import java.util.ArrayList;
import java.util.List;

public class Main {
    static String greet(String name) { return "hi " + name; }

    public static void main(String[] args) {
        List<String> list = new ArrayList<>();   // invokespecial <init>
        list.add("a");                           // invokeinterface List.add
        StringBuilder sb = new StringBuilder();  // invokespecial <init>
        sb.append("b");                          // invokevirtual StringBuilder.append
        String g = greet("c");                   // invokestatic Main.greet
        System.out.println(list + " " + sb + " " + g);
    }
}
Output
[a] b hi c
Instruction Used for Dispatch
invokestatic static methods none; target known at link time
invokespecial constructors (<init>), super.m() calls none; exact method named
invokevirtual instance methods called through a class type vtable lookup on the receiver's class
invokeinterface methods called through an interface type interface lookup (itable) on the receiver's class
invokedynamic lambdas, string concatenation, records' toString/equals/hashCode, pattern switch a bootstrap method links the call site once

Before Java 11, javac compiled calls to private instance methods with invokespecial, and a nested class reached its outer class's private members through synthetic accessor methods. Java 11 added nestmates (JEP 181): a class and its nested classes may access each other's private members directly, and javac 11 and later compiles private instance method calls with invokevirtual (invokeinterface for private interface methods), both inside the class and from a nested class. The JIT removes most dispatch cost when it can prove a single target, which Safepoints, Warmup, and Deoptimization covers.

Example 4: lambdas use invokedynamic

A lambda does not compile to an inner class file. javac moves the body into a private synthetic method and emits an invokedynamic instruction whose bootstrap method is LambdaMetafactory.metafactory. On the first execution, the bootstrap spins a hidden class that implements the interface, and the call site is linked to it for good.

Java
import java.util.function.Function;

public class Main {
    public static void main(String[] args) {
        Function<Integer, Integer> square = x -> x * x;
        System.out.println(square.apply(7));
        System.out.println(square.getClass().isSynthetic());
        System.out.println(square.getClass().isHidden());
    }
}
Output
49
true
true

Excerpt from javap -c -p Main:

Text
public static void main(java.lang.String[]);
  Code:
     0: invokedynamic #7,  0   // InvokeDynamic #0:apply:()Ljava/util/function/Function;
     5: astore_1
     ...
private static java.lang.Integer lambda$main$0(java.lang.Integer);
  Code:
     0: aload_0
     1: invokevirtual #..      // Method java/lang/Integer.intValue:()I
     ...

javap -v also lists a BootstrapMethods attribute naming LambdaMetafactory.metafactory. A non-capturing lambda such as square returns the same instance on every evaluation; a lambda that captures variables creates a new instance each time unless the JIT removes the allocation. Class.isHidden exists since Java 15 (JEP 371), when lambda classes moved from Unsafe.defineAnonymousClass to hidden classes.

Example 5: string concatenation uses invokedynamic

Since Java 9 (JEP 280), "a" + x compiles to invokedynamic with the bootstrap StringConcatFactory.makeConcatWithConstants, not to a chain of StringBuilder.append calls. The constant parts travel in a recipe string where \u0001 marks each argument.

Java
public class Main {
    static String label(String symbol, int qty) {
        return symbol + ":" + qty;
    }

    public static void main(String[] args) {
        System.out.println(label("AAPL", 100));
    }
}
Output
AAPL:100

Excerpt from javap -c -v Main:

Text
static java.lang.String label(java.lang.String, int);
  Code:
     0: aload_0
     1: iload_1
     2: invokedynamic #..,  0  // InvokeDynamic #0:makeConcatWithConstants:(Ljava/lang/String;I)Ljava/lang/String;
     7: areturn
BootstrapMethods:
  0: REF_invokeStatic java/lang/invoke/StringConcatFactory.makeConcatWithConstants
    Method arguments:
      \u0001:\u0001

The JDK chooses the concatenation strategy at link time, so later JDKs improve the code without recompiling the class. The first execution of each call site pays for bootstrapping, which is one source of startup cost.

Example 6: loaders and delegation

A class loader turns a name into a Class. Since Java 9 there are three built-in loaders: the bootstrap loader (written in native code, shown as null) loads java.base and other core modules, the platform loader loads modules such as java.sql, and the application loader loads the class path and application modules. Each loader first asks its parent, and defines the class itself only if the parent cannot find it. This parent-first delegation stops an application from replacing java.lang.String.

Java
public class Main {
    static String name(ClassLoader loader) {
        return loader == null ? "bootstrap" : loader.getName();
    }

    public static void main(String[] args) throws Exception {
        ClassLoader app = Main.class.getClassLoader();
        System.out.println("String: " + name(String.class.getClassLoader()));
        System.out.println("java.sql.Connection: " + name(java.sql.Connection.class.getClassLoader()));
        System.out.println("Main: " + name(app));
        System.out.println("parent of app: " + name(app.getParent()));
        System.out.println("parent of platform: " + name(app.getParent().getParent()));
    }
}
Output
String: bootstrap
java.sql.Connection: platform
Main: app
parent of app: platform
parent of platform: bootstrap

The identity of a class at run time is the pair (class name, defining loader). Two loaders that each define Plugin from the same bytes produce two unrelated classes. The next program shows that with a loader that breaks delegation for one name, as application servers and plugin systems do.

Java
import java.io.InputStream;

public class Main {
    public static void main(String[] args) throws Exception {
        ClassLoader app = Main.class.getClassLoader();
        IsolatingLoader isolated = new IsolatingLoader(app);

        Class<?> a = app.loadClass("Plugin");
        Class<?> b = isolated.loadClass("Plugin");
        var constructor = b.getDeclaredConstructor();
        constructor.setAccessible(true);   // b's package is a different runtime package
        Object instance = constructor.newInstance();

        System.out.println("same name: " + a.getName().equals(b.getName()));
        System.out.println("same class: " + (a == b));
        System.out.println("loaders: " + a.getClassLoader().getName() + ", " + b.getClassLoader().getName());
        System.out.println("instance of app's Plugin: " + a.isInstance(instance));
    }
}

class Plugin { }

class IsolatingLoader extends ClassLoader {
    IsolatingLoader(ClassLoader parent) { super("isolating", parent); }

    @Override
    protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException {
        if (!name.equals("Plugin")) {
            return super.loadClass(name, resolve);       // normal parent-first path
        }
        synchronized (getClassLoadingLock(name)) {
            Class<?> c = findLoadedClass(name);
            if (c == null) {
                try (InputStream in = getParent().getResourceAsStream("Plugin.class")) {
                    byte[] bytes = in.readAllBytes();
                    c = defineClass(name, bytes, 0, bytes.length);
                } catch (java.io.IOException e) {
                    throw new ClassNotFoundException(name, e);
                }
            }
            return c;
        }
    }
}
Output
same name: true
same class: false
loaders: app, isolating
instance of app's Plugin: false

Casting instance to the app's Plugin would throw ClassCastException with a message that names Plugin twice. That message in production usually means one class was loaded by two loaders. A custom loader normally overrides findClass and keeps delegation; overriding loadClass is the deliberate exception.

Example 7: loading, linking, and initialization order

Loading reads the bytes and creates the Class. Linking verifies the bytecode, prepares static fields with default values, and resolves symbolic references (lazily in HotSpot). Initialization runs the static initializer, <clinit>, which javac builds from static field initializers and static blocks in source order. Initialization happens on first active use: new, a static method call, a non-constant static field access, reflection such as Class.forName(name), or initialization of a subclass. A static final field with a compile-time constant value is inlined into the caller and does not trigger initialization.

Java
public class Main {
    public static void main(String[] args) throws Exception {
        System.out.println("start");
        System.out.println("MAX = " + Config.MAX);
        Class.forName("Config", false, Main.class.getClassLoader());
        System.out.println("loaded without init");
        System.out.println("counter = " + Config.counter);
        System.out.println("---");
        new Child();
    }
}

class Config {
    static final int MAX = 10;                 // constant: inlined, no init
    static int counter = log("counter initializer");
    static { System.out.println("Config static block"); }

    static int log(String s) {
        System.out.println(s);
        return 1;
    }
}

class Parent {
    static { System.out.println("Parent static"); }
    { System.out.println("Parent instance block"); }
    Parent() { System.out.println("Parent constructor"); }
}

class Child extends Parent {
    static { System.out.println("Child static"); }
    { System.out.println("Child instance block"); }
    Child() { System.out.println("Child constructor"); }
}
Output
start
MAX = 10
loaded without init
counter initializer
Config static block
counter = 1
---
Parent static
Child static
Parent instance block
Parent constructor
Child instance block
Child constructor

Static initialization runs superclass first, once per class per loader. Instance creation runs the superclass constructor first, then the subclass's instance initializers and field initializers, then the rest of the subclass constructor. If <clinit> throws, the class is marked erroneous: the first use gets ExceptionInInitializerError and every later use gets NoClassDefFoundError, which is why that error can appear for a class that exists on the class path.

Example 8: class initialization deadlock

The JVM runs each <clinit> under a per-class initialization lock, and other threads that need the class wait until it finishes. If thread 1 initializes A, which needs B, while thread 2 initializes B, which needs A, both wait forever. The latches below force that interleaving so the deadlock always happens.

Java
import java.util.concurrent.CountDownLatch;

public class Main {
    static final CountDownLatch aStarted = new CountDownLatch(1);
    static final CountDownLatch bStarted = new CountDownLatch(1);

    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(() -> System.out.println(A.VALUE));
        Thread t2 = new Thread(() -> System.out.println(B.VALUE));
        t1.setDaemon(true);
        t2.setDaemon(true);
        t1.start();
        t2.start();
        t1.join(1000);
        t2.join(1000);
        System.out.println("t1 alive: " + t1.isAlive());
        System.out.println("t2 alive: " + t2.isAlive());
    }

    static void await(CountDownLatch latch) {
        try { latch.await(); } catch (InterruptedException e) { throw new RuntimeException(e); }
    }
}

class A {
    static final Integer VALUE;
    static {
        Main.aStarted.countDown();
        Main.await(Main.bStarted);
        VALUE = B.VALUE + 1;          // needs B, which t2 is initializing
    }
}

class B {
    static final Integer VALUE;
    static {
        Main.bStarted.countDown();
        Main.await(Main.aStarted);
        VALUE = A.VALUE + 1;          // needs A, which t1 is initializing
    }
}
Output
t1 alive: true
t2 alive: true

The threads are daemons so the JVM can exit. A thread dump (jstack or jcmd <pid> Thread.print) shows both threads inside <clinit> with a line such as waiting on the Class initialization monitor for B; on JDK 25 the thread state is reported as RUNNABLE rather than BLOCKED, because the wait is not on a Java monitor, so deadlock detection in tools may not report it. The fix is to remove the cycle between static initializers, for example by moving the shared value into a third class or into a lazy holder (see Safe Publication and final Fields).

Interview points

  • Major version 65 is Java 21, 69 is Java 25; a too-new class file fails with UnsupportedClassVersionError.
  • Bytecode is stack-based with numbered local slots; javap -c -p shows it and javap -v shows the constant pool and bootstrap methods.
  • invokevirtual dispatches through the class vtable, invokeinterface through interface tables, invokespecial targets constructors and super calls, invokestatic has no receiver.
  • Lambdas and string concatenation (Java 9+) use invokedynamic; the bootstrap runs once per call site.
  • Class identity is name plus defining loader; the same bytes in two loaders give two classes and a ClassCastException between them.
  • Initialization happens on first active use; compile-time constants do not trigger it; Class.forName(name, false, loader) loads without initializing.
  • A failed <clinit> gives ExceptionInInitializerError once, then NoClassDefFoundError.
  • Cycles between static initializers in two threads can deadlock on class initialization locks.

Summary

  • Class file: magic, version, constant pool, flags, this/super, interfaces, fields, methods, attributes.
  • The operand stack and local slots define each frame; the JIT compiles them to registers.
  • Five invoke instructions; invokedynamic links through a bootstrap method such as LambdaMetafactory.metafactory or StringConcatFactory.makeConcatWithConstants.
  • Lifecycle: loading, linking (verification, preparation, resolution), initialization.
  • Loaders: bootstrap, platform, application, with parent-first delegation.
  • Static init runs superclass first; instance init runs superclass constructor, then subclass initializers, then subclass constructor body.