Topic 14.1
Class Loading
In one line
Before a class can run, a class loader finds its bytes, the JVM links it (verify, prepare, resolve) and finally initialises it by running its static initialisers, exactly once and only on first active use. A class's identity at run time is its name *plus* the loader that defined it.
Think of it like this
A school library. When you ask for a book, the librarian first asks the head office whether they already hold it; only if they don't does she look on her own shelves. Once a book is on loan, she hands out the same copy every time you ask. And two libraries can both have a book called "Atlas" that are still different books. A class loader is that librarian, the head office is its parent loader, the loan record is the loader's cache, and "same title, different library" is why two classes with the same name can be different classes.
Words you'll meet
New words in this topic, in plain English. Come back here whenever one feels fuzzy.
- Class loader
- An object that finds the bytes of a class (from a folder, a JAR, the network) and hands them to the JVM to turn into a class.
- Bootstrap loader
- The loader built into the JVM itself. It loads the core Java classes such as
StringandObject, and appears asnullfrom Java code. - Parent delegation
- The rule that a loader asks its parent for a class before trying to load the class itself.
- Linking
- The middle stage of getting a class ready: checking the bytecode is safe (verification), making room for static fields (preparation) and connecting names to real targets (resolution).
- Initialisation
- Running a class's static field initialisers and static blocks. Happens once, the first time the class is really used.
- <clinit>
- The hidden method the compiler builds from all static initialisers and static blocks of a class. The JVM calls it during initialisation.
- Constant variable
- A
static finalfield of a primitive type orStringwhose value is known at compile time, such asstatic final int MAX = 10;. The compiler copies its value into the code that uses it. - Metaspace
- Native memory (outside the heap) where the JVM keeps class metadata: method bytecode, field layouts, constant pools.
- Class Data Sharing (CDS)
- A JVM feature that saves already-parsed classes to an archive file and maps that file at start-up, so the JVM starts faster.
Step by step
01From .class file to running class
javac writes one .class file per class: a fixed format starting with the magic number 0xCAFEBABE, a version number (61 for Java 17, 65 for Java 21, 69 for Java 25), a constant pool of names and literals, and the bytecode of each method.
When your code first needs a class, the JVM asks a loader for it, then links and initialises it. The diagram shows the order. Verification is the step that makes Java memory-safe even for bytecode that didn't come from javac: it proves, for example, that no instruction pops an int and uses it as an object reference.
02Three loaders, one delegation chain
Ask the app loader for java.sql.Connection and it delegates to the platform loader, which (on Java 9+) knows that the java.sql module is its own and defines it. Ask for java.lang.String and the request travels up to the bootstrap loader. Ask for your Main and both parents say "not mine", so the app loader reads it from the class path.
Since Java 9 the built-in loaders are module-aware: a loader knows which modules (and so which packages) belong to which loader, so for JDK packages it goes straight to the right loader instead of searching blindly (Topic 15.3 covers modules).
// What ClassLoader.loadClass does, simplified from the JDK source
protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException {
synchronized (getClassLoadingLock(name)) {
Class<?> c = findLoadedClass(name); // 1. already loaded by me?
if (c == null) {
try {
c = (parent != null)
? parent.loadClass(name, false) // 2. ask the parent
: findBootstrapClassOrNull(name);
} catch (ClassNotFoundException e) {
// parent couldn't find it
}
if (c == null) c = findClass(name); // 3. look myself
}
return c;
}
}03Watch classes load
The unified logging flag -Xlog:class+load (Java 9+, replacing -verbose:class, which still works) prints every class as it is loaded and where it came from. Even a hello-world loads several hundred JDK classes; most come from the CDS archive (shared objects file) rather than being parsed from scratch.
The output below is sample output: timestamps and counts differ on your machine.
04Exactly when initialisation happens
Loading and initialising are different moments. A class can be loaded (its Class object exists) long before its static initialiser runs. The JLS lists the triggers precisely, and the first runnable example below tests each one.
The surprising case is the constant variable: static final int MAX = 10; is copied by javac into every class that reads it, so reading Config.MAX never touches Config at run time. That's also why changing a constant in a library and recompiling only the library leaves old values in callers that weren't recompiled.
class Config {
static final int MAX = 10; // constant variable: inlined, no init
static final Integer BOXED = 10; // NOT a constant (not primitive/String): reading it initialises
static final int COMPUTED = compute(); // NOT a constant (not known at compile time)
static int compute() { return 10; }
}05Initialisation is thread-safe, which gives a free lazy singleton
If two threads trigger initialisation of the same class at once, the JVM lets one run <clinit> and blocks the other until it finishes (JLS §12.4.2). The holder idiom uses this: the instance lives in a nested class that is initialised only when get() first touches it.
The same lock is a hazard: if <clinit> of class A waits for another thread that is initialising class B, while B's <clinit> needs A, the two threads deadlock inside class initialisation, and a thread dump shows them in RUNNABLE state waiting "on class initialization" (recent JDKs print this in jstack, Topic 14.5).
final class Registry {
private Registry() { }
private static class Holder { // not initialised until get() runs
static final Registry INSTANCE = new Registry();
}
static Registry get() { return Holder.INSTANCE; } // first call initialises Holder, thread-safely
}06When static initialisation fails
If <clinit> throws, the first caller gets ExceptionInInitializerError wrapping the real exception. The class is then marked as erroneous forever: every later use throws NoClassDefFoundError: Could not initialize class X, without the original cause in the message. In production logs you often see only the second error, thousands of times, while the first one (with the real cause) scrolled away at start-up. Recent JDKs (JDK 21, for example) attach a copy of the original error as the NoClassDefFoundError's cause (ExceptionInInitializerError: Exception java.lang.ArithmeticException: / by zero [in thread "main"]) to make this easier, but always search the log for the *first* error.
Don't confuse it with ClassNotFoundException, a checked exception from Class.forName or loadClass when the bytes can't be found at all.
07Custom loaders and the context class loader
You write a loader by extending ClassLoader (or using URLClassLoader) and overriding findClass, so delegation still works. Frameworks that load *your* classes from *their* code (JDBC drivers via ServiceLoader, JNDI, serialization libraries) can't use their own loader, which can't see your classes; they use the thread's context class loader, Thread.currentThread().getContextClassLoader(), which application servers set to the web app's loader.
Some containers invert delegation for web apps ("child-first": look in the app's WEB-INF/lib before the parent) so each app can use its own library versions. That's powerful and is exactly where Foo cannot be cast to Foo errors come from when the same library is visible in two places.
08Faster start-up with CDS
Since JDK 12 the JDK ships a default CDS archive for its own classes and uses it automatically. AppCDS (JDK 10) extends this to application classes: run once to record the classes used, dump an archive, and start later runs with it. JDK 19 added -XX:+AutoCreateSharedArchive, which creates and refreshes the archive automatically.
Try it yourself
- 1
Find the trigger
In "What triggers initialisation", change
static final int MAX = 10;tostatic final Integer MAX = 10;. Predict where>> initialising Confignow appears, then run it. (It moves up to step 1: a boxedIntegeris not a constant variable, so reading it is an active use.)Now replace step 4 with
Class.forName("Main$Config");(the one-argument form). Predict, then run. - 2
Count the classes a program loads
On your own JDK, run
java -Xlog:class+load:file=classes.txt Main.javaand count lines withwc -l classes.txt(or open it). Then search forsource: jrt:/versussource: shared objects file: the first group was parsed from the JDK's module image, the second came pre-parsed from CDS. Run again with-Xshare:offand compare start-up. - 3
Break the cast
In "Same name, different loader", make
Widget's constructor reachable withvar ctor = fromA.getDeclaredConstructor(); ctor.setAccessible(true); Object w = ctor.newInstance();and then tryWidget x = (Widget) w;. Read the fullClassCastExceptionmessage: it names both loaders.
Code & diagrams
Steps 1 to 4 load Config at most, but never initialise it. Reading an inherited static field through Child initialises only the class that declares the field.
Expected output
1. read Config.MAX: 10
2. made a Config[3]
3. class literal: Config
4. Class.forName(name, false, loader) done
5. read Child.counter (declared in Config)
>> initialising Config
6. read Child.own
>> initialising Child
7. call Config.touch() again
sum 2Both loaders read the very same Widget.class file, yet the JVM sees three different Widget classes. Core classes still come from the bootstrap loader, so String is shared.
Expected output
same name: true
same Class object: false
A's Widget == ours: false
A asked again, cached: true
our Widget instanceof A's: false
String via A is the JDK's: true
package-private access: denied (different runtime package)Only the first error names the real cause. Catching Throwable is for the demo only.
Expected output
attempt 1: java.lang.ExceptionInInitializerError
message: null
cause: java.lang.ArithmeticException: / by zero
attempt 2: java.lang.NoClassDefFoundError
message: Could not initialize class Main$Brokenimport java.nio.file.*;
class DirLoader extends ClassLoader {
private final Path root;
DirLoader(Path root, ClassLoader parent) {
super("plugins", parent); // named loader (Java 9+)
this.root = root;
}
@Override
protected Class<?> findClass(String name) throws ClassNotFoundException {
Path file = root.resolve(name.replace('.', '/') + ".class");
try {
byte[] bytes = Files.readAllBytes(file);
return defineClass(name, bytes, 0, bytes.length); // JVM parses + verifies
} catch (java.io.IOException e) {
throw new ClassNotFoundException(name, e);
}
}
}
// Override findClass, not loadClass, so parent delegation keeps working.Break it on purpose
Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.
Break #1
A class on the compile path but not the run path
Compile Main against a library JAR (javac -cp lib.jar Main.java) and then run it without the JAR on the class path (java Main).
Break #2
Defining your own java.lang class
Write package java.lang; public class Hacked { }, compile it, and load it from the class path.
Myth vs fact
Myth
All classes are loaded when the program starts.
Fact
Classes are loaded on demand and initialised on first active use. A large app can run for minutes before touching some of its classes.
Myth
A class name uniquely identifies a class.
Fact
At run time a class is identified by its name plus its defining loader. The same name can exist several times in one JVM.
Myth
Loading a class runs its static block.
Fact
Loading and initialising are separate. Foo.class, Class.forName(name, false, loader) and declaring a Foo[] load without initialising.
Myth
Classes stay in memory forever.
Fact
Classes from custom loaders are unloaded when the loader and all its classes and instances become unreachable. Only classes of the built-in loaders are permanent.
When it breaks
Metaspace grows after every hot redeploy of a web app until OutOfMemoryError: Metaspace.
What you see
The server dies after N redeploys. A heap dump shows several copies of the app's classes, each held by an old WebappClassLoader.
Fix & prevent
Find what pins the old loader (a thread started by the app and never stopped, a ThreadLocal on a pooled thread, a JDBC driver registered in DriverManager, a static cache in a shared library). Stop threads and deregister drivers on shutdown; in the heap dump, follow "path to GC roots" from the old loader (Topic 14.5). Set -XX:MaxMetaspaceSize so the failure is loud and early rather than eating native memory.
Start-up logs one ExceptionInInitializerError and then thousands of NoClassDefFoundError: Could not initialize class ...Config.
What you see
Every request fails; the logs are flooded with the second error, which doesn't name the cause.
Fix & prevent
Search for the first error: its cause is the real problem (a missing environment variable, a bad config file). Avoid heavy work in static initialisers; load config explicitly at start-up and fail fast with a clear message.
Pro corner
Extra depth for experienced readers. New to this? Skip it for now and come back later.
- ▸
invokedynamiccall sites (lambdas, string concatenation since Java 9, records'toString) are linked on first execution by a bootstrap method. Lambdas become hidden classes (Java 15,Lookup.defineHiddenClass), which are not discoverable by name and can be unloaded independently of their loader; they replaced the internalUnsafe.defineAnonymousClass. - ▸
Verification uses StackMapTable attributes (class file version 50+, required from 51) so the verifier type-checks each method in one pass instead of inferring types. Old-style inference verification is only used for old class files.
-Xverify:none/-XX:-BytecodeVerificationRemoteare deprecated since JDK 13; don't use them to "speed up" start-up. - ▸
Class loading is parallel-capable when a loader calls
ClassLoader.registerAsParallelCapable(): locks are taken per class name (getClassLoadingLock) instead of on the whole loader. The built-in loaders are parallel-capable; custom loaders that aren't can become a start-up bottleneck. - ▸
JDK 24 added ahead-of-time class loading and linking (JEP 483): a training run records which classes were loaded and linked, and later runs start with them already in that state, a further step beyond CDS that Project Leyden is building on. Check your JDK's documentation for the exact flags, which have been changing between releases.
Remember this
- 1
A
.classfile is just bytes on disk or in a JAR. Turning it into a usable class happens in three phases that the Java Virtual Machine Specification (JVMS, chapter 5) names precisely. Loading: a class loader finds the bytes and the JVM parses them into an internal class structure (stored in Metaspace, Topic 14.2) and creates thejava.lang.Classobject you can see from Java. Linking: verification checks the bytecode is type-safe and can't corrupt memory, preparation allocates static fields and sets them to default values (0,null,false), and resolution turns symbolic names such asjava/lang/String.length()into direct references, usually lazily, the first time each one is used. Initialisation: the JVM runs the class's<clinit>method, which is all the static field initialisers andstatic { }blocks in source order (Topic 4.6). - 2
Java has three built-in loaders since Java 9. The bootstrap loader is part of the JVM itself (written in C++, so it shows as
nullfrom Java) and loads the core modules such asjava.base. The platform loader loads other Java SE and JDK modules such asjava.sql. The application (system) loader, namedapp, loads your classes from the class path or module path. Before Java 9 the middle one was the extension loader and the class path was the only path; theextandendorseddirectories are gone. - 3
Loaders follow parent delegation:
loadClass(name)first checks its cache, then asks its parent, and only if the parent can't find the class does it look itself. That's why nobody can replacejava.lang.Stringwith their own copy on the class path: the request always reaches the bootstrap loader first, and the JDK'sStringwins. The JVM also refuses to define classes injava.*packages from any loader other than the built-in ones, with aSecurityException("Prohibited package name"). - 4
A class's run-time identity is the pair (binary name, defining loader). Load
com.shop.Orderthrough two different loaders and you get two differentClassobjects whose instances can't be cast to each other: the famousClassCastException: class Order cannot be cast to class Order. Application servers, plugin systems, OSGi and hot-reload tools all rely on this deliberately to keep apps apart. Package-private access also depends on it: two classes are in the same runtime package only if they have the same package name *and* the same loader. - 5
Initialisation is lazy and happens exactly once. The JLS (§12.4.1) lists the triggers: creating an instance, calling a static method, assigning a static field, reading a static field that is not a constant variable, some reflective calls such as
Class.forName(name), and initialising a subclass (which initialises the superclass first). Things that do not trigger it: reading astatic finalcompile-time constant (the compiler copied the value into your code), declaring a variable or an array of the type, usingFoo.class, orClass.forName(name, false, loader). Reading a static field through a subclass (Child.counterwherecounteris declared inParent) initialises onlyParent. The JVM makes<clinit>thread-safe by holding an initialisation lock, which is why the holder-class idiom gives a lazy, thread-safe singleton for free. - 6
Classes are unloaded only when their defining loader becomes unreachable, together with every class it loaded and every instance of them; the garbage collector then frees their Metaspace. Classes from the bootstrap, platform and app loaders are never unloaded. That's why a leaked loader (a web app redeployed while an old thread or
ThreadLocalstill holds one of its objects) leaks *all* of its classes, and why Metaspace can fill up after many redeploys. Start-up cost is real too: loading, verifying and linking thousands of classes is a large part of JVM start-up, which Class Data Sharing (CDS) attacks by mapping pre-parsed classes from an archive file.
Explain it without notes
Walk through what happens between new Order() executing for the first time and the constructor running.
What is parent delegation and why does Java use it?
Why can ClassCastException: com.acme.Order cannot be cast to com.acme.Order happen?
What is the difference between ClassNotFoundException and NoClassDefFoundError?
Practice
Write a program that prints the loader names for Main, java.sql.Connection and java.lang.String (print bootstrap when the loader is null).
Demonstrate the holder idiom: print a line from the holder's static initialiser and show that it runs only when get() is first called, and only once.
Show that a static final String constant is inlined but a static final String computed by a method call is not.
Trade-offs
- ↔
Lazy loading and initialisation keep start-up fast and memory low, but move work (and failures) to the first request that needs a class; warm-up and readiness checks exist partly for this.
- ↔
Separate class loaders isolate apps and allow hot reload and plugin versions side by side, at the cost of cast errors, leaked loaders and more Metaspace.
- ↔
CDS and AOT archives speed up start-up but must be regenerated when the application or JDK changes, and add a build step.
Done when you can
Done when you can name and order the stages: loading, verification, preparation, resolution, initialisation.
Done when you can list what triggers class initialisation and what doesn't, including constant variables.
Done when you can explain parent delegation and the bootstrap, platform and app loaders.
Done when you can explain why the same class name can give a ClassCastException.
Done when you can diagnose NoClassDefFoundError: missing class vs failed static initialiser.