Lesson 01 / 11
Class Loading
The class loader hierarchy runs on delegation: the class a loader is asked for is, in most cases, defined by the loader above it, and identity is read only through `==`. Three of four triggers run class initialization; one — reading a compile-time constant — never does. When two threads touch the same class at the same time, only one of them runs the initialization block; the other waits for it to finish.
Published
In this lesson
- Distinguishes the loader that requests a class from the loader that defines it, in the class loader hierarchy, through identity comparison
- Shows by counting which actions trigger a class's initialization and which do not
- Measures that loading and initialization are two separate events, and that initialization runs in only one of two threads that touch the same class at the same time
Contents
The Standard Library and Streams course closed its last lesson with a guarantee: a single
thread no longer stood behind a behavior, because multiple threads now reached the same
object at the same time. This course asks exactly what that guarantee is, but first there
is a smaller question that still cannot be answered by reading the code alone: which
thread runs this line? The question starts before the first line we write. Even before
main begins running, a class has to enter its runtime form — it has to be loaded and
initialized — and a thread does that work too: the thread that first touches the class.
This lesson separates two questions. First: when a class is requested, which loader defines it? Second: which thread runs the initialization block, and what happens to a second thread that touches the same class at the same time?
The Class Loader Hierarchy and Delegation
When a class is requested, the JVM does not ask a single loader for it; it asks a chain of
loaders. Every loader in the chain has a parent, and at the top of the chain stands the
JVM’s bootstrap class loader — this loader is not represented as a Java object, and a call
to getClassLoader() returns null for it. The loader a class is requested from (the
initiating loader) first hands its own work to its parent: it asks its parent, and the
parent asks its own parent in turn. If some loader in the chain can actually define the
class — if it can make the defineClass call — that loader becomes the class’s defining
loader. This is the principle of delegation, and it is the default behavior of the
ClassLoader.loadClass method: it is inherited without writing a single line, just by
naming a loader’s parent.
The result of delegation can split the requester from the definer: a class is asked of one
loader, but if the parent finds and defines it, the initiating loader and the defining
loader are not the same object. This difference is invisible in how the call is written —
child.loadClass("X") is the same line under every outcome; which loader defined the
class is visible only by comparing the returned Class object’s getClassLoader() with
==.
- JV1 — Loader identity is read only through
==comparison; the loader’s name, identity hash, ortoStringoutput is never printed anywhere. - JV2 — The compiler is invoked from inside the program through the standard library’s tool provider interface; two small classes are compiled into two separate directories, and no source outside those two directories enters the measurement.
- JV3 — The parent loader in this measurement has no parent of its own; what is measured is not how far up the chain climbs, but which layer turns out to be the definer.
// Delegating.java — a class loader that runs on delegation: findClass is overridden, loadClass is inherited
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
class Delegating extends ClassLoader {
private final Path directory;
Delegating(Path directory, ClassLoader parent) {
super(parent);
this.directory = directory;
}
protected Class<?> findClass(String name) throws ClassNotFoundException {
try {
byte[] bytes = Files.readAllBytes(directory.resolve(name + ".class"));
return defineClass(name, bytes, 0, bytes.length);
} catch (IOException e) {
throw new ClassNotFoundException(name);
}
}
}
Delegating only redefines findClass; loadClass is never touched. The bytecode for the
Shared class sits only in the directory the parent loader looks at, and the bytecode for
Custom sits only in the directory the child looks at. The two directories thus stand for
two separate sources, each visible to a different loader.
// Loaders.java — the initiating loader and the defining loader are told apart by identity comparison
import java.io.PrintWriter;
import java.io.Writer;
import java.nio.file.*;
import java.util.spi.ToolProvider;
public class Loaders {
static void compile(String source, String className, Path directory) throws Exception {
Path d = Files.createTempDirectory("source");
Path f = d.resolve(className + ".java");
Files.writeString(f, source);
Files.createDirectories(directory);
PrintWriter discard = new PrintWriter(Writer.nullWriter());
ToolProvider.findFirst("javac").orElseThrow()
.run(discard, discard, "-d", directory.toString(), f.toString());
}
public static void main(String[] args) throws Exception {
Path parentDir = Files.createTempDirectory("parent");
Path childDir = Files.createTempDirectory("child");
compile("class Shared { static int value() { return 1; } }", "Shared", parentDir);
compile("class Custom { static int value() { return 2; } }", "Custom", childDir);
ClassLoader parent = new Delegating(parentDir, null);
ClassLoader child = new Delegating(childDir, parent);
Class<?> c1 = child.loadClass("Shared");
Class<?> c2 = child.loadClass("Custom");
System.out.println("loader that defines Shared : "
+ (c1.getClassLoader() == parent ? "parent" : c1.getClassLoader() == child ? "itself" : "other"));
System.out.println("loader that defines Custom : "
+ (c2.getClassLoader() == child ? "itself" : c2.getClassLoader() == parent ? "parent" : "other"));
System.out.println("root loader's representation (null) : " + (Object.class.getClassLoader() == null));
}
}
loader that defines Shared : parent loader that defines Custom : itself root loader's representation (null) : true
The same child.loadClass(...) call was made twice, and it came back with two different
answers. Shared was asked of the child loader, but the parent was the one that found it —
delegation stopped at the top, and the definer turned out to be parent, not child.
Custom was not in the directory the parent loader looks at; once delegation failed, the
request fell back to the child, and this time the definer was the child itself. The only
difference between the two was that the second class’s bytecode was never present at the
parent — nothing in the code branches on this. The call to Object.class.getClassLoader()
returned null: at the very top of the chain sits the bootstrap class loader, represented
by no Java object at all.
Which Thread Runs Class Initialization
Loading — finding a class file and building its runtime representation — and initialization — giving static fields their first values and running the initialization block — are two separate events, and they can happen at two separate moments. The JVM does not initialize a class the moment it is requested, but the moment it is actually about to be used, and it initializes it exactly once. If two threads touch the same class at the same time, one of them runs the initialization block; the other waits for it to finish — the order is not left to the scheduler, this is a guarantee of the language itself, enforced inside the JVM with an implicit lock.
Which actions actually initialize a class is also invisible from how the call is written: three of four candidate actions trigger initialization, and one never does.
- JV4 — Whether a class has initialized is tracked with a counter we build ourselves; the counter only advances when the initialization block’s body actually runs, not when the class merely loads.
- JV5 — The four triggers are each tried on a separate class, because a class initializes at most once; testing the same class with two triggers would make the second attempt meaningless.
- JV6 — Two threads touching the same class at the same time is arranged with two
CountDownLatchinstances: one guarantees that both are ready, the other guarantees that both are released at the same moment; the order between them is not left to the scheduler. - JV7 — Both racing threads are registered under the same label (“created”); which one wins is not the subject of this measurement — whether the winner is registered at all is.
// Init.java — which thread runs class initialization: tracked with our own record
import java.util.*;
import java.util.concurrent.atomic.AtomicInteger;
class Init {
static final AtomicInteger counter = new AtomicInteger(0);
static final List<String> seen = Collections.synchronizedList(new ArrayList<>());
static final Map<Thread, String> labels = Collections.synchronizedMap(new IdentityHashMap<>());
static void register(Thread t, String label) { labels.put(t, label); }
static String identify() {
String l = labels.get(Thread.currentThread());
return l == null ? "unknown" : l;
}
static void observe(String trigger) {
counter.incrementAndGet();
seen.add(trigger);
System.out.printf("%-30s initializer running: %s%n", trigger, identify());
}
}
The four triggers are written into four separate classes; each class’s initialization
block only calls Init.observe.
// NewInstanceTrigger.java — does creating a new instance trigger initialization
class NewInstanceTrigger { static { Init.observe("new instance creation"); } }
// StaticMethodTrigger.java — does calling a static method trigger initialization
class StaticMethodTrigger { static { Init.observe("static method invocation"); } static void method() { } }
// StaticFieldTrigger.java — does reading a non-final static field trigger initialization
class StaticFieldTrigger { static { Init.observe("static field read"); } static int value = 5; }
// ConstantTrigger.java — does reading a compile-time constant trigger initialization
class ConstantTrigger { static { Init.observe("compile-time constant read"); } static final int CONSTANT = 7; }
A fifth class is used across two measurements: first it shows that a class can sit loaded without being initialized, then that initialization runs exactly once when two threads touch it at the same time.
// LateInitialized.java — first can sit loaded without initializing, then two threads touch it concurrently
class LateInitialized { static { Init.observe("late-initialized class"); } static int value = 9; }
// Triggers.java — how many of the four triggers run initialization, is loading separate from initializing, what does concurrent touch give
import java.util.concurrent.CountDownLatch;
public class Triggers {
public static void main(String[] args) throws Exception {
Init.register(Thread.currentThread(), "caller");
System.out.println("-- four triggers --");
new NewInstanceTrigger();
StaticMethodTrigger.method();
int a = StaticFieldTrigger.value;
int s = ConstantTrigger.CONSTANT;
System.out.println("value read for the constant : " + s);
System.out.println("new instance creation triggered initialization : " + Init.seen.contains("new instance creation"));
System.out.println("static method invocation triggered initialization : " + Init.seen.contains("static method invocation"));
System.out.println("static field read triggered initialization : " + Init.seen.contains("static field read"));
System.out.println("compile-time constant read triggered initialization : " + Init.seen.contains("compile-time constant read"));
System.out.println("initializers run by the four triggers : " + Init.counter.get());
System.out.println("-- load without initializing; then two threads touch it concurrently --");
ClassLoader loader = Triggers.class.getClassLoader();
Class<?> loaded = Class.forName("LateInitialized", false, loader);
System.out.println("loaded only, initializers run so far : " + Init.counter.get());
CountDownLatch ready = new CountDownLatch(2);
CountDownLatch go = new CountDownLatch(1);
Runnable body = () -> {
ready.countDown();
try { go.await(); } catch (InterruptedException e) { }
int x = LateInitialized.value;
};
Thread first = new Thread(body);
Thread second = new Thread(body);
Init.register(first, "created");
Init.register(second, "created");
first.start();
second.start();
ready.await();
go.countDown();
first.join();
second.join();
System.out.println("same Class after both threads touched it : " + (loaded == LateInitialized.class));
System.out.println("total times the initializer ran : " + Init.counter.get());
}
}
-- four triggers -- new instance creation initializer running: caller static method invocation initializer running: caller static field read initializer running: caller value read for the constant : 7 new instance creation triggered initialization : true static method invocation triggered initialization : true static field read triggered initialization : true compile-time constant read triggered initialization : false initializers run by the four triggers : 3 -- load without initializing; then two threads touch it concurrently -- loaded only, initializers run so far : 3 late-initialized class initializer running: created same Class after both threads touched it : true total times the initializer ran : 4
Three of the four candidates actually ran initialization: creating a new instance, calling
a static method, reading a non-final static field. The fourth — reading a compile-time
constant — left no line at all; ConstantTrigger’s initialization block never ran, yet the
value read (7) came back correct. This line is taken up separately below.
The second block separates loading from initialization. Class.forName("LateInitialized", false, ...) loaded the class — it returned a Class object — but the counter stayed at
3: the initialization block did not run. Loading can complete before initialization and
independently of it.
The third block sends two threads at the same class at the same time. The ready latch
guarantees both are at the starting line; the go latch guarantees both are released at
the same moment; which one reaches the read of LateInitialized.value first depends on
the scheduler and is not known. The result, though, is deterministic: the initializer ran
exactly once in total (the counter went from 3 to 4, not to 5), and the thread
that ran it was one of the two registered threads — not unknown. The second thread also
read LateInitialized.value, but it did not run its own initialization block; it hit the
JVM’s implicit lock and waited for the first to finish. Which thread wins can change from
run to run, but the fact that the winner is a registered thread, and that initialization
runs exactly once, does not — which is why the lesson registers both threads under the
same label (JV7).
A Bounding Measurement: A Compile-Time Constant Class Never Initializes
The fourth row in the table above was empty: ConstantTrigger‘s initialization block
never ran, and Init.observe was never called. The reason lies in how the CONSTANT
field is declared: static final int CONSTANT = 7; is a compile-time constant, and the
compiler replaces every place that reads such a field with the constant’s own value — the
value inlining the Object-Oriented Java course measured. The line int s = ConstantTrigger.CONSTANT; leaves no trace of ConstantTrigger in the class file at all;
the value 7 is inlined directly at the call site. Since the class is never touched, there
is nothing to initialize either.
This is the same blind spot of the shared measurement framework, rebuilt inside this lesson: an empty line does not mean “initialization did not happen” — it means “the trigger never arose.” A measurement cannot conclude “the mechanism does not work” merely from the absence of an expected line; it first has to confirm that the action meant to produce that line actually took place. Here the action (reading a field) did take place, but what got read was not the class — it was the number the compiler put in the class’s place. The absence of an observation is not the absence of a mechanism.
The only difference between StaticFieldTrigger and ConstantTrigger is a single final
keyword; both hold a field of type int, and both are read by the same kind of trigger
line. Yet one initializes its class while the other never touches it at all — what decides
this is not the field’s type or value, but whether its value is known at compile time.
This distinction comes from the same source as the constant folding measured in the first
lesson of the Java Fundamentals course: the compiler leaves no trace at runtime anywhere it
can compute the result ahead of time.
Summary
- The class loader hierarchy runs on delegation: the loader a class is requested from asks its
parent first, and if the parent finds the class, the parent becomes the definer;
identity is read only through
==— no name or number is ever printed. - The bootstrap class loader is not represented as a Java object;
getClassLoader()returnsnullfor it. - Loading and initialization are two separate events: a class can be loaded without its initialization block ever running.
- Three of the four triggers (a new instance, a static method call, reading a non-final static field) run class initialization; reading a compile-time constant does not, because the value is inlined at the call site and the class is never touched.
- When two threads touch the same class at the same time, only one of them runs the initialization block; the other hits the JVM’s implicit lock and waits for it to finish. Which thread wins can change from run to run, but it is always a registered thread, and initialization runs exactly once in total.
- If a measurement’s expected line is missing from the output, the first thing to check is whether the action meant to trigger it actually occurred; an empty line can mean “not triggered” rather than “did not happen.”
Next Step
This lesson measured whose hands a class enters its runtime through: which loader defines
it, which thread initializes it. But it never asked where the fields of an initialized
class actually sit, or whether a value one thread writes can be seen by another —
LateInitialized.value was read, but whether it saw the value some other thread wrote was
never tested. The next lesson measures exactly that: a local variable, a static field, an
object field, and a thread-local value sit in four separate places, and only some of them
are visible outside the thread that wrote them.
To keep your progress and take notes, Log in
My notes
Log in to take notes.