Class Loading and Initialization — Find the Bug¶
10 buggy snippets, each with the stack trace it produces, the JLS / JVMS rule that explains it, and the fix. Read the code, predict the symptom, decide which rule was misapplied. Then read the explanation. The lessons compound — most production classloading incidents are a small re-mix of these patterns.
Bug 1 — I/O in <clinit> bricks the class¶
public final class RouteRegistry {
public static final Map<String, Route> ROUTES = load();
private static Map<String, Route> load() {
try (var in = Files.newBufferedReader(Path.of("/etc/app/routes.yml"))) {
return YamlParser.parse(in);
} catch (IOException e) {
throw new UncheckedIOException(e);
}
}
}
public class Main {
public static void main(String[] args) {
System.out.println(RouteRegistry.ROUTES.size());
// ... 5 minutes later, after operator notices an exception:
System.out.println(RouteRegistry.ROUTES.size());
}
}
Symptom. First call fails with a startup error, second call fails with a different one:
Exception in thread "main" java.lang.ExceptionInInitializerError
at Main.main(Main.java:3)
Caused by: java.io.UncheckedIOException: java.nio.file.NoSuchFileException: /etc/app/routes.yml
at RouteRegistry.load(RouteRegistry.java:9)
at RouteRegistry.<clinit>(RouteRegistry.java:2)
After the operator drops the file in place and retries, the second call (in the same JVM, if it survived) throws:
Exception in thread "main" java.lang.NoClassDefFoundError: Could not initialize class RouteRegistry
at Main.main(Main.java:5)
Note the second error has no Caused by. The class is permanently in the erroneous state defined by JLS §12.4.2 step 5; no later attempt will retry <clinit>.
Rule. <clinit> runs once per loader. If it throws, the JVM remembers the failure forever — every later access to that class fails with NoClassDefFoundError with the original cause lost.
Fix. Move I/O out of <clinit>. Make the registry a normal object, build it once at the composition root, and pass it where it's needed. Failures become catchable at the call site, with the original IOException intact.
public final class RouteRegistry {
private final Map<String, Route> routes;
public RouteRegistry(Map<String, Route> r) { this.routes = Map.copyOf(r); }
public static RouteRegistry fromYaml(Path p) throws IOException {
try (var in = Files.newBufferedReader(p)) { return new RouteRegistry(YamlParser.parse(in)); }
}
}
Bug 2 — virtual call in constructor sees null subclass fields¶
class Base {
Base() { onInit(); } // (*) called during construction
protected void onInit() { /* default */ }
}
class Derived extends Base {
private final List<String> log = new ArrayList<>();
@Override
protected void onInit() {
log.add("starting"); // NPE: log is still null when Base's ctor runs
}
}
public class Demo {
public static void main(String[] a) {
new Derived();
}
}
Symptom.
Exception in thread "main" java.lang.NullPointerException:
Cannot invoke "java.util.List.add(Object)" because "this.log" is null
at Derived.onInit(Derived.java:5)
at Base.<init>(Base.java:2)
at Derived.<init>(Derived.java:1)
at Demo.main(Demo.java:3)
Rule. JLS §12.5: when new Derived() runs, the JVM allocates the object (all fields zeroed), then <init> of Derived calls super() before running Derived's field initializers. The virtual call onInit() dispatches to Derived.onInit — but log hasn't been assigned yet. This is the same trap as the Fragile Base Class problem ([../../03-design-principles/06-fragile-base-class-problem/](../../03-design-principles/06-fragile-base-class-problem/)), seen through the classloading lens: instance initialization order is not the order you might naively expect.
Fix. Don't call overridable methods from constructors. Either mark onInit final, or move the call out of the constructor into a deliberate start() method.
Bug 3 — same class, two loaders, == fails¶
// In a plugin loader:
PluginClassLoader cl1 = new PluginClassLoader(pluginJar);
PluginClassLoader cl2 = new PluginClassLoader(pluginJar);
Class<?> a = cl1.loadClass("com.acme.plugin.Handler");
Class<?> b = cl2.loadClass("com.acme.plugin.Handler");
System.out.println(a == b); // false
System.out.println(a.isAssignableFrom(b)); // false
Object instanceFromA = a.getDeclaredConstructor().newInstance();
Handler h = (Handler) instanceFromA; // ClassCastException if Handler came from cl2
Symptom.
Exception in thread "main" java.lang.ClassCastException:
class com.acme.plugin.Handler cannot be cast to class com.acme.plugin.Handler
(com.acme.plugin.Handler is in unnamed module of loader
'plugin:plugin.jar' @1234; com.acme.plugin.Handler is in unnamed module of loader
'plugin:plugin.jar' @5678)
The message reads like a typo (same name on both sides). The two parenthetical loader IDs are the giveaway.
Rule. JVMS §5.3: class identity is (defining-loader, name). Two loaders that both define the same class produce two distinct Class<?> instances, even if the bytes are byte-identical.
Fix. Communicate across plugins through a shared type defined by the parent loader — typically a marker interface or DTO in a "plugin API" JAR loaded once at the application loader:
// plugin-api.jar — loaded by the application loader, shared by every plugin loader
public interface Handler { void handle(Request r); }
// In the plugin loader, Handler is asked for and found at the PARENT (parent-first delegation).
// Both plugins see the SAME Handler interface, even though their implementations differ.
If you must compare or cast across loaders, go through serialization or Object + reflection — never direct cross-loader casts of plugin-defined types.
Bug 4 — reflection sets a static field, but <clinit> ordering is wrong¶
public final class FeatureFlags {
public static final boolean DEBUG_MODE = computeDebug();
private static boolean computeDebug() {
return Boolean.parseBoolean(System.getProperty("acme.debug", "false"));
}
}
public class Test {
public static void main(String[] args) throws Exception {
// Test author wants debug mode on, sets the system property after JVM start
Field f = FeatureFlags.class.getDeclaredField("DEBUG_MODE");
f.setAccessible(true);
// Remove the final modifier — pre-Java 17 trick
Field mod = Field.class.getDeclaredField("modifiers");
mod.setAccessible(true);
mod.setInt(f, f.getModifiers() & ~Modifier.FINAL);
f.setBoolean(null, true);
if (FeatureFlags.DEBUG_MODE) System.out.println("debug on");
else System.out.println("debug off"); // !
}
}
Symptom. Prints debug off. Even worse, on JDK 17+, the reflection trick fails with InaccessibleObjectException because the modifiers field is no longer accessible by default.
Rule. Two layers:
- Compile-time constant inlining (JLS §13.1). Because
DEBUG_MODEisstatic final booleaninitialized to abooleanexpression, the callers compiled againstDEBUG_MODEmay have inlined the valuefalseat the call site. The branchif (FeatureFlags.DEBUG_MODE)may be compiled asif (false)— completely independent of the field's actual value.
Wait — computeDebug() is a method call, not a constant expression, so this particular field is not a compile-time constant. The branch reads the field at runtime. But the next engineer who refactors DEBUG_MODE = false; makes it constant and the reflective trick stops working invisibly.
- JLS §12.4 timing.
<clinit>runs the first time the class is actively used — which, in the snippet, isgetDeclaredField("DEBUG_MODE"). By the time the reflection runs,<clinit>has already executed and setDEBUG_MODEto its computed value. Setting the property after class init has no effect.
Fix. Don't try to override <clinit> results with reflection. If you need runtime configuration, read it from a method, not a static final field. If you must mutate static state in tests, do it before any code path triggers <clinit> — typically by setting the system property before the JVM starts (-Dacme.debug=true), not after.
Bug 5 — ServiceLoader returns no providers in a modular app¶
// module com.example.app
module com.example.app {
requires com.example.api;
// forgot: uses com.example.api.PaymentGateway;
}
public class App {
public static void main(String[] args) {
ServiceLoader<PaymentGateway> loaded = ServiceLoader.load(PaymentGateway.class);
loaded.forEach(g -> System.out.println(g.name()));
// Prints nothing.
}
}
Symptom. Zero providers found. No exception. Provider modules com.example.payments.stripe and com.example.payments.paypal declare provides com.example.api.PaymentGateway with ..., are on the module path, and resolve at compile time.
Rule. JLS §7.7.4: a named module that wants to consume services from ServiceLoader.load(C.class) must declare uses C in its module-info.java. Without uses, the module system reports zero providers — the API treats the consumer as not opting in.
Fix. Add the directive:
Note that this is only required for named modules. Classpath / unnamed-module code finds providers via META-INF/services files and doesn't need uses. A common variant of this bug: project mixes classpath and module-path (e.g. main code is modular, tests are on classpath); the test finds providers but production doesn't.
Bug 6 — Tomcat classloader leak via ThreadLocal¶
public final class RequestContext {
private static final ThreadLocal<UserPrincipal> CURRENT = new ThreadLocal<>();
public static void set(UserPrincipal u) { CURRENT.set(u); }
public static UserPrincipal get() { return CURRENT.get(); }
public static void clear() { CURRENT.remove(); }
}
public class AuthFilter implements Filter {
public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain)
throws IOException, ServletException {
UserPrincipal u = authenticate((HttpServletRequest) req);
RequestContext.set(u);
chain.doFilter(req, res);
// forgot: RequestContext.clear();
}
}
Symptom. Tomcat redeploys app-v1.war → app-v2.war. Metaspace climbs by ~40 MB per redeploy. After 12 redeploys, OOM kills Tomcat. A heap dump shows multiple WebappClassLoader instances, each with a "Path to GC root" of:
WebappClassLoader (webapp v1)
<- contextClassLoader of Thread "http-nio-8080-exec-7"
<- entry of ThreadLocalMap whose KEY is class UserPrincipal (loaded by WebappClassLoader v1)
Rule. ThreadLocal's value is keyed by a WeakReference to the ThreadLocal object, but the value is a strong reference. If the value's class was loaded by a per-app WebappClassLoader, that loader is reachable from the value, from the thread's ThreadLocalMap, from the thread (a long-lived container thread). The loader cannot be GC'd; nor can any class it loaded.
Fix. Always clear() after use, and (defensively) at undeploy:
public void doFilter(...) {
try {
RequestContext.set(u);
chain.doFilter(req, res);
} finally {
RequestContext.clear(); // mandatory
}
}
@WebListener
public class ClearOnUndeploy implements ServletContextListener {
public void contextDestroyed(ServletContextEvent e) {
RequestContext.clear();
// also: deregister any drivers, stop any executors, etc.
}
}
The try/finally solves the per-request leak. The contextDestroyed listener handles threads that may still be pooled across redeploys.
Bug 7 — Circular static initialization¶
public class A {
public static final int X = B.Y + 1;
public static final int Y = 100;
}
public class B {
public static final int Y = A.Y + 1;
}
public class CircularDemo {
public static void main(String[] args) {
System.out.println("A.X=" + A.X + " A.Y=" + A.Y + " B.Y=" + B.Y);
}
}
Symptom. Prints A.X=1 A.Y=100 B.Y=1. The author expected A.X=102 (B.Y + 1 = 101 + 1 = 102) and B.Y=101.
Rule. JLS §12.4.2 step 2. The chain:
mainreadsA.X→ triggersA's<clinit>.A.X = B.Y + 1→ triggersB's<clinit>.B.Y = A.Y + 1.Ais currently being initialized by this thread → step 2 returns immediately.A.Yis read at its default value0. SoB.Y = 0 + 1 = 1.B's<clinit>completes. Back inA:A.X = 1 + 1 = 2. (Wait — let's re-check the example.B.Y = 1, soA.X = B.Y + 1 = 2.)A.Y = 100runs next.- Output is
A.X=2 A.Y=100 B.Y=1.
(Adjust your prediction: the bug is the silent 0, regardless of the exact arithmetic.)
Fix. Eliminate the cycle. Extract shared constants into a third class that neither depends on the others, or compute the value lazily:
public class Constants {
public static final int Y = 100;
}
public class A { public static final int X = Constants.Y + 1; } // 101
public class B { public static final int Y = Constants.Y + 1; } // 101
The cycle never forms because no class needs another class's state during its own <clinit>.
Bug 8 — final non-primitive constant triggers initialization¶
public class TaxRates {
static { System.out.println("TaxRates <clinit>"); }
public static final int STANDARD_INT = 20; // compile-time constant
public static final Integer STANDARD_BOXED = 20; // NOT a compile-time constant
public static final BigDecimal STANDARD_BD = new BigDecimal("0.20");
}
public class Demo {
public static void main(String[] args) {
System.out.println(TaxRates.STANDARD_INT); // prints 20. No <clinit>.
System.out.println(TaxRates.STANDARD_BOXED); // prints 20. <clinit> ran.
System.out.println(TaxRates.STANDARD_BD); // prints 0.20. <clinit> ran (already).
}
}
Symptom. The first read should not trigger <clinit> (so no print), but the second and third should. Output:
Engineers expect all three to be "compile-time constants" because they say static final. They are not.
Rule. JLS §4.12.4 defines a constant variable: a final variable of primitive type or String initialized with a constant expression. Only those are inlined by javac and skip initialization. Integer (boxed) and BigDecimal are not in the set; reading them triggers <clinit> just like any other static.
Fix. This isn't a bug per se — it's a misconception. The implication: do not rely on "constant" being free if the type isn't primitive or String. If you want lazy "constants" that don't drag <clinit> along, the Holder idiom is the right tool.
Bug 9 — <clinit> deadlock between two threads¶
public class A {
static B b = new B();
static int after = 1;
}
public class B {
static {
Thread t = new Thread(() -> {
int x = A.after; // triggers A's <clinit> — but it's already in flight
System.out.println("got " + x);
}, "B-spawned");
t.start();
try { t.join(); } catch (InterruptedException ignored) { Thread.currentThread().interrupt(); }
}
}
public class Deadlock {
public static void main(String[] args) {
new A(); // triggers A's <clinit>, which constructs B
}
}
Symptom. The JVM hangs. jstack shows:
"main" prio=5 ... waiting on object monitor (B's <clinit> calling t.join())
at java.lang.Thread.join(...)
at B.<clinit>(B.java:6)
at A.<clinit>(A.java:2)
"B-spawned" prio=5 ... waiting for class initialization
at A.<clinit>(A.java:?) [waiting for A's init lock — held by "main"]
Two threads, two locks (A's init lock and the new thread's join handle), classic deadlock.
Rule. JLS §12.4.2 step 3: a thread that requests initialization of a class being initialized by another thread blocks on that class's lock. A <clinit> that starts a new thread which then accesses its own currently-initializing class — and waits for that thread — deadlocks.
Fix. Never start threads in <clinit>. If a class needs a background worker, expose a start() method called from the composition root after the class is fully initialized.
Bug 10 — resolution-time NoSuchMethodError after partial deploy¶
// app.jar (compiled against utils.jar v2 which has:)
public class Utils { public static int compute(long x) { return ...; } } // v2
// Production has accidentally deployed utils.jar v1:
public class Utils { public static int compute(int x) { return ...; } } // v1
// app.jar calls:
int r = Utils.compute(42L); // <-- bytecode says invokestatic Utils.compute(J)I
Symptom.
Exception in thread "main" java.lang.NoSuchMethodError:
'int Utils.compute(long)'
at App.main(App.java:5)
No ClassNotFoundException, no NoClassDefFoundError. The class loaded fine; the method resolution failed at first use.
Rule. JVMS §5.4.3: resolution is lazy. app.jar compiles a symbolic reference Methodref(Utils, compute, (J)I). The JVM only resolves it the first time App.main is executed and reaches the invokestatic instruction. The resolution fails because the loaded Utils has compute(I)I, not compute(J)I.
A worse variant: the missing method is in a code path that runs only on Mondays. The bug ships, and you discover it during the Monday batch — six days after deploy, with the deploy long-since "verified" by Tuesday's smoke tests.
Fix. Two angles:
- Defensive deploys. Pin the dependency versions; never let the production classpath drift from what the binary was compiled against. Build-once-deploy-everywhere artifacts (jlink images, Docker images, GraalVM native images) eliminate this class of bug.
- Early detection. Compile-time-only
tools.jar-style binary compatibility checks (japicmp,revapi) flag binary breakage in CI. A pre-deploy smoke test that calls every public entry point once would also fire — but coverage is hard.
Pattern summary¶
| Bug type | Spec rule | Symptom shape |
|---|---|---|
I/O in <clinit> (Bug 1) | JLS §12.4.2 step 10 + step 5 | ExceptionInInitializerError then permanent NoClassDefFoundError |
| Virtual call in constructor (Bug 2) | JLS §12.5 | NPE in subclass method called from super <init> |
| Loader-scoped identity (Bug 3) | JVMS §5.3 | ClassCastException: X cannot be cast to X |
Reflection vs <clinit> timing (Bug 4) | JLS §12.4 + constant inlining (§13.1) | Reflection "doesn't take effect" |
Missing uses for ServiceLoader (Bug 5) | JLS §7.7.4 | ServiceLoader finds zero providers, no error |
| ThreadLocal classloader leak (Bug 6) | JLS §12.4 per-loader statics | Metaspace growth across redeploys |
Circular <clinit> (Bug 7) | JLS §12.4.2 step 2 | Silent default value (0 / null) |
final non-primitive constant (Bug 8) | JLS §4.12.4 | <clinit> runs when you didn't expect it |
<clinit> deadlock (Bug 9) | JLS §12.4.2 step 3 | JVM hangs; jstack shows wait-for-init |
| Lazy resolution (Bug 10) | JVMS §5.4.3 | NoSuchMethodError long after deploy |
The unifying theme: classloading bugs do not produce compile errors. They produce runtime errors whose causes look unrelated to classloading until you know the rule. Each rule in this file maps a stack-trace shape to its underlying spec section — that mapping is what separates "I can read this trace" from "I have no idea what's happening".
Memorize this: <clinit> runs once and a failure is permanent — keep I/O and threads out of it. Class<?> identity is (loader, name) — two loaders mean two classes. Forward references and cycles return default values, not errors. ServiceLoader in named modules needs uses. Resolution is lazy — NoSuchMethodError is the JVM saying "this method should exist, but doesn't, now that I tried to call it". The shape of the stack trace is enough to point at the rule.