Contents

When the JVM first encounters a class reference it goes through three phases:

  1. Loading — find the .class bytes (from JAR, filesystem, network, generated bytecode) and create a Class<?> object in the heap.
  2. Linking
    • Verification — bytecode is structurally valid and safe.
    • Preparation — static fields allocated and zero-initialized.
    • Resolution — symbolic references resolved to concrete references.
  3. Initialization — static initializers and static field assignments run (class's <clinit>).
A class is loaded lazily — the first time it is actively used (instantiation, static field access, static method call, etc.), not at JVM startup.

Java ships with three built-in ClassLoaders arranged in a hierarchy:

// Bootstrap ClassLoader (null in Java — native C++ code) // Loads: java.lang.*, java.util.*, java.io.* — the core JDK modules (java.base, etc.) ClassLoader bootstrap = String.class.getClassLoader(); System.out.println(bootstrap); // null — bootstrap is not represented as a Java object // Platform ClassLoader (Java 9+; was Extension ClassLoader before) // Loads: java.se, javax.*, jdk.* non-core modules ClassLoader platform = ClassLoader.getPlatformClassLoader(); System.out.println(platform); // jdk.internal.loader.ClassLoaders$PlatformClassLoader // Application (System) ClassLoader // Loads: your application classes and classpath JARs ClassLoader app = ClassLoader.getSystemClassLoader(); System.out.println(app); // jdk.internal.loader.ClassLoaders$AppClassLoader // Every user-defined class's loader ClassLoader mine = MyClass.class.getClassLoader(); System.out.println(mine); // usually AppClassLoader (or a custom one in frameworks)

When a ClassLoader is asked to load a class it first delegates to its parent. Only if the parent cannot find the class does it attempt to load it itself. This ensures core JDK classes always come from the bootstrap loader and cannot be overridden by application code.

// Simplified logic inside ClassLoader.loadClass() protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException { // 1. Check if already loaded (cache) Class<?> c = findLoadedClass(name); if (c == null) { try { // 2. Delegate to parent c = (parent != null) ? parent.loadClass(name, false) : findBootstrapClassOrNull(name); } catch (ClassNotFoundException e) { // parent couldn't find it — fall through } if (c == null) { // 3. Try to load it ourselves c = findClass(name); } } if (resolve) resolveClass(c); return c; } Override findClass(), not loadClass(), to stay within the delegation contract. Overriding loadClass() is reserved for cases like child-first loading (OSGi, Tomcat) where the delegation order is intentionally reversed.
// Walk the parent chain ClassLoader cl = Thread.currentThread().getContextClassLoader(); while (cl != null) { System.out.println(cl); cl = cl.getParent(); } // Find where a class was loaded from URL location = MyService.class.getProtectionDomain() .getCodeSource() .getLocation(); System.out.println("Loaded from: " + location); // file:/path/to/app.jar // List all resources named "META-INF/services/..." visible to a classloader Enumeration<URL> resources = ClassLoader.getSystemClassLoader() .getResources("META-INF/services/java.sql.Driver"); while (resources.hasMoreElements()) { System.out.println(resources.nextElement()); } // Get the context classloader (used by many frameworks: JNDI, JDBC, etc.) ClassLoader ctx = Thread.currentThread().getContextClassLoader();

Override findClass() to define where bytecode comes from. The example below loads .class files from an arbitrary directory outside the classpath:

import java.io.*; import java.nio.file.*; public class DirectoryClassLoader extends ClassLoader { private final Path baseDir; public DirectoryClassLoader(Path baseDir, ClassLoader parent) { super(parent); this.baseDir = baseDir; } @Override protected Class<?> findClass(String name) throws ClassNotFoundException { // com.example.Foo → com/example/Foo.class String relative = name.replace('.', '/') + ".class"; Path classFile = baseDir.resolve(relative); if (!Files.exists(classFile)) { throw new ClassNotFoundException(name); } try { byte[] bytes = Files.readAllBytes(classFile); // defineClass converts raw bytes into a Class object return defineClass(name, bytes, 0, bytes.length); } catch (IOException e) { throw new ClassNotFoundException(name, e); } } } // Usage ClassLoader loader = new DirectoryClassLoader( Path.of("/plugins/myPlugin"), ClassLoader.getSystemClassLoader() ); Class<?> clazz = loader.loadClass("com.example.PluginImpl"); Object instance = clazz.getDeclaredConstructor().newInstance();

URLClassLoader is the simplest way to load JARs or directories discovered at runtime — typical for plugin systems.

import java.net.URL; import java.net.URLClassLoader; URL jarUrl = Path.of("/plugins/analytics-plugin.jar").toUri().toURL(); // URLClassLoader is closeable — always close when done to release JAR file handles try (URLClassLoader pluginLoader = new URLClassLoader( new URL[]{ jarUrl }, ClassLoader.getSystemClassLoader())) { // parent loader Class<?> pluginClass = pluginLoader.loadClass("com.analytics.AnalyticsPlugin"); // Cast via a shared interface loaded by the parent/system classloader AnalyticsPlugin plugin = (AnalyticsPlugin) pluginClass.getDeclaredConstructor().newInstance(); plugin.init(); } // After close: pluginClass and instances are still usable but the JAR is unlocked The shared interface (AnalyticsPlugin) must be loaded by a common parent of both the host classloader and the plugin classloader. If both sides load it independently from different loaders, casting will throw ClassCastException even though the class name matches.

Two classes with the same fully qualified name can coexist in the JVM if they are loaded by different ClassLoaders. This is how application servers (Tomcat, JBoss) run multiple webapps with conflicting library versions.

// Load the same class from two different JARs simultaneously URLClassLoader loaderV1 = new URLClassLoader(new URL[]{ v1JarUrl }, bootParent); URLClassLoader loaderV2 = new URLClassLoader(new URL[]{ v2JarUrl }, bootParent); Class<?> v1 = loaderV1.loadClass("com.lib.MyService"); Class<?> v2 = loaderV2.loadClass("com.lib.MyService"); System.out.println(v1 == v2); // false — different Class objects System.out.println(v1.equals(v2)); // false System.out.println(v1.getClassLoader()); // loaderV1 System.out.println(v2.getClassLoader()); // loaderV2 // Instances from different loaders are NOT assignable to each other: // v1Instance instanceof v2Class → ClassCastException

ClassLoader leaks are one of the most common causes of OutOfMemoryError: Metaspace in long-running applications (especially after hot deploys). A leaked ClassLoader keeps its entire loaded class graph alive.

// ❌ Common leak pattern — static field holds a reference to a class // loaded by a child ClassLoader public class Registry { // If SomePluginClass was loaded by a child classloader, // this static reference prevents the child classloader from being GC'd private static final Class<?> CACHED = SomePluginClass.class; } // ❌ Thread locals not cleaned up ThreadLocal<SomePluginClass> tl = new ThreadLocal<>(); tl.set(new SomePluginClass()); // If the thread outlives the classloader and tl.remove() is never called → leak // ✅ Mitigation checklist // 1. Close URLClassLoaders when the plugin is unloaded // 2. Call ThreadLocal.remove() before handing threads back to pools // 3. Deregister JDBC drivers loaded by child classloaders // 4. Clear caches (e.g., BeanInfo, Introspector) on undeploy Introspector.flushCaches(); // 5. Use WeakReference to hold Class objects in caches Map<String, WeakReference<Class<?>>> cache = new WeakHashMap<>(); Use JFR or tools like Eclipse Memory Analyzer (MAT) to detect ClassLoader leaks — look for an ever-growing number of ClassLoader instances in heap dumps after repeated hot-deploys.