Class loading

444 classes to print hello, parent delegation, "Cannot cast PriceRule to PriceRule", the NoClassDefFoundError that means a static initialiser failed, and the loader that fills Metaspace.

6 min read⚙️ JVM Internals and Performance

Before a line of your code runs, the JVM has to find its class, read the bytes, check them, and prepare them. That process — class loading — is invisible when it works, and responsible for some of the most confusing errors in Java when it does not: a ClassCastException between a class and itself, a NoClassDefFoundError for a class that is plainly on the classpath, a Metaspace that fills after every redeploy.

Every output here is from Java 21.

Loading, linking, initialising

A class goes through three phases, and the errors differ by phase:

  1. Loading — a class loader finds the bytes (from a JAR, a directory, the runtime image, a network, or generated in memory) and creates a Class object. A class that cannot be found fails here.
  2. Linkingverification checks the bytecode is well-formed and type-safe, so a malformed or malicious class cannot corrupt the JVM; preparation allocates static fields with default values; resolution turns symbolic references into direct ones, often lazily.
  3. Initialisation — static initialisers and static field assignments run, exactly once, the first time the class is actively used.

Loading is lazy. Printing "hello" loads far more than one class:

plaintext
$ java -Xlog:class+load:file=cl.log Hello
classes loaded to print hello: 444
  of which from the CDS archive: 441
  java.lang.String source: shared objects file
  Hello source: file:/w/

Four hundred and forty-four classes, 441 of them from the CDS archive — class data sharing, a pre-processed archive of JDK classes shipped with the runtime and mapped into memory instead of being parsed again. Only three were loaded another way, one of them Hello, from its directory. Timing the start of that program fifteen times each way:

plaintext
-Xshare:auto: median 30 ms, fastest 20 ms, slowest 130 ms (15 runs)
-Xshare:off: median 36 ms, fastest 32 ms, slowest 44 ms (15 runs)

Small for "hello", and it grows with the number of classes: A Spring application loads many thousands, which is why application-level CDS archives exist for startup time.

Three loaders and parent delegation

The JVM starts with three class loaders, arranged as a hierarchy:

plaintext
String          -> null
java.sql.Driver -> jdk.internal.loader.ClassLoaders$PlatformClassLoader@45ee12a7
Loaders         -> jdk.internal.loader.ClassLoaders$AppClassLoader@11524711
its parent      -> jdk.internal.loader.ClassLoaders$PlatformClassLoader@45ee12a7
  • The bootstrap loader loads the core of the JDK — java.lang, java.util — and is written in native code, so getClassLoader() returns null for its classes.
  • The platform loader loads the rest of the JDK's modules, such as java.sql.
  • The application (system) loader loads your classpath or module path.

When a loader is asked for a class, it first asks its parent; only if the parent cannot find it does it look itself. This parent delegation is why your code cannot replace java.lang.String by putting a class with that name on the classpath: the request reaches the bootstrap loader first, which already has the real one.

A class is its name and its loader

The identity of a class at run time is the pair (fully qualified name, defining loader). The same .class file loaded by two different loaders produces two different classes. Two loaders, each pointed at the same plugin directory, with no parent to delegate to:

java
ClassLoader first  = new URLClassLoader(new URL[]{plugins}, null);
ClassLoader second = new URLClassLoader(new URL[]{plugins}, null);
Class<?> a = first.loadClass("plugin.PriceRule"), b = second.loadClass("plugin.PriceRule");
Object rule = a.getDeclaredConstructor().newInstance();
b.cast(rule);
plaintext
same name: true, same class: false
ClassCastException: Cannot cast plugin.PriceRule to plugin.PriceRule

"Cannot cast X to X" is the error message that sends people looking for a typo. There is no typo. Something in the application loaded the class twice through different loaders — an application server with a library both in the server and in the application, a plugin system, a hot-reload tool, or a Spring Boot DevTools restart loader holding one copy while a library cached another. The fix is to make sure one loader is responsible for the shared type, usually by moving it to a common parent.

ClassNotFoundException and NoClassDefFoundError

Two errors with similar names and different meanings:

plaintext
java.lang.ClassNotFoundException: com.example.MissingDriver

ClassNotFoundException is a checked exception thrown when code asks for a class by name at run time — Class.forName, loadClass — and no loader finds it. A JDBC driver not on the classpath, a plugin class name misspelled in configuration.

NoClassDefFoundError is an error thrown when a class that was present at compile time cannot be used at run time. It has two quite different causes, and the second is the one that wastes an afternoon. Here is a class whose static initialiser reads an environment variable that is not set, used twice:

java
static class Config {
    static final String REGION = System.getenv().get("REGION").toUpperCase();
}
plaintext
attempt 1: java.lang.ExceptionInInitializerError: null  cause: java.lang.NullPointerException: Cannot invoke "String.toUpperCase()" because the return value of "java.util.Map.get(Object)" is null
attempt 2: java.lang.NoClassDefFoundError: Could not initialize class Loaders$Config  cause: java.lang.ExceptionInInitializerError: Exception java.lang.NullPointerException [in thread "main"]

The first use fails with ExceptionInInitializerError, carrying the real cause. The class is then marked as failed, and every later use throws NoClassDefFoundError: Could not initialize class — with the class file present and correct. If the first failure scrolled out of the logs, or happened on another thread, the error left behind points at a class that obviously exists.

Classpath, module path, and a fat JAR

  • The classpath is a flat list of JARs and directories searched in order. Two JARs with the same class means the first one wins, silently — the source of many "it works on my machine" version conflicts.
  • The module path (Java 9+) loads modular JARs as named modules, which declare what they export and require. Split packages and missing dependencies are errors at startup instead of surprises at run time. Most applications still run on the classpath.
  • A Spring Boot executable JAR contains your classes and every dependency as nested JARs under BOOT-INF/lib. The standard class loaders cannot read a JAR inside a JAR, so Spring Boot's launcher creates its own class loader that can. That is why java -jar app.jar works, and why tools that expect plain classpath JARs sometimes need the application unpacked first.

Custom loaders, and the leak they cause

Application servers, plugin systems and scripting engines create class loaders so that code can be loaded — and unloaded — independently. A class can only be unloaded when its loader becomes unreachable, together with every class it loaded and every object of those classes.

A single reference to any one of those objects from outside — a static field in a shared library, a thread started by the plugin that never stops, a JDBC driver registered in DriverManager, a ThreadLocal on a server thread — keeps the whole loader, and all its classes, in memory. Each redeploy adds another copy, and the memory areas lesson's other OutOfMemoryError arrives:

plaintext
java.lang.OutOfMemoryError: Metaspace   (after 10550 allocations)

That run created a new class loader and a proxy class through it, over and over, keeping every loader referenced — about 5,000 classes before a 32 MB Metaspace limit was reached. In a real application server, the diagnosis is a heap dump: find instances of the application's class loader, expect exactly one, and follow the path to GC roots from any extra ones.

Progress is saved on this device and to your account when signed in.