Skip to lesson content

Foundations · Lesson 10

Class loading lifecycle — from .class bytes to a live JVM class

Follow a class through loading, linking, and initialization, and connect runtime type identity to HotSpot metadata and class-loader lifetime.

15 min read · HotSpot · JDK 25 class loading foundations

You add a library, start the application, and eventually call one of its methods. Before that method can do useful work, the JVM needs a runtime type behind the class name. Having a .class file on disk does not mean its static initializer has already run.

Lesson 9 followed objects after their classes existed, including objects the JIT could eliminate. Now we move one step earlier: how do class-file bytes become a class the running JVM can use?

Loading creates a runtime type; verification and preparation precede initialization. Resolution belongs to linking but may continue later.
The solid path shows prerequisites for initialization. The separate resolution path prevents “linked” from implying that every reference has already been resolved. Open full-size diagram.

Loaded, linked, and initialized are different states. Linking comprises verification, preparation, and resolution, but these are not three boxes that must all finish eagerly before any execution. Keep that qualification in mind as we follow the lifecycle defined by the JVM specification.

Loading: bring class bytes into the runtime

Suppose the application needs com.example.Customer. A class loader locates its binary representation, perhaps in a JAR or in bytes generated by a framework. A custom loader can pass those bytes to ClassLoader.defineClass(...).

Loading creates two connected runtime pieces. HotSpot builds internal metadata that records the class’s fields, methods, parent types, and other details the VM needs. It also creates a java.lang.Class object on the Java heap. That is the object returned by expressions such as:

Customer.class
customer.getClass()

Java code uses this Class<Customer> object to inspect and refer to the type. HotSpot documentation and source code often call it the class’s mirror because it is the Java-facing representative of the VM’s internal class definition.

A loader supplies Customer bytes. HotSpot creates internal class metadata and a connected Class Customer heap object that Java code can use.
The Class object is Java's view of the runtime type. It is connected to HotSpot's internal metadata, but it does not contain all of that metadata. Open full-size diagram.

The word mirror does not introduce another object. It is just a HotSpot name for this familiar java.lang.Class object. Reflection starts from the mirror, while the VM keeps the fuller class representation in its own metadata. We will place both pieces in memory later in the lesson.

Class identity includes the defining loader

Imagine two plugins each bring a class called com.example.Customer. If separate loaders define those classes, the JVM has two distinct runtime types even if the bytes are identical:

class identity = binary name
              + defining class loader
Loader A and Loader B each define com.example.Customer, producing distinct runtime types.
Identical names do not make independently defined classes the same type. Open full-size diagram.

An object of one Customer type cannot be cast to the other. That explains errors whose abbreviated message seems to say “Customer cannot be cast to Customer”: the displayed name hides the loader part of identity.

The defining loader is the loader that actually turns the bytes into the runtime type. A plugin loader may instead ask its parent to load Customer. If the parent defines Customer once, both plugins use that same type.

So two loader objects do not automatically mean two types. Two separate definitions of the same name do. When a cast fails across a plugin boundary, inspect both the class name and getClassLoader(); equal names with different defining loaders identify different runtime types.

Linking connects the loaded type to runtime state

Finding bytes is only the beginning. The JVM needs to establish that their instructions obey its rules, create the class state those instructions rely on, and connect symbolic references to runtime entities.

Linking contains verification for bytecode constraints, preparation for static and runtime state, and resolution for symbolic references.
Verification and preparation are required before initialization. Individual references may be resolved early or when needed. Open full-size diagram.

These stages answer different questions. Verification concerns valid execution; preparation concerns initial state; resolution concerns what a reference means. A class may be verified and prepared while some of its symbolic references are still unresolved.

Verification: can these instructions execute legally?

Bytecode can come from javac, another JVM language, an agent, or a bytecode generator. The JVM cannot assume every producer generates valid instructions. The verifier checks constraints such as operand-stack types, local-variable use, branch targets, and consistency where control-flow paths meet.

An integer addition requires two integer operands. A path supplying a reference where an integer is required fails verification.
Verification checks virtual-machine constraints, not whether the application implements the intended business rules. Open full-size diagram.

For example, iadd expects integer operands. Bytecode that supplies an object reference in place of an integer cannot be executed as a valid integer addition. Verification failure can produce VerifyError. Malformed class-file structure can fail earlier with ClassFormatError; not every bad file is a verifier failure. Access and member lookup also have resolution-time checks.

Preparation creates state before Java initialization logic

Use one small class to separate the stages:

class Config {
    static int count = 42;
    static Object owner = new Object();
}

Preparation establishes static fields and their JVM defaults: conceptually count is 0 and owner is null. It can also establish implementation data structures such as method tables. It does not execute the Java expressions 42 and new Object() as ordinary static initialization code.

Preparation establishes count as zero and owner as null. Java initialization later assigns 42 and an object reference.
This comparison concerns ordinary static fields; ConstantValue fields need the separate distinction below. Open full-size diagram.

Later, initialization performs the assignments described in the source. This separation is useful when reasoning about recursive initialization: partially initialized class state exists, even though straightforward Java access usually triggers initialization before letting you read a nonconstant static field.

Why a compile-time constant behaves differently

We just saw that an ordinary static field first receives its default value, then its Java initializer runs during class initialization. One common case follows a special path:

static final int LIMIT = 42;
static final Integer BOXED = 42;

LIMIT is a compile-time constant. The class file records 42 in a ConstantValue attribute, and code that reads Config.LIMIT may contain the value 42 directly. That read therefore does not need to initialize Config.

BOXED looks similar, but it is not a compile-time constant because Integer is a reference type. Assigning it requires runtime initialization work.

The point is narrow but important: static final alone does not make a field a compile-time constant. Primitive and String fields with constant-expression initializers can receive this treatment; other static fields still depend on class initialization. In the JVM’s specified initialization procedure, a ConstantValue is assigned before <clinit> runs.

Resolution gives symbolic references runtime meaning

A concrete call makes resolution easier to see:

class Customer {
    static int fee() {
        return 42;
    }
}

class Checkout {
    int total() {
        return Customer.fee();
    }
}

Checkout.class does not store a final memory address for Customer.fee(). The call instruction points to entry #7 in Checkout’s constant pool:

0: invokestatic #7
   // Method Customer.fee:()I

Constant pool:
#7 Methodref #8.#9
   // Customer.fee:()I
#8 Class #10
   // Customer
#9 NameAndType #11:#12
   // fee:()I
#10 Utf8 Customer
#11 Utf8 fee
#12 Utf8 ()I

The symbol says: find the class named Customer, then find its static method named fee with descriptor ()I—no parameters, returning an int.

Checkout bytecode calls constant-pool entry 7. The entry describes Customer fee with descriptor returning int, and resolution connects that symbol to the runtime method.
The bytecode stores a constant-pool index. Resolution connects the description at that index to the corresponding runtime class and method. Open full-size diagram.

When the reference is resolved, the JVM uses Checkout’s loading context to identify the correct runtime Customer class, looks up fee()I, and checks that the reference is valid and accessible. The runtime constant-pool entry can then represent the established runtime relationship.

If the loaded Customer class does not have that method, resolution can produce NoSuchMethodError. The source compiled against one API, but the symbolic description cannot be connected to the classes present at runtime. The next lesson opens the constant pool in more detail; here the key idea is that resolution turns a description into a checked runtime relationship.

Resolution may continue after initialization

A class can contain a reference used only on an optional code path. The JVM need not resolve that reference just because an unrelated method is executing.

An initialized class contains references A and B. Execution needs A and resolves it; unused B can remain symbolic.
This is a permitted lazy strategy, not a promise that every JVM resolves each reference at exactly its first instruction use. Open full-size diagram.

Implementations may resolve eagerly or lazily within the specification’s rules for when errors become observable. Therefore, an initialized class can still encounter a resolution failure later. Initialization readiness does not prove that every optional dependency works. This is one reason a rarely used feature can expose a linkage error long after startup.

Initialization executes the class’s static logic

Extend Config with a static block:

class Config {
    static int count = 42;

    static {
        System.out.println("initializing");
    }
}

The compiler collects the required static field assignments and static blocks into the special class or interface initialization method, <clinit>. Here it assigns count, then prints the message, preserving source order. A class with no such work need not have a <clinit> method.

The initialization procedure handles constants and required parent initialization before running Config clinit, which assigns count and prints a message.
The JVM coordinates initialization. Applications do not call clinit as an ordinary method. Open full-size diagram.

Before initializing a class, the JVM ensures it is verified and prepared. It initializes its superclass and the relevant superinterfaces that declare default methods before running the class’s own initializer. The procedure coordinates competing threads; successful initialization is not repeated for each access. Recursive initialization and failure have additional rules. If initialization fails, the type can enter an erroneous state, and later active uses can fail rather than retry the initializer normally.

<clinit> initializes the class. Constructors, represented by <init>, initialize individual instances. Creating a second Config instance does not rerun the static block.

First relevant use can trigger initialization

Creating an instance, invoking a declared static method, or accessing a declared nonconstant static field can require initialization. Certain reflective operations do too. Inherited field access initializes the class that actually declares the field, rather than necessarily the subclass name written in the expression.

A class literal such as Config.class does not itself trigger initialization. Reading a compile-time constant such as Config.LIMIT also does not require initialization of Config. This makes “first relevant use” more accurate than “the first mention of its name.”

Save this bounded experiment as LifecycleDemo.java. Predict when the message will appear:

class Config {
    static final int LIMIT = 42;
    static int count = 42;
    static {
        System.out.println("initializing");
    }
}

public class LifecycleDemo {
    public static void main(
            String[] args)
            throws Exception {
        var type = LifecycleDemo.class;
        var loader = type.getClassLoader();
        Class.forName(
            "Config", false, loader);
        System.out.println("loaded");
        System.out.println(Config.LIMIT);
        System.out.println(Config.count);
        System.out.println(Config.count);
    }
}

Run javac LifecycleDemo.java, followed by java LifecycleDemo. The false argument requests loading without initialization. The first read of count then requires initialization before the read proceeds.

loaded
42
initializing
42
42

This output was verified with Homebrew OpenJDK HotSpot 25.0.2. The example demonstrates initialization timing; it does not expose the exact instant every linking operation occurs. javap -c -verbose Config also shows ConstantValue: int 42 for LIMIT, while the static initializer contains the assignment to count.

Deferring work avoids executing every library’s initialization code at startup. The trade-off is that a first request may pay for loading, linking, or initialization as well as JIT warmup. Those are separate causes of first-use latency.

Follow Customer across Metaspace and the heap

Consider this class after loading and initialization:

class Customer {
    static int nextId = 1;
    final int id = nextId++;
}

Loading created HotSpot’s Customer metadata and the heap object returned by Customer.class. Preparation established the ordinary static field with its default 0; initialization changed nextId to 1. A later new Customer() gives the new object id = 1 and advances the class-wide nextId to 2.

Metaspace holds Customer class metadata. The Java heap contains the connected Class Customer object with class-wide nextId equal to two and a separate Customer instance with id equal to one.
A simplified HotSpot view after class initialization and one Customer allocation: the Class object gives Java code access to the type, while Metaspace holds much of the VM's detailed class representation. The arrows show VM associations, not ordinary Java reference fields. Open full-size diagram.

In HotSpot, much of the class, method, field, type-relationship, and runtime-constant-pool metadata is allocated through Metaspace in native memory. The Class<Customer> object is a Java heap object associated with that metadata. HotSpot also stores static fields for an ordinary instance class with its mirror, while each Customer object carries only its own instance state.

Both the Class<Customer> object and each Customer instance are connected to the same runtime class definition. They serve different roles: one represents the type to Java code; the other is an instance of that type. Loading creates the type representation before the class’s initialization logic runs. JIT-compiled machine code, if produced later, belongs to the separate Code Cache.

Loader reachability can keep Metaspace occupied

Suppose each plugin reload creates a loader and defines another generation of plugin classes. Removing the plugin from a menu does not prove that its loader has become unreachable. A long-lived cache, thread context class loader, or retained plugin instance can keep the old loader reachable indirectly.

A GC root reaches a cache that retains an old plugin loader. Its ordinary named classes and associated metadata remain live across reloads.
Repeated retained loader generations can grow Metaspace usage; one retained loader by itself does not imply continuing growth. Open full-size diagram.

For ordinary named classes, unloading depends on the defining loader becoming reclaimable. A reachable loader prevents unloading its ordinary classes. Once the retention path is removed, unloading becomes possible, not immediate or guaranteed at the next collection; collector configuration and JVM policy matter. Bootstrap-defined classes are not unloaded, and hidden classes have special lifetime rules outside this lesson’s ordinary loader model.

This explains why a Metaspace problem can begin with a Java heap reference. The collector preserves what is still reachable. It cannot reclaim an old plugin generation merely because the application intended to retire it. Generating ever more classes in one long-lived loader can also grow metadata without repeated loader creation.

When investigating, correlate loaded and unloaded class counts with Metaspace used memory and inspect retention paths for retired loaders. Reserved address space, committed memory, and used metadata are different measurements; a large reservation alone is not evidence of a leak.

Check your reasoning

Config has been loaded and prepared. Its ordinary field is declared static int count = 42, but initialization has not executed. What belongs in the conceptual preparation snapshot?

Pause here, then check your reasoning

count = 0. The assignment to 42 belongs to the Java initialization logic. An ordinary Java read of the nonconstant static field normally triggers initialization first, so that conceptual snapshot is not a promise that the first normal read prints zero.

Now suppose two loaders delegate Customer to the same parent. Have they necessarily created distinct Customer types?

Check the identity boundary

No. Identity uses the defining loader. If the parent defines the class once and both children use that definition, they share the same runtime type. Separate definitions of the same binary name would create distinct types.

Connect the source file to execution

We can now connect the earlier object and memory lessons to the type that makes those objects meaningful. Compilation produces bytes; loading creates the runtime representation; linking verifies, prepares, and resolves relationships as required; initialization runs the class’s static logic before relevant active use proceeds.

Java source compiles to class-file bytes. Loading creates metadata and a mirror; verification and preparation precede initialization and active use. Resolution can continue during execution.
This is a dependency map, not a claim that every JVM performs all work in a single eager pass. Open full-size diagram.

The next planned lesson opens the .class file itself: what the constant pool contains, how bytecode names its dependencies, and how the runtime constant pool connects that representation to interpretation and later JIT compilation.

Sources and implementation boundaries

Adapted from Daily JVM Byte #10 and its reviewed draft. Diagrams are conceptual. Lifecycle rules come from Java SE 25; Metaspace and mirror placement describe HotSpot. The small initialization experiment was executed; leak diagrams describe reasoning examples, not measured leak runs.