Skip to content

Java · JVM Internals

Java Class Loading Explained — ClassLoaders, Delegation, and Metaspace Leaks

9 min read Updated 2026-06-20 Share:

Practice Class Loading interview questions

Why class loading comes up in interviews

Class loading underpins some of the trickiest Java bugs: ClassCastException despite identical class names, Metaspace growth after hot-redeploy, JDBC drivers that silently fail to register, and plugin systems that conflict with the main application. If you understand the ClassLoader model, these failures all become predictable rather than mysterious. That's why senior-level interviews probe it.

When does the JVM load a class?

The JVM loads classes lazily — a .class file is not read until the class is first actively used. "Actively used" means one of:

  • An instance of the class is created with new
  • A static field or method of the class is accessed
  • The class is named in a Class.forName() call
  • The class is a subclass or superinterface of a class that is actively used

This means your application can start even if some optional classes are missing on the classpath, as long as those code paths are never exercised at runtime.

The three built-in ClassLoaders

The JVM ships with a hierarchy of ClassLoaders:

Bootstrap ClassLoader  (native, Java object = null)
        │
        ▼
Platform ClassLoader  (Java 9+, formerly Extension CL)
        │
        ▼
Application ClassLoader  (loads your classpath)

Bootstrap ClassLoader — implemented in native code; no Java object. Loads the core JDK classes from java.base (and other core modules in Java 9+): java.lang.*, java.util.*, java.io.*, etc. This is why String.class.getClassLoader() returns null.

Platform ClassLoader (Java 9+, Extension ClassLoader in Java 8) — loads the remaining JDK named modules (java.sql, java.logging, java.xml, security providers, etc.).

Application ClassLoader — loads classes from the user classpath (-cp / -classpath) or module path. This is ClassLoader.getSystemClassLoader() and is what loads your application code.

System.out.println(String.class.getClassLoader());     // null (Bootstrap)
System.out.println(Connection.class.getClassLoader()); // PlatformClassLoader (java.sql)
System.out.println(MyApp.class.getClassLoader());      // AppClassLoader

The parent-delegation model

Before loading a class, a ClassLoader always delegates to its parent first. If the parent (or its parent) can supply the class, that result is used. The current loader only attempts to load the class if the parent chain returns empty-handed.

Thread asks AppClassLoader for "com.example.MyClass"
  → AppCL delegates to PlatformCL
      → PlatformCL delegates to BootstrapCL
          → Bootstrap: not in java.base → null
      ← PlatformCL: not in platform modules → null
  ← AppCL: not found by parents → AppCL searches classpath → found ✓

Why this matters: it ensures that a java/lang/String.class placed on the classpath cannot shadow the real String. Bootstrap always wins for JDK classes. This is also why you cannot override java.lang.String simply by putting a different version on your classpath — the Bootstrap loader finds the real one first.

The three phases: loading, linking, initialisation

Phase 1 — Loading

The ClassLoader reads the binary .class data from its source (file, JAR, URL, database, generated bytecode) and creates a Class<?> object in Metaspace. The bytecode is stored but not yet verified or executed.

Phase 2 — Linking

Linking has three sub-steps:

Verification — the bytecode verifier checks that the class file is structurally valid and obeys JVM safety rules (no out-of-bounds stack ops, no invalid type conversions). Malformed bytecode is rejected here.

Preparation — memory is allocated for static fields and they are set to their default values (0, null, false). The values you declared in the source code are not assigned yet — that happens in initialisation.

Resolution — symbolic references in the constant pool (class names, field names, method descriptors encoded as strings) are replaced with direct pointers to memory locations. Resolution may be deferred to first use (lazy resolution).

Phase 3 — Initialisation

The class's <clinit> method runs — this is the compiled form of all static initialiser blocks and static field assignment expressions, in source order. It runs exactly once, guarded by a JVM-internal lock, so it is thread-safe even under concurrent access.

class Config {
    static final int MAX;
    static {
        String val = System.getenv("MAX_CONNECTIONS");
        MAX = (val != null) ? Integer.parseInt(val) : 100; // runs in <clinit>
    }
}
// MAX is 0 after Preparation; its real value is set when Config is first used

The guarantee: you will never observe a partially-initialised static field from another thread after the class has finished initialising.

Class identity: name + ClassLoader

A class in the JVM is identified by both its fully qualified name and the ClassLoader instance that loaded it. Two Class<?> objects with the same binary name but different loaders are different types — you cannot cast between them.

URLClassLoader loader1 = new URLClassLoader(urls, parent);
URLClassLoader loader2 = new URLClassLoader(urls, parent);
Class<?> a = loader1.loadClass("com.example.Widget");
Class<?> b = loader2.loadClass("com.example.Widget");

System.out.println(a == b);   // false
Object obj = a.getDeclaredConstructor().newInstance();
b.cast(obj);                  // ClassCastException — same source, different types

This is the mechanism that lets Java EE containers, OSGi frameworks, and plugin systems isolate applications from each other: each gets its own ClassLoader, so their classes never collide.

ClassNotFoundException vs NoClassDefFoundError

These two are frequently confused:

ClassNotFoundExceptionNoClassDefFoundError
KindChecked exceptionError (unchecked)
SourceExplicit call to Class.forName() or ClassLoader.loadClass()JVM resolving a class reference that was present at compile time but absent at runtime
Typical causeMissing JAR, typo in class name stringDeployment error: forgot to include a JAR in the runtime classpath
// ClassNotFoundException — you asked for it by name:
try {
    Class.forName("com.mysql.cj.jdbc.Driver");
} catch (ClassNotFoundException e) { /* MySQL JAR not on classpath */ }

// NoClassDefFoundError — JVM couldn't resolve a static reference:
// MyService references com.example.Dep, which is missing at runtime
MyService svc = new MyService(); // throws NoClassDefFoundError: com/example/Dep

The key: ClassNotFoundException is thrown by your code when you dynamically name a class; NoClassDefFoundError is thrown by the JVM when it fails to resolve a class reference baked into compiled bytecode.

Writing a custom ClassLoader

Override findClass()not loadClass() — to preserve parent delegation:

public class EncryptedJarLoader extends ClassLoader {

    private final Path encryptedJar;

    EncryptedJarLoader(Path jar, ClassLoader parent) {
        super(parent);              // wire up delegation
        this.encryptedJar = jar;
    }

    @Override
    protected Class<?> findClass(String name) throws ClassNotFoundException {
        byte[] plain = decrypt(readEntry(encryptedJar, name));
        if (plain == null) throw new ClassNotFoundException(name);
        return defineClass(name, plain, 0, plain.length);
    }
}

defineClass() passes the raw bytecode to the JVM, which runs verification and preparation, then returns a live Class<?> object.

Common uses: loading classes from encrypted JARs, generated bytecode (ASM, ByteBuddy), databases, remote URLs, or creating isolated namespaces for plugin architectures.

ServiceLoader — the standard plugin mechanism

For discovering and loading interface implementations at runtime, prefer java.util.ServiceLoader over rolling your own Class.forName() dispatch:

# src/main/resources/META-INF/services/com.example.Plugin
com.example.impl.FooPlugin
com.example.impl.BarPlugin
ServiceLoader<Plugin> loader = ServiceLoader.load(Plugin.class);
for (Plugin p : loader) {
    p.execute();
}

JDBC 4.0+ drivers self-register via ServiceLoader; you no longer need Class.forName("com.mysql.cj.jdbc.Driver"). In Java 9+ modules, the equivalent is provides com.example.Plugin with com.example.impl.FooPlugin in module-info.java.

Class unloading and Metaspace leaks

A class can only be unloaded when its ClassLoader becomes unreachable. JDK classes (loaded by Bootstrap) are never unloaded. Application classes (loaded by AppClassLoader) are permanent for the JVM's lifetime. Only classes loaded by a custom ClassLoader can be unloaded — when the loader itself is GC'd.

This is the source of Metaspace leaks in hot-deploy scenarios. Every time you redeploy a web app in Tomcat without restarting the JVM, a new ClassLoader is created. If anything outside the old ClassLoader still holds a reference to it (or to any object of a class it loaded), the old loader cannot be GC'd — Metaspace grows with each deploy.

Common anchor points:

  • ThreadLocals — a value whose class was loaded by the webapp ClassLoader, stored in a server thread's ThreadLocal, never cleared on undeploy.
  • Static fields in shared libraries — a library loaded by the parent ClassLoader holds a reference to a webapp object (e.g., a listener registered in a static list).
  • JDBC drivers — registered globally with DriverManager but never deregistered.
  • Logging configuration — log4j2/logback holding class references.
// ServletContextListener — cleanup on undeploy:
@Override
public void contextDestroyed(ServletContextEvent sce) {
    // Deregister JDBC drivers loaded by this webapp:
    Enumeration<Driver> drivers = DriverManager.getDrivers();
    while (drivers.hasMoreElements()) {
        try { DriverManager.deregisterDriver(drivers.nextElement()); }
        catch (SQLException ignored) {}
    }
    // Clear ThreadLocals your code set on server threads
}

Detect Metaspace leaks by enabling -XX:NativeMemoryTracking=summary and running jcmd <pid> VM.native_memory summary before and after redeploy. A growing "Class" section confirms a ClassLoader leak. Confirm with a heap dump: look for ClassLoader instances that should be dead and trace their retaining references in Eclipse MAT.

The Initialization-on-demand Holder idiom

The class initialisation guarantee (runs once, thread-safe) enables a clean lazy-singleton pattern without explicit locks:

public final class ConnectionPool {
    private ConnectionPool() {}

    private static final class Holder {
        static final ConnectionPool INSTANCE = new ConnectionPool();
    }

    public static ConnectionPool getInstance() {
        return Holder.INSTANCE;
    }
}

Holder is not loaded until getInstance() is first called. The JVM's <clinit> lock ensures INSTANCE is created exactly once, safely. No synchronized, no volatile.

Java 9 module system changes

Java 9 split rt.jar into named JDK modules. Class loading mechanics (parent delegation, defineClass) are unchanged, but:

  • Bootstrap loads only java.base (and a few low-level modules), not all of rt.jar.
  • Platform ClassLoader loads remaining JDK modules.
  • Strong encapsulation: a module must exports a package for code outside the module to access it. Reflection into non-exported packages is blocked by default.
# Frameworks that rely on deep reflection need --add-opens:
java --add-opens java.base/java.lang=ALL-UNNAMED \
     --add-opens java.base/java.lang.reflect=ALL-UNNAMED \
     -jar myapp.jar

ALL-UNNAMED grants access to all code on the unnamed module path (the classpath). This is a compatibility bridge for libraries not yet modularised.

Recap

Java class loading is lazy — classes are read, verified, and initialised only when first actively used. The Bootstrap → Platform → Application ClassLoader hierarchy enforces parent delegation, ensuring JDK classes always take precedence. Loading proceeds through loading → linking (verify, prepare, resolve) → initialisation; static fields get their real values only in initialisation. A class's identity is (name, ClassLoader) — the same bytecode loaded by two different loaders produces incompatible types. Custom ClassLoaders enable plugins, encryption, and hot-reload by overriding findClass(). Classes can only be unloaded when their ClassLoader is GC'd; failing to clear ThreadLocals, event listeners, or JDBC drivers on undeploy causes Metaspace leaks that grow with each hot-deploy cycle.

More ways to practice

The self-quiz is live. Get notified when mock interviews and new question packs drop.

or
Join our WhatsApp Channel