> ## Content Index
> Fetch the complete content index at: https://www.ggorantala.dev/llms.txt
> Use this file to discover other available public pages before exploring further.

# Class Loading, Linking and Initialization in the JVM
- URL: https://www.ggorantala.dev/class-loading-linking-initialization/
- Published: 2026-11-04T03:52:00.000Z
- Updated: 2026-11-05T03:52:50.000Z
- Description: Class loading, linking and initialization in the JVM, step by step: when static blocks run, why constants skip init, and what Could not initialize class means.
- Author: Gopi Gorantala
- Tags: jvm, class-loading, jvm-internals

## The question I couldn't answer from the log

A colleague pasted two lines from a service log and asked why the second one existed.

```log
java.lang.ExceptionInInitializerError
   Caused by: java.lang.NullPointerException: Cannot invoke "String.trim()" because the return value of "java.lang.System.getenv(String)" is null
java.lang.NoClassDefFoundError: Could not initialize class Boom$Config
   Caused by: java.lang.ExceptionInInitializerError: Exception java.lang.NullPointerException [in thread "main"]

```

OpenJDK 21.0.12.1, Linux x86-64, plain `java -cp out Boom`. Same class, same call, three attempts. The first attempt blows up with the real cause. Attempts two and three blow up with a *different* error that names a class which is, as far as anyone can tell, sitting right there on the classpath.

That's class loading, linking and initialization showing its seams. Three separate phases, one of which runs your code exactly once, and if that one fails the JVM remembers it forever. Nobody told my colleague that, and for years nobody told me either, not in a way that stuck. This one took me a bit over a day to get straight, mostly because the spec, the JLS and what HotSpot logs each describe it from a slightly different angle. It's written up so you can skip that day.

## Class loading, linking and initialization: three phases, one runs your code

Loading finds the bytes, parses them, and creates the in-memory representation of the class under a particular class loader. No code from the class runs. Linking takes that raw class and makes it safe and ready: it verifies the bytecode, prepares the static fields (allocates them, sets them to zero/null/false), and optionally resolves symbolic references like "the method `Foo.bar` in the class named `Foo`" into direct ones. Still none of your code. Initialization is the phase that finally runs the class's static initializer, `<clinit>`, which is the compiler's name for all your `static { }` blocks and static field initializers glued together in source order.

Two things people get wrong, and both cost me time. First: loading a class does not initialize it. `Foo.class` as an expression loads `Foo` and leaves its static block alone. Second: the three phases don't happen at the same moment for every class. The JVM spec says the order has to hold per class (loaded before linked, verified and prepared before initialized) but leaves the *timing* mostly up to the implementation. HotSpot is lazy about nearly all of it. A class gets loaded when something first needs it, linked right before it's initialized, and initialized when the first real use happens.

What counts as a "real use" is a short, fixed list, and Section 3 lets you watch each item on it. The part about the ExceptionInInitializerError is further down, in the traps.

## Five small programs and a log

You need a JDK 21\. I used OpenJDK 21.0.12.1 from the Ubuntu package; if you use SDKMAN, pick a 21 Temurin from `sdk list java` (I didn't run Temurin, I don't expect a difference). `java -version` on mine:

```log
openjdk version "21.0.12.1" 2026-08-18
OpenJDK 64-Bit Server VM (build 21.0.12.1+1-1-24.04.4-Ubuntu, mixed mode, sharing)

```

Compile the class files with `javac -d out *.java` and run with `java -cp out`. I'm not using the single-file source launcher here, because it compiles in memory and that muddies exactly the things we're trying to look at.

**Program 1: the trigger matrix.** Every nested class prints from its static block, so you can see when `<clinit>` runs.

```java
public class Triggers {
    static void say(String s) { System.out.println("  [main] " + s); }
    static int noisy(String s) { System.out.println("  >> init of " + s); return 1; }

    static class Plain    { static { System.out.println("  >> Plain.<clinit>"); }  static void hi() {} }
    static class Const    { static { System.out.println("  >> Const.<clinit>"); }  static final int K = 42; static final String S = "hello"; }
    static class NotConst { static { System.out.println("  >> NotConst.<clinit>"); } static final int R = Integer.parseInt("7"); }
    static class Base     { static { System.out.println("  >> Base.<clinit>"); }   static int shared = 5; }
    static class Derived extends Base { static { System.out.println("  >> Derived.<clinit>"); } static int own = 9; }
    interface Marker      { int X = Triggers.noisy("Marker.X"); }
    interface WithDefault { int Y = Triggers.noisy("WithDefault.Y"); default void d() {} }
    static class Impl implements Marker, WithDefault { static { System.out.println("  >> Impl.<clinit>"); } }
    static class ByName   { static { System.out.println("  >> ByName.<clinit>"); } }
    static class ByLoad   { static { System.out.println("  >> ByLoad.<clinit>"); } }
    static class Arr      { static { System.out.println("  >> Arr.<clinit>"); } }

    public static void main(String[] a) throws Exception {
        say("1. Plain.class literal");              Class<?> c = Plain.class;
        say("2. new Arr[1] (array)");               Object arr = new Arr[1];
        say("3. Const.K (compile-time constant)");  int k = Const.K; String s = Const.S;
        say("4. NotConst.R (static final, not constant)"); int r = NotConst.R;
        say("5. Derived.shared (declared in Base)"); int sh = Derived.shared;
        say("6. Derived.own");                      int ow = Derived.own;
        say("7. new Impl()");                       new Impl();
        say("8. loadClass(ByLoad)");                Triggers.class.getClassLoader().loadClass("Triggers$ByLoad");
        say("9. forName(ByLoad, false, loader)");   Class.forName("Triggers$ByLoad", false, Triggers.class.getClassLoader());
        say("10. forName(ByName)");                 Class.forName("Triggers$ByName");
        say("11. Plain.hi() (invokestatic)");       Plain.hi();
    }
}

```

**Program 2:** `Boom`, from the opening. A nested `Config` class whose static field reads an environment variable that isn't set, called three times in a loop.

```java
public class Boom {
    static class Config {
        static final String URL = System.getenv("DB_URL").trim();   // NPE: variable not set
        static void use() { System.out.println("used " + URL); }
    }
    public static void main(String[] a) {
        for (int i = 1; i <= 3; i++) {
            System.out.println("--- attempt " + i);
            try { Config.use(); } catch (Throwable t) {
                System.out.println(t);
                for (Throwable c = t.getCause(); c != null; c = c.getCause()) System.out.println("   Caused by: " + c);
            }
        }
    }
}

```

**Program 3:** `Dead`, two classes whose static blocks touch each other, started from two threads.

```java
public class Dead {
    static class A { static { System.out.println(Thread.currentThread().getName() + " in A.<clinit>, touching B"); nap(); B.touch(); } static void touch() {} }
    static class B { static { System.out.println(Thread.currentThread().getName() + " in B.<clinit>, touching A"); nap(); A.touch(); } static void touch() {} }
    static void nap() { try { Thread.sleep(300); } catch (InterruptedException e) {} }
    public static void main(String[] x) throws Exception {
        Thread t1 = new Thread(A::touch, "T-A"), t2 = new Thread(B::touch, "T-B");
        t1.start(); t2.start(); t1.join(4000); t2.join(4000);
        System.out.println("alive? T-A=" + t1.isAlive() + " T-B=" + t2.isAlive());
        System.exit(0);
    }
}

```

**Programs 4 and 5:** `Lazy` (calls `Gone.hello()` only if you pass an argument) and `Lazy3` (a method with an untaken branch that does `return new Sub3();` where `Sub3 extends Base3`). After compiling, copy `Lazy.class` into one directory and `Lazy3.class` plus `Base3.class` into another, and leave `Gone` and `Sub3` out of both. That's the whole point of them.

### Step by step

```log
Step 1 — the trigger matrix
$ java -cp out Triggers
Expected: the output shown in section 4 (reproduced there). Eleven [main] lines, a handful of ">>" lines.

Step 2 — see load, verify and init as separate events
$ java -Xlog:class+init=info,class+load=info -cp out Triggers 2>&1 | grep -E 'Triggers\$(NotConst|Plain)'
Expected: for each class a "class,load" line, later "Start class verification", "End class verification", and then "Initializing 'Triggers$X' ... by thread "main"".

Step 3 — the failure that remembers
$ java -cp out Boom
Expected: ExceptionInInitializerError on attempt 1, NoClassDefFoundError: Could not initialize class on 2 and 3.

Step 4 — the init deadlock
$ java -cp out Dead > dead.out &  sleep 2; jcmd $(pgrep -f "^java -cp out Dead") Thread.print | grep -A4 '"T-[AB]"'
Expected: both threads in <clinit>, each "waiting on the Class initialization monitor" for the other's class. After four seconds the program prints alive? T-A=true T-B=true.

Step 5 — lazy resolution, two ways
$ java -cp out2 Lazy ; java -cp out2 Lazy x ; java -cp out4 Lazy3
Expected: Lazy finishes; Lazy x dies at Lazy.main with NoClassDefFoundError: Gone; Lazy3 never prints "started".

```

Forcing levers: none of these needed shrinking. They're all tiny, so the labs run in well under a second. The one deliberate trick is the `nap()` in `Dead`, which widens the window so both threads are inside `<clinit>` at the same time; production deadlocks don't announce themselves that politely.

What tells you that you saw the right thing: in Step 2, the load line for a class and its "Initializing" line are separated by the verification pair, and in some cases by other classes' whole lifecycle. If you see "Initializing" without a verification pair before it, that class most likely came from the CDS archive (look for `source: shared objects file` on its load line). Your own classes never do.

Environment differences worth knowing: on JDK 17 and older, the cause in Step 3's attempt 2 is missing entirely (more below). Output order in the `Dead` run varies; which thread prints first isn't defined. I did not run any of this on JDK 17, 25 or 27.

Cleanup: `rm -rf out out2 out3 out4 dead.out`. Nothing here writes big files.

## What the spec says happens to a class, in order

Here's what my eleven-line matrix printed on 21.0.12.1:

```log
  [main] 1. Plain.class literal
  [main] 2. new Arr[1] (array)
  [main] 3. Const.K (compile-time constant)
  [main] 4. NotConst.R (static final, not constant)
  >> NotConst.<clinit>
  [main] 5. Derived.shared (declared in Base)
  >> Base.<clinit>
  [main] 6. Derived.own
  >> Derived.<clinit>
  [main] 7. new Impl()
  >> init of WithDefault.Y
  >> Impl.<clinit>
  [main] 8. loadClass(ByLoad)
  [main] 9. forName(ByLoad, false, loader)
  [main] 10. forName(ByName)
  >> ByName.<clinit>
  [main] 11. Plain.hi() (invokestatic)
  >> Plain.<clinit>

```

That matches the JLS rule almost exactly (§12.4.1): a class is initialized right before the first time an instance is created, a static method it declares is invoked, a static field it declares is assigned, or a static field it declares is used and isn't a constant variable. Reflection also counts, as in line 10\. And nothing else does. A few of those lines are worth slowing down for, because each one contradicts something I'd have said confidently a month ago.

Line 3 printed nothing because `Const.K` isn't read at runtime at all. `javac` replaces it with the literal. `javap -c` on `Triggers` shows `bipush 42` and `ldc "hello"` in `main`, no `getstatic`, and `javap -v` on `Const` shows `ConstantValue: int 42` on the field. A `static final` primitive or String initialised with a constant expression is a *constant variable*; it's baked into every class that uses it. That's why changing such a constant and recompiling only the defining class leaves callers with the old value. Line 4, `static final int R = Integer.parseInt("7")`, looks the same in source and is not a constant, so it triggers init.

Line 5 is the one I'd have lost money on. `Derived.shared` compiles to `getstatic Triggers$Derived.shared`, and `Derived` still isn't initialized: only `Base` is, because that's the class that declares the field. Line 6 initializes `Derived` (and it was already guaranteed that `Base` came first). The JLS example calls this out, and I'd read it twice without it registering.

Line 7 shows the interface rule. `Impl` implements two interfaces with a field initialised by a method call. `WithDefault` has a default method; `Marker` doesn't. Only `WithDefault` initialized, and it did so *before* `Impl`'s own static block. Superinterfaces are initialized along with a class only when they declare default methods.

Lines 8 and 9 are the loaders. `loadClass` and `Class.forName(name, false, loader)` both load without initializing. Plain one-argument `Class.forName` initializes. That distinction is why JDBC drivers used `Class.forName("com.mysql.Driver")` to register themselves: the static block *is* the registration.

### The initialization procedure

When something triggers initialization, the JLS (§12.4.2) gives a twelve-step procedure around a per-class initialization lock. I tried to read the JVM spec's §5.5 version of it and the page fetch kept truncating at the first bullet, so I used JLS 12.4.2; the two describe the same thing. Compressed:

1. Take the class's lock. If another thread is initializing it, wait (release the lock and block until it finishes).
2. If *this* thread is already initializing it, return immediately. That's a recursive request, and it's how a static block can safely read its own class's partly-built statics.
3. If it's already initialized, return. If it's marked erroneous, throw `NoClassDefFoundError`.
4. Otherwise mark it "being initialized by me", release the lock, initialize the superclass (and any superinterfaces with default methods) first, then run the static initializers in source order.
5. If that succeeds, mark it initialized and wake the waiters. If it throws something that isn't an `Error`, wrap it in `ExceptionInInitializerError`; mark the class erroneous; rethrow.

Step 3 is the whole opening mystery. After a failed `<clinit>`, a class never gets a second chance. The JVM doesn't re-run the static block, it doesn't clear the state, and every later trigger throws `NoClassDefFoundError`. The lock in step 1 is also what produced the deadlock in Program 3, and I'll get to that.

## What HotSpot does with it, and what moved between JDK 8 and 21

HotSpot keeps this as a state machine on each loaded class: allocated, loaded, linked, being-initialized, fully-initialized, and initialization-error. I'm going from memory of `instanceKlass.cpp` here; I did not re-read it for this run, so treat the names as a guide to look for rather than a quote. The state names line up with the log: `-Xlog:class+init` prints "Start class verification" / "End class verification" for the move from loaded to linked, then "Initializing" for the move into being-initialized.

That log is how I found the thing I should have known. Verification doesn't happen at load. In Step 2 the `Triggers$Plain` load line sits at 0.037 s (it was loaded back at line 1, by the class literal) and its verification pair at 0.040 s, immediately before "Initializing". Linking is lazy: a class that is loaded but never initialized never gets verified.

Which brings me to the dead end. I'd been telling people that a missing class "only fails when you touch it," and I built `Lazy` to show it:

```log
$ java -cp out2 Lazy
started
finished without touching Gone
$ java -cp out2 Lazy x
started
Exception in thread "main" java.lang.NoClassDefFoundError: Gone
	at Lazy.main(Lazy.java:4)
Caused by: java.lang.ClassNotFoundException: Gone

```

That works: resolution is lazy, so the symbolic reference to `Gone` sits unresolved until line 4 executes, and fails there with the JVM spec's "linkage errors are thrown at a point where the program needs the class". I assumed `Lazy3` would work the same way and wrote it as the second example in thirty seconds. It doesn't:

```log
$ java -cp out4 Lazy3
Error: Unable to initialize main class Lazy3
Caused by: java.lang.NoClassDefFoundError: Sub3

```

`Sub3` appears in an untaken branch (`return new Sub3();` in a method returning `Base3`), `started` is never printed, and the failure comes before `main` runs a single instruction. The verifier needs to prove `Sub3` is assignable to `Base3`, and the only way to know is to load `Sub3`. I got one more data point by accident: `Lazy2`, same shape, but returning the missing class as a `Runnable` interface, starts fine, because the verifier treats assignment to an interface type leniently. If you want the full story, it's the verifier article's job, [jvm-bytecode-verification](https://ggorantala.dev/jvm-bytecode-verification/?ref=ggorantala.dev), and the practical rule here is this: *a missing class can fail at link time, before initialization, before main, and nowhere near the line that mentions it.* Running with `-XX:+UnlockDiagnosticVMOptions -XX:-BytecodeVerificationRemote` makes `Lazy3` start (printing `started` and `null`), which is how I confirmed it was the verifier and nothing else. Don't ship that.

Version table, short, because the loading and init rules themselves haven't changed in any way that matters here:

```log
JDK 8    NoClassDefFoundError: Could not initialize class X has no cause. The original
         failure is in a log line somewhere earlier, if anyone kept it.
JDK 9    Unified logging: -Xlog:class+init, class+load supersede -XX:+TraceClassLoading and friends.
JDK 12   Default CDS archive of JDK classes on by default: those load from "shared objects file".
~JDK 18  JDK-8048190: the NoClassDefFoundError carries the original error as its cause
         (RFR threads are from August 2021; I did not confirm the exact fix release, I only ran 21).
JDK 24   JEP 483: load and link classes ahead of time into an AOT cache. Initialization still
         runs at startup; your static blocks aren't moved.

```

The JDK-8048190 line is the one that matters in practice. If your service is on 17 and the line reads `Could not initialize class X` with no cause, you're on the old behaviour and the root cause is in whatever logged the first `ExceptionInInitializerError`.

## The traps: constants, deadlocks, and an error that hides its cause

**The hidden first failure.** In the real system, attempt one happens on some request thread, gets swallowed by a framework's catch-all or logged at WARN in a rolled file, and every later request produces the noisy `Could not initialize class`. People then search for the second message, and the page-1 results (several explain it only as "the class failed to initialize") don't say that the *first* error is gone from the current log window. On JDK 21 the cause you see is the stand-in `ExceptionInInitializerError: Exception java.lang.NullPointerException [in thread "main"]`. It names the exception type and thread, but not the message or the stack. The real stack is only in the first `ExceptionInInitializerError`'s `Caused by`. Search for the first one: `grep -n 'ExceptionInInitializerError' app.log | head`.

**Errors aren't wrapped.** Step 5 of the procedure says only non-`Error` exceptions become `ExceptionInInitializerError`. An `Error` thrown from a static block (an `OutOfMemoryError`, an `AssertionError`) propagates as itself, and the class still goes erroneous. I took that from the JLS and didn't test it.

**Init-lock deadlocks.** Program 3 shows two threads each holding one class's init lock and wanting the other's. `jcmd <pid> Thread.print` on 21.0.12.1:

```log
"T-A" ... in Object.wait()
   java.lang.Thread.State: RUNNABLE
	at Dead$A.<clinit>(Dead.java:2)
	- waiting on the Class initialization monitor for Dead$B
"T-B" ... in Object.wait()
   java.lang.Thread.State: RUNNABLE
	at Dead$B.<clinit>(Dead.java:3)
	- waiting on the Class initialization monitor for Dead$A

```

Two details I didn't expect. The state says RUNNABLE even though the thread is blocked. And the dump contained no "deadlock" section (I counted zero occurrences of the word), presumably because this is a lock inside the VM that the Java-level deadlock detector doesn't track. So the way to spot it is the "Class initialization monitor" line, which showed up twice in my dump. Static blocks that reference each other's classes, or a superclass whose static block touches a subclass, are the usual recipe. A cycle within one thread is harmless (step 2 above); it takes two threads to wedge.

**The constant trap.** Change a `static final int` constant in a library, ship only the library jar, and callers keep the old value baked into their bytecode until they're recompiled. No error, no log line. Not a JVM bug; that's how line 3 works.

**Flags people copy.** `-Xverify:none` / `-XX:-BytecodeVerificationRemote` "to make a stubborn missing class go away" is the one I see most, and above is what it actually does: hides a symptom of a classpath problem. Deprecated, diagnostic-only, and not a fix. `-Xshare:off` to "fix" init-order weirdness doesn't change init semantics at all.

## Fixing it: stop doing work in the static block

A static initializer is the worst place to do anything that can fail. It runs on whichever thread arrives first, it holds a lock others wait on, and a failure there is permanent for the life of the JVM. In order of what I'd do:

First, move the risky work out. Reading environment variables, opening connections, parsing config files, calling other services: none of it belongs in `<clinit>`. Put it in a constructor or a factory method that can throw a normal exception, be retried and be logged where you expect.

Second, if you do want lazy and thread-safe initialization of something heavy, use the holder idiom, which borrows the init lock on purpose:

```java
public final class Registry {
    private Registry() {}
    private static final class Holder { static final Registry INSTANCE = build(); }
    public static Registry get() { return Holder.INSTANCE; }          // Holder initializes on first call
    private static Registry build() { /* may throw, so keep it small and loud */ return new Registry(); }
}

```

It's safe because of step 1 and 2 of the procedure: concurrent first callers wait, one runs `build()`, everyone sees the result. It's also exactly as permanent when `build()` fails, so keep what's inside it boring.

Third, fail early and loudly. If a class *must* initialize for the service to be useful, touch it on startup with `Class.forName("com.acme.Config")` in `main`, and let the exception kill the process with the real stack. That turns a mystery on request 40,000 into a crash on request zero.

What these cost: the holder idiom adds one tiny class; eager touch adds a few milliseconds at startup. Neither slows the steady state.

## Reading the evidence, and what to retest on upgrade

Leave this on: `-Xlog:class+init:file=class-init.log:uptime,level,tags` when you're diagnosing, not always. It's chatty (JDK classes are logged too: 400-odd "Initializing" lines before my own first class). For the production question "which class failed and why", the useful signals are the application log for the first `ExceptionInInitializerError`, a count of `Could not initialize class` lines (any non-zero is a class that is dead until restart), and thread dumps with `Class initialization monitor` in them if threads seem stuck at startup. I don't know a stock Micrometer metric for either; I'd alert on the log line.

On an LTS-to-LTS move: from 17 to 21 the cause on `Could not initialize class` appears (if you're on 17, verify, since I didn't run it); the verifier and CDS behaviour are the same story as in the verification article. If you adopt the AOT cache from JEP 483 on a newer JDK, retest startup order, because load and link move earlier but your static blocks should still run when they ran before. I haven't run that.

## What to keep

- Loading, linking and initialization are different events. `Foo.class`, `loadClass` and `forName(name, false, ...)` load without running a static block.
- Reading a compile-time constant never initializes anything. Reading `Sub.field` initializes only the class that *declares* `field`.
- A failed static block poisons the class until the JVM restarts. The first error has the cause; every later `Could not initialize class` is an echo.
- A missing class can fail during verification, before `main`, from an untaken branch. It's not always lazy.
- Never put I/O, config reads or cross-class dependencies in a static initializer. Use a holder or a factory.

## Quick answers on class initialization

**When does a static block run in Java?**  
When the class is first initialized: right before its first instance is created, a static method it declares is called, or a non-constant static field it declares is read or written. Loading it, referencing `Foo.class`, or reading a compile-time constant does not run it. Verified on JDK 21.0.12.1.

**What is the difference between loading and initializing a class?**  
Loading finds the bytes and builds the class under a loader. Initializing runs `<clinit>`. Linking (verify, prepare, resolve) sits between them. `Class.forName(name, false, loader)` stops after loading; `Class.forName(name)` goes all the way. Which loader does the loading is covered in [java-class-loaders](https://ggorantala.dev/java-class-loaders/?ref=ggorantala.dev).

**Why do I get "Could not initialize class" after an earlier ExceptionInInitializerError?**  
The class's static initializer threw, so the JVM marked it erroneous and won't run it again. Each later use throws `NoClassDefFoundError`. The original stack is in the first `ExceptionInInitializerError`. Diagnosing the NoClassDefFoundError family end to end is the job of [java-noclassdeffounderror](https://ggorantala.dev/java-noclassdeffounderror/?ref=ggorantala.dev).

**Can two classes deadlock during initialization?**  
Yes, if two threads each start initializing a class whose static block touches the other. `jcmd Thread.print` shows both threads RUNNABLE in `<clinit>` with a "waiting on the Class initialization monitor" line, and no deadlock section.