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.
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));
}
}
}magic: cafebabe
minor: 0, major: 69
constant pool has entries: trueAfter 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.
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));
}
}5
13javap -c -p Main shows the instructions (excerpt):
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: ireturnThe 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.
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);
}
}[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.
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());
}
}49
true
trueExcerpt from javap -c -p Main:
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.
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));
}
}AAPL:100Excerpt from javap -c -v Main:
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:\u0001The 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.
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()));
}
}String: bootstrap
java.sql.Connection: platform
Main: app
parent of app: platform
parent of platform: bootstrapThe 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.
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;
}
}
}same name: true
same class: false
loaders: app, isolating
instance of app's Plugin: falseCasting 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.
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"); }
}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 constructorStatic 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.
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
}
}t1 alive: true
t2 alive: trueThe 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 -pshows it andjavap -vshows the constant pool and bootstrap methods. invokevirtualdispatches through the class vtable,invokeinterfacethrough interface tables,invokespecialtargets constructors and super calls,invokestatichas 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
ClassCastExceptionbetween 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>givesExceptionInInitializerErroronce, thenNoClassDefFoundError. - 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;
invokedynamiclinks through a bootstrap method such asLambdaMetafactory.metafactoryorStringConcatFactory.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.
