Class Loaders
Class loaders are the JVM subsystem that finds, reads, and turns .class bytecode into live Class objects, using a delegation model that also defines what 'the same class' means.
Before any Java code runs, its bytecode has to get from a .class file (on disk, in a JAR, over a network, or generated dynamically) into the JVM as a usable Class object. That job belongs to a class loader. The JVM ships with a small hierarchy of built-in loaders that cooperate through parent delegation, and applications can supply their own custom loaders for tasks like plugin systems, hot-reloading, or isolating dependency versions. A class's identity in the JVM is tied not just to its fully qualified name but to which loader loaded it — the same bytecode loaded by two different loaders produces two distinct, mutually incompatible classes.
What Class Loading Does
Class loading is the process of locating a class's bytecode, reading it, and constructing an in-memory Class object representing it — including its fields, methods, constant pool, and supertype relationships. It happens lazily and on demand: a class is typically loaded the first time it's actively used (instantiated, statically referenced, or its static initializers must run), not simply because it appears in the classpath.
Loading is distinct from linking (verifying bytecode, preparing static fields, resolving symbolic references) and initialization (running static initializers and static field assignments), which follow loading as separate JVM Specification phases — covered in more depth in the JVM Architecture topic.
The Built-In Loader Hierarchy
A typical modern HotSpot JVM organizes built-in class loaders in a roughly three-tier structure (the exact names and boundaries are JVM-implementation details, though this shape is common and, since Java 9, formalized by JPMS):
loads core JDK classes (java.lang.*, java.util.*) loads other JDK platform modules loads classes from the application classpath/module path
Each loader's 'parent' is listed as pointsTo; this shape is a typical implementation model, not a language-mandated exact structure.
The bootstrap loader is implemented in native code inside the JVM itself and has no Java-level parent (it is represented as null when queried via getClassLoader()). Before Java 9, the tier above application was called the 'extension' loader; JPMS renamed and repurposed this role as the 'platform' loader for JDK modules that are not part of the core bootstrap set.
Parent Delegation
By default, class loaders follow a delegation model: before attempting to load a class itself, a loader first asks its parent to try. Only if every ancestor up to (and including) the bootstrap loader fails to find the class does the original loader attempt to load it itself. This is why user code can never accidentally 'shadow' java.lang.String with its own class of the same name — the request always reaches the bootstrap loader first, which finds the real java.lang.String and returns it before the application loader ever gets a chance.
String.class.getClassLoader() typically prints null, representing the bootstrap loader, which is implemented natively and has no corresponding Java object in many JVM implementations.
Parent delegation is a convention followed by the standard loaders (and expected of well-behaved custom loaders), not an unbreakable JVM guarantee — a custom loader can implement child-first lookup, though doing so is unusual and risks the class-shadowing problems delegation exists to prevent.
public class LoaderDemo {
public static void main(String[] args) {
ClassLoader appLoader = LoaderDemo.class.getClassLoader();
System.out.println("App loader: " + appLoader);
System.out.println("Parent: " + appLoader.getParent());
System.out.println("String's loader: " + String.class.getClassLoader());
}
}Why Delegation Exists: Security and Consistency
Delegation exists primarily for two reasons: it prevents malicious or accidental replacement of trusted core classes (you cannot supply your own java.lang.Object and have it silently used instead of the JDK's), and it ensures a consistent view of core types across an application — every part of a program sees the same java.lang.String class, not competing incompatible copies.
Class Identity: Name Plus Loader
A class's full runtime identity in the JVM is the pair (fully qualified name, defining class loader) — not the name alone. Two Class objects with identical bytecode and the same name, loaded by two different loader instances, are considered different types by the JVM: instanceof checks fail between them, and casting one to the other throws ClassCastException.
This is a frequent source of confusing ClassCastException messages in application-server and plugin environments, where the message literally names the same class twice (e.g. 'com.example.Widget cannot be cast to com.example.Widget') because they were loaded by different loaders.
// Conceptually, given two distinct loader instances loaderA and loaderB
// that both load the same Widget.class bytecode:
Class<?> widgetA = loaderA.loadClass("com.example.Widget");
Class<?> widgetB = loaderB.loadClass("com.example.Widget");
System.out.println(widgetA == widgetB); // false
System.out.println(widgetA.equals(widgetB)); // false
// Casting an instance of widgetB's class to widgetA's type throws
// ClassCastException at run time, even though both came from
// identical bytecode.Custom Class Loaders
Applications can define their own loader by extending java.lang.ClassLoader (or, more commonly, URLClassLoader for JAR/directory-based sources) and overriding findClass to supply the bytecode from a non-standard source — a database, a network resource, an encrypted file, or dynamically generated bytes.
findClass is the extension point a custom loader overrides; loadClass (inherited from ClassLoader) still runs the standard delegate-to-parent-first logic before falling back to findClass. defineClass turns raw bytes into a live Class object.
Common real uses of custom loaders: application servers isolating each deployed web application's classes from one another; plugin systems that load and unload plugin JARs at run time; frameworks that generate bytecode dynamically (proxies, ORMs) and define it directly without ever writing a .class file to disk.
import java.io.*;
import java.nio.file.*;
class FileSystemClassLoader extends ClassLoader {
private final Path baseDir;
FileSystemClassLoader(Path baseDir, ClassLoader parent) {
super(parent); // still participates in delegation
this.baseDir = baseDir;
}
@Override
protected Class<?> findClass(String name) throws ClassNotFoundException {
Path file = baseDir.resolve(name.replace('.', '/') + ".class");
try {
byte[] bytes = Files.readAllBytes(file);
return defineClass(name, bytes, 0, bytes.length);
} catch (IOException e) {
throw new ClassNotFoundException(name, e);
}
}
}loadClass vs. findClass vs. defineClass
These three ClassLoader methods have distinct roles: loadClass(String) is the public entry point implementing the delegation algorithm (check if already loaded, ask parent, then call findClass); findClass(String) is the method custom loaders override to actually locate bytecode for a name the parent chain couldn't resolve; defineClass(...) is a final, protected method that converts a raw byte array into an actual Class object, performing bytecode verification in the process. Custom loaders almost always override findClass, rarely loadClass itself, to avoid breaking delegation.
Unloading Concepts
Classes loaded by the bootstrap, platform, and application loaders are effectively never unloaded for the life of the JVM. A class loaded by a custom loader instance can become eligible for garbage collection only when its defining class loader, every Class it loaded, and every instance of those classes all become unreachable simultaneously — this is why plugin/application-server frameworks that support 'hot unloading' create a fresh loader instance per deployment and must ensure nothing external retains a reference into it (a common source of classloader-related memory leaks when, e.g., a background thread or static field elsewhere holds onto an object from the 'unloaded' loader).
Thread Context Class Loader
Thread.currentThread().getContextClassLoader() exists to solve a delegation limitation: a class loaded by a parent loader (e.g. a core JDK class implementing a service-provider pattern) sometimes needs to load application-level classes it has no direct visibility into, since delegation only lets a loader ask upward, never downward. Frameworks like JDBC and ServiceLoader use the context class loader — set per thread, defaulting to the application loader — as an escape hatch to reach back down to application classes from code that was itself loaded higher up.
Common Pitfalls in Practice
The most frequent real-world symptom of class-loader issues is a ClassCastException or NoClassDefFoundError where the class name genuinely matches but the loader doesn't — typically in application servers hosting multiple deployments, OSGi environments, or when mixing classpath and module path in unexpected ways. Diagnosing it usually means printing getClassLoader() on the relevant Class objects to confirm whether two 'identical' classes actually came from different loader instances.
Key Takeaways
Class loading turns bytecode into a Class object, lazily, typically on first active use — distinct from the later linking and initialization phases.
The built-in hierarchy (bootstrap, platform, application) cooperates through parent delegation: a loader asks its parent before trying itself, preventing core classes from being shadowed.
A class's true identity is (fully qualified name, defining loader) — the same bytecode loaded by two different loaders produces two incompatible classes.
Custom loaders typically override findClass and call defineClass, while inheriting loadClass's delegation behavior.
A custom-loaded class can only become GC-eligible when its loader, its Class objects, and all their instances are all simultaneously unreachable — a common source of classloader leaks in dynamic environments.
Assuming a class is loaded as soon as it's compiled into the classpath.
Understand that loading is typically lazy, happening on first active use of the class, not merely because a .class file exists on the classpath.
Treating loading as eager leads to wrong assumptions about when static initializers run and when ClassNotFoundException/NoClassDefFoundError would actually surface.
Concluding two objects are of incompatible types because a ClassCastException names the 'same' class on both sides.
Check each object's actual defining class loader via getClass().getClassLoader() — the exception message shows the class name only, not the loader.
In multi-loader environments (app servers, plugin systems) the same fully qualified name loaded by two different loaders is legitimately two different, incompatible types.
Overriding loadClass() directly in a custom class loader without preserving delegation.
Override findClass() instead, and let the inherited loadClass() handle parent delegation before falling back to it.
Bypassing delegation risks accidentally shadowing core JDK classes and breaking the consistency guarantees delegation is designed to provide.
What are the three tiers in a typical modern JVM's built-in class loader hierarchy, from top to bottom?
Bootstrap loader (core JDK classes, implemented natively), platform loader (other JDK platform modules), and application/system loader (application classpath/module path classes).
Explain parent delegation in one sentence.
Before attempting to load a class itself, a class loader first asks its parent to try, all the way up to the bootstrap loader, and only loads the class itself if every ancestor fails to find it.
Why can't application code accidentally replace java.lang.String with its own class of the same name?
Because of parent delegation: any request to load 'java.lang.String' is delegated up to the bootstrap loader first, which finds and returns the real java.lang.String before the application loader is ever consulted.
What determines whether two Class objects with the same fully qualified name are considered 'the same class' by the JVM?
Both the fully qualified name AND the defining class loader must match — the same name loaded by two different loader instances produces two distinct, incompatible classes.
Which method should a custom ClassLoader typically override — loadClass or findClass — and why?
findClass, so the inherited loadClass keeps performing standard parent delegation, only falling back to the custom findClass logic when the parent chain can't find the class.
Under what condition can a class loaded by a custom class loader become eligible for garbage collection?
Only when its defining class loader instance, every Class object it loaded, and every instance of those classes are all simultaneously unreachable.
What problem does the thread context class loader solve?
It lets code loaded by a parent loader (e.g. a JDK service-provider mechanism) reach back down to load application-level classes it otherwise has no visibility into, since ordinary delegation only allows asking upward toward parents.
Write code that prints the class loader for a user-defined class and for java.lang.Object, illustrating the loader hierarchy.
public class LoaderPrint { public static void main(String[] args) { System.out.println("LoaderPrint's loader: " + LoaderPrint.class.getClassLoader()); System.out.println("Object's loader: " + Object.class.getClassLoader()); } }
LoaderPrint's loader typically prints the application (system) class loader; Object's loader typically prints null, representing the natively implemented bootstrap loader, which has no direct Java-object representation.
What does this print, and why?
It prints false. Even though both loaders load identical bytecode for com.example.Widget, they are separate loader instances, so the two Class objects are treated as distinct incompatible types — an instance created via loaderA's class is not considered an instance of loaderB's class.
Write a minimal custom ClassLoader that loads .class bytes from a byte array already in memory (e.g. received over a network), using defineClass.
class InMemoryClassLoader extends ClassLoader { InMemoryClassLoader(ClassLoader parent) { super(parent); } Class<?> defineFromBytes(String name, byte[] classBytes) { return defineClass(name, classBytes, 0, classBytes.length); } } defineClass is protected and final in ClassLoader; a subclass exposes it (here through a wrapper method, defineFromBytes) to convert raw bytecode obtained from any source into a live Class object, with the JVM performing bytecode verification as part of the call.
ClassLoader loaderA = new java.net.URLClassLoader(new java.net.URL[]{ /* jar path */ }, null);
ClassLoader loaderB = new java.net.URLClassLoader(new java.net.URL[]{ /* same jar path */ }, null);
Class<?> a = loaderA.loadClass("com.example.Widget");
Class<?> b = loaderB.loadClass("com.example.Widget");
Object instanceOfA = a.getDeclaredConstructor().newInstance();
System.out.println(b.isInstance(instanceOfA));