Contents
- Class Loading Phases
- Bootstrap, Platform & Application Loaders
- Parent-Delegation Model
- Inspecting ClassLoaders at Runtime
- Writing a Custom ClassLoader
- URLClassLoader — Loading JARs at Runtime
- Class Isolation & Multiple Versions
- ClassLoader Memory Leaks
When the JVM first encounters a class reference it goes through three phases:
- Loading — find the .class bytes (from JAR, filesystem, network, generated bytecode) and create a Class<?> object in the heap.
- Linking
- Verification — bytecode is structurally valid and safe.
- Preparation — static fields allocated and zero-initialized.
- Resolution — symbolic references resolved to concrete references.
- 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.