Java Class Loading
How the JVM finds and loads classes with class loaders — bootstrap, platform, system, and custom loaders.
Java Class Loading
Before the JVM can run a single line of your code, it has to find the .class file, read its bytecode, verify it, and turn it into a live Class object in memory. That job belongs to a class loader. Class loading is what makes java.lang.String available without you doing anything, what lets a JAR on the classpath show up at runtime, and what powers plugin systems, application servers, and hot-reload tools. This chapter shows how the loaders are organized, how delegation works, and why a class's identity is more than just its name.
The class-loader hierarchy
Loaders are arranged as a chain of parents, each responsible for a different source of classes. On a modern JDK (9+) there are three built-in loaders:
| Loader | Loads | Reported as |
|---|---|---|
| Bootstrap | Core JDK classes (java.*, javax.* base modules) | null |
| Platform | The rest of the JDK platform modules | a PlatformClassLoader |
| System / Application | Your code from the classpath/module path | an AppClassLoader |
Every class remembers the loader that defined it. You can ask any class which loader produced it:
ClassLoader appLoader = MyApp.class.getClassLoader(); // AppClassLoader
ClassLoader strLoader = String.class.getClassLoader(); // null = bootstrap
ClassLoader parent = appLoader.getParent(); // PlatformClassLoaderThe bootstrap loader is written in native code, not Java, which is why String.class.getClassLoader() returns null rather than an object — there is no Java ClassLoader instance to hand back.
The delegation model
Class loaders follow the parent-first delegation model. When asked to load a class, a loader does not immediately try to find it. It first asks its parent, which asks its parent, all the way up to bootstrap. Only if no ancestor can supply the class does the original loader attempt to define it itself.
// Conceptual shape of ClassLoader.loadClass:
protected Class<?> loadClass(String name, boolean resolve) {
Class<?> c = findLoadedClass(name); // already loaded? reuse it
if (c == null) {
try {
c = parent.loadClass(name); // delegate UP first
} catch (ClassNotFoundException e) {
c = findClass(name); // only now load it myself
}
}
return c;
}This delegation guarantees that core types are loaded once, by the highest loader that can supply them. It is why you cannot override java.lang.String by dropping your own String.class on the classpath — the bootstrap loader claims the name first.
Loading, linking, initialization
Bringing a class to life happens in three phases, and they are not the same thing:
- Loading — read the bytecode and create the
Classobject. - Linking — verify the bytecode is well-formed, prepare static fields with default values, and resolve symbolic references.
- Initialization — run static initializers and static field assignments (the class's
<clinit>method).
The key practical fact: initialization is lazy and happens exactly once. A class is only initialized on first active use — the first new, the first static method call, or the first read of a non-constant static field.
class Config {
static final Map<String, String> SETTINGS = load(); // runs once, on first touch
static Map<String, String> load() {
System.out.println("Config initialized");
return Map.of("env", "prod");
}
}
// "Config initialized" prints only when Config is first actively used.Custom class loaders
You can extend ClassLoader to load classes from anywhere — a database, a network stream, generated bytecode, or an encrypted JAR. The two methods that matter are findClass (locate and define the bytes) and defineClass (hand the raw bytes to the JVM, which returns a Class).
class BytesLoader extends ClassLoader {
private final byte[] bytecode;
BytesLoader(byte[] bytecode) { this.bytecode = bytecode; }
@Override
protected Class<?> findClass(String name) {
return defineClass(name, bytecode, 0, bytecode.length);
}
}URLClassLoader is the built-in version of this idea — point it at JARs or directories and it loads classes on demand:
URL jar = Path.of("plugin.jar").toUri().toURL();
try (URLClassLoader loader = new URLClassLoader(new URL[]{ jar })) {
Class<?> plugin = loader.loadClass("com.example.Plugin");
Object instance = plugin.getDeclaredConstructor().newInstance();
}Class identity: name plus loader
Here is the subtlety that trips people up: a class's runtime identity is its fully-qualified name and the loader that defined it. Load bytes for Widget through two different loaders and you get two distinct Class objects — not equal, not assignment-compatible — even though both came from identical bytecode. This is exactly how application servers isolate two deployed apps that both ship a class called com.acme.Util.
A worked example: loaders, delegation, laziness, and identity
This program needs no external classes — it uses the loaders already present in any JVM. It walks the loader chain, proves core classes come from the bootstrap loader, shows delegation returning the same Class object, watches a static initializer fire lazily and only once, then defines the same hand-built bytecode through two loaders to prove the name-plus-loader identity rule.
What to take from the run:
- The printed loader chain is the live class-loader hierarchy with your code at the bottom:
ClassLoadingDemowas defined by an application-level loader whosegetParent()is the next loader up. Each loader knows only its parent, and the chain always climbs toward bootstrap. String.class.getClassLoader()printsnull, the JVM's way of saying "loaded by the bootstrap loader." Core JDK types always reportnullhere; an object would imply they came from a lower loader, which they never do.app.loadClass("java.lang.StringBuilder") == StringBuilder.classistrue. Delegation sent the request up to the loader that already ownsStringBuilder, so you got back the identicalClassobject, not a duplicate — proof that delegation prevents the core types from being loaded twice.Lazy <clinit> runningprints once, between the--- referencing Lazy now ---marker and the firstLazy.VALUE = 42, and never again on the second read. Initialization is lazy (it waited until first use) and idempotent (the static block runs exactly once per loader).aandbare both namedWidgetyeta == bisfalseanda.isAssignableFrom(b)isfalse. Two loaders defined the same bytecode into two distinct types — concrete proof that runtime class identity is fully-qualified name plus defining loader, the mechanism behind classpath isolation in app servers.
Practice
Two separate custom class loaders each load identical bytecode for a class named 'com.acme.Widget'. What is true of the resulting Class objects a (from loader 1) and b (from loader 2)?