Class loading, and why Tomcat inverts it
Most confusing Tomcat failures are class-loading questions wearing
a costume. A WAR that deploys but 404s, a ClassCastException where the
two type names are identical, a library that is patched in the container but
vulnerable at runtime — all three are the same question about which loader
answered.
01The loader hierarchy
A class loader is an object that maps a binary name to a
Class. Loaders form a parent chain — not an inheritance hierarchy, a
delegation chain held in a field.
// Java 9+ hierarchy. The Extension loader was renamed Platform in 9. Bootstrap java.base and the rest of the JDK modules │ written in native code; represented as null ▼ Platform jdk.* modules not in java.base │ (was "Extension", loaded lib/ext before Java 9) ▼ Application your -cp / -classpath, the "system" loader │ ClassLoader.getSystemClassLoader() ▼ custom URLClassLoader, Tomcat's WebappClassLoader, OSGi…
Verify it on any JVM:
$ cat > L.java <<'J' public class L { public static void main(String[] a) { System.out.println(L.class.getClassLoader()); System.out.println(String.class.getClassLoader()); // null = bootstrap System.out.println(L.class.getClassLoader().getParent()); } } J $ javac L.java && java L jdk.internal.loader.ClassLoaders$AppClassLoader@2f0e140b null jdk.internal.loader.ClassLoaders$PlatformClassLoader@73d16e93
String.class.getClassLoader() returning null is not an
error. The bootstrap loader is native and has no Java object, so null
is its representation. Code that checks if (loader != null) before
using it is handling this, not being defensive about a bug.
02The delegation algorithm
This is the actual shape of java.lang.ClassLoader.loadClass. Reading
it answers most "why did it load that one" questions directly.
protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException { synchronized (getClassLoadingLock(name)) { // 1. Already loaded by THIS loader? Each loader has its own record. Class<?> c = findLoadedClass(name); if (c == null) { try { // 2. Ask the parent first. This is "parent delegation". if (parent != null) c = parent.loadClass(name, false); else c = findBootstrapClassOrNull(name); } catch (ClassNotFoundException e) { // parent could not find it — swallowed on purpose } // 3. Only now look in our own repositories. if (c == null) c = findClass(name); } if (resolve) resolveClass(c); return c; } }
Parent-first exists for one reason: it makes core types
un-overridable. A JAR on your classpath containing its own
java/lang/String.class is never consulted, because the bootstrap
loader answers first. Without delegation, any dependency could replace
String or SecurityManager and the whole type system
would be negotiable.
A custom loader is meant to override findClass, which is called at
step 3 after delegation has already failed. Overriding loadClass
instead replaces the delegation policy itself — which is exactly what Tomcat does,
deliberately, and what most other code does by accident.
03Tomcat's inversion
A servlet container has a requirement the standard model cannot meet: two WARs in one JVM must be able to use different, incompatible versions of the same library. Strict parent-first would mean the first version loaded wins for everybody.
So WebappClassLoaderBase overrides loadClass and checks
its own repositories before delegating. The documented effective
order, with the default delegate="false":
Always first, and not negotiable. This guard is what stops a WAR from shipping
its own java.lang.*. Note it is checked before the local
repositories — the inversion does not extend to core types.
/WEB-INF/classes
Your compiled application classes.
/WEB-INF/lib/*.jar
Your bundled dependencies. This is where the inversion bites — a JAR here wins over the same library in the container.
CATALINA_HOME/bin — bootstrap.jar, tomcat-juli.jar.
CATALINA_HOME/lib — the servlet API, JDBC drivers you installed
for the container, anything shared across applications.
Setting <Context delegate="true"> restores parent-first
(bootstrap, system, common, then the webapp). Use it to diagnose a conflict, rarely
to fix one.
Step 3 beating step 5 means patching a library in
CATALINA_HOME/lib does nothing if a vulnerable copy is also in
WEB-INF/lib. The application's bundled version is what loads.
This is a recurring false-negative in dependency triage: the operator patched the
container, the scanner checked the container, and the running code is the old one.
Always enumerate WEB-INF/lib — and check for duplicates:
$ unzip -Z1 app.war 'WEB-INF/lib/*' | sed 's|.*/||' \ | sed -E 's/-[0-9].*\.jar$//' | sort | uniq -d # any output = two versions of one library in the same WAR
Proving which copy won
# Tomcat logs every class load and the source it came from $ CATALINA_OPTS="-verbose:class" ./bin/catalina.sh run \ | grep -i 'commons-collections' [Loaded org.apache.commons.collections.map.LazyMap from file:/opt/tomcat/webapps/app/WEB-INF/lib/commons-collections-3.2.1.jar] ^ the WAR's copy, not the container's # At runtime, from inside the application: System.out.println(LazyMap.class.getProtectionDomain() .getCodeSource().getLocation());
04ClassNotFoundException vs NoClassDefFoundError
These are not synonyms and the difference tells you where to look.
ClassNotFoundException | NoClassDefFoundError | |
|---|---|---|
| Type | checked Exception | Error |
| Raised by | Class.forName, loadClass — an explicit, by-name lookup | The JVM resolving a symbolic reference during normal execution |
| Means | Something asked for a name that no loader could find | It was there at compile time; it is not loadable now |
| Usual cause | Reflection, a JDBC driver name, a plugin class in config | Missing JAR at runtime — or a failed static initialiser |
The variant that hides its own cause
NoClassDefFoundError has a second form that is responsible for a
great deal of wasted debugging:
public class Config { static final String SECRET = System.getenv("SECRET").trim(); // getenv returns null when unset -> NPE inside <clinit> }
// First access: java.lang.ExceptionInInitializerError Caused by: java.lang.NullPointerException <-- the real cause // Every subsequent access, including from other threads: java.lang.NoClassDefFoundError: Could not initialize class Config ^^^^^^^^^^^^^^^^^^ no cause attached
Once a class's static initialiser throws, the JVM marks it erroneous permanently. Every later attempt gets the second message, with the original exception nowhere in sight.
When you see "Could not initialize class", the stack trace in
front of you is not the bug. Search the log backwards for the first
ExceptionInInitializerError — often minutes earlier, often on a
different thread, often during startup. Under load it may have been logged before
the request you are investigating even arrived.
05Class identity is loader-scoped
A runtime type is identified by (binary name, defining loader), not by name alone. Two loaders loading the same bytes produce two distinct, mutually incompatible types.
java.lang.ClassCastException: class com.example.Contact cannot be cast to
class com.example.Contact (com.example.Contact is in unnamed module of loader
'app'; com.example.Contact is in unnamed module of loader
org.apache.catalina.loader.ParallelWebappClassLoader @1b6d3586)
The message looks absurd until you read the parenthetical: same name, two
loaders, two types. It appears when a class sits in both
WEB-INF/lib and CATALINA_HOME/lib and instances cross
between them — typically through a shared session, a JNDI resource, or a
ThreadLocal.
The related LinkageError: loader constraint violation is the same
root cause caught earlier, when the verifier notices two loaders disagree about a
type in a method signature.
The fix is never a cast. It is to decide which loader owns the
class and remove the other copy — usually by marking the dependency
provided in Maven so it stops being bundled:
<dependency> <groupId>jakarta.servlet</groupId> <artifactId>jakarta.servlet-api</artifactId> <version>6.0.0</version> <scope>provided</scope> <!-- compile against it, do NOT package it --> </dependency>
Shipping the servlet API inside WEB-INF/lib is the single most
common version of this mistake. The container must own that API; a bundled copy
produces exactly the cast error above, or a silent 404 when the container's
HttpServlet is not the one your servlet extends.
06Redeploy leaks
Undeploying a web application should make its WebappClassLoader
unreachable so the garbage collector can reclaim every class it defined. One
reference from outside the application prevents that, and because a loader
strongly references every class it loaded, and every class references its loader,
the whole graph survives.
Tomcat detects several of these and names them on shutdown:
WARNING [main] WebappClassLoaderBase.checkThreadLocalMapForLeaks
The web application [app] created a ThreadLocal with key of type
[com.example.CurrentUser] and a value of type [com.example.User] but failed
to remove it when the web application was stopped. Threads are going to be
renewed over time to try and avoid a probable memory leak.
| Leak source | Why it holds the loader | Fix |
|---|---|---|
ThreadLocal on a pooled request thread | The thread outlives the app; its map holds your class | remove() in a finally, or a ServletRequestListener |
JDBC driver registered with DriverManager | DriverManager is loaded by the system loader and holds your driver class | DriverManager.deregisterDriver in contextDestroyed |
| Executor threads not shut down | A live thread's context class loader is yours | shutdownNow() in contextDestroyed |
| JVM shutdown hook | Held by ApplicationShutdownHooks for the JVM's life | Remove the hook on undeploy |
The symptom is OutOfMemoryError: Metaspace after several redeploys
on a long-running server. It is a class-loading bug, not a heap-sizing problem, and
raising -XX:MaxMetaspaceSize only changes how many redeploys it
survives.
07Security consequences
Writable WEB-INF is code execution
An attacker who can write a file into a deployed application directory does not need a memory corruption bug. The container will load it:
WEB-INF/classes/…/X.class— loaded by the webapp loader on first reference, ahead of any container copy.WEB-INF/lib/evil.jar— same, for everything inside it.anything.jspunder the context root — compiled to a servlet by Jasper and executed on request. No restart needed.
This is why the file upload and Zip Slip entries treat "writes into the deployment directory" as the decisive question rather than the file extension.
Loader isolation is not a security boundary
Separate WebappClassLoaders give each application its own namespace.
They do not sandbox it. Every application in the JVM shares the
heap, the filesystem, the process credentials and the network identity. Code in one
WAR can reach another's data through any class the common loader holds — a static
cache, a singleton registry, a JNDI resource.
With SecurityManager removed (JEP 411 deprecated it in 17, JEP 486
removed it in 24), there is no in-JVM mechanism left that would stop it.
Treat co-tenancy in one Tomcat as a shared trust zone. If two
applications need isolation, they need separate processes.
Reflection reaches what delegation hides
Delegation controls which class a name resolves to. It does not restrict
what loaded code may do. Class.forName(userInput) lets an attacker
choose any type reachable from any loader in the chain, and
newInstance on it runs a constructor:
Class<?> c = Class.forName(req.getParameter("handler")); Handler h = (Handler) c.getDeclaredConstructor().newInstance();
The cast to Handler happens after the constructor has
already run. Any class with a side effect in its constructor or static initialiser
is reachable.
static final Map<String, Supplier<Handler>> HANDLERS = Map.of( "csv", CsvHandler::new, "json", JsonHandler::new); Supplier<Handler> s = HANDLERS.get(req.getParameter("handler")); if (s == null) throw new IllegalArgumentException(); Handler h = s.get();
The set of constructible types is now finite, declared in source, and reviewable.
Not a fix: prefixing the input with your package name — ..
and nested class syntax escape it, and your own package may contain something
useful to an attacker.
Why does Tomcat check
WEB-INF/libbeforeCATALINA_HOME/lib, when the standard model is parent-first?So two applications in one JVM can use different, incompatible versions of the same library. Strict parent delegation would mean the first version loaded wins for everybody.WebappClassLoaderBaseoverridesloadClassto search local repositories first — but JavaSE classes are still checked ahead of everything, sojava.lang.*cannot be replaced.An operator patched a vulnerable library in
CATALINA_HOME/lib, but the running application still loads the old version. Why, and how do you prove it?BecauseWEB-INF/libis searched first, the WAR's bundled copy wins. Prove it with-verbose:class, which prints the JAR each class came from, or at runtime withFoo.class.getProtectionDomain().getCodeSource().getLocation(). This is a standard false negative in dependency triage — always enumerateWEB-INF/lib.A log line reads
NoClassDefFoundError: Could not initialize class Config. Where is the actual cause?Earlier in the log, as the firstExceptionInInitializerErroron that class — possibly minutes before, on another thread, or during startup. Once a static initialiser throws, the JVM marks the class erroneous permanently and every later access reports this second message with no cause attached. The trace in front of you is not the bug.ClassCastException: class com.example.Contact cannot be cast to class com.example.Contact. Explain it, and say why a cast is not the fix.A runtime type is identified by (binary name, defining loader). The class exists in two loaders — typically bothWEB-INF/libandCATALINA_HOME/lib— producing two incompatible types with the same name. The fix is to remove one copy, usually by marking the dependency<scope>provided</scope>so it stops being packaged.