luap.in/java
← Index · 02 · JVM JDK 21 · Tomcat 10.1

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.

Extend findClass, not loadClass

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":

1
Bootstrap / JavaSE classes

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.

2
/WEB-INF/classes

Your compiled application classes.

3
/WEB-INF/lib/*.jar

Your bundled dependencies. This is where the inversion bites — a JAR here wins over the same library in the container.

4
System class loader

CATALINA_HOME/bin — bootstrap.jar, tomcat-juli.jar.

5
Common class loader

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.

The triage consequence

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.

ClassNotFoundExceptionNoClassDefFoundError
Typechecked ExceptionError
Raised byClass.forName, loadClass — an explicit, by-name lookupThe JVM resolving a symbolic reference during normal execution
MeansSomething asked for a name that no loader could findIt was there at compile time; it is not loadable now
Usual causeReflection, a JDBC driver name, a plugin class in configMissing 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.

Triage rule

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 sourceWhy it holds the loaderFix
ThreadLocal on a pooled request threadThe thread outlives the app; its map holds your classremove() in a finally, or a ServletRequestListener
JDBC driver registered with DriverManagerDriverManager is loaded by the system loader and holds your driver classDriverManager.deregisterDriver in contextDestroyed
Executor threads not shut downA live thread's context class loader is yoursshutdownNow() in contextDestroyed
JVM shutdown hookHeld by ApplicationShutdownHooks for the JVM's lifeRemove 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.jsp under 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.

Check yourself
  1. Why does Tomcat check WEB-INF/lib before CATALINA_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. WebappClassLoaderBase overrides loadClass to search local repositories first — but JavaSE classes are still checked ahead of everything, so java.lang.* cannot be replaced.
  2. 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?
    Because WEB-INF/lib is searched first, the WAR's bundled copy wins. Prove it with -verbose:class, which prints the JAR each class came from, or at runtime with Foo.class.getProtectionDomain().getCodeSource().getLocation(). This is a standard false negative in dependency triage — always enumerate WEB-INF/lib.
  3. A log line reads NoClassDefFoundError: Could not initialize class Config. Where is the actual cause?
    Earlier in the log, as the first ExceptionInInitializerError on 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.
  4. 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 both WEB-INF/lib and CATALINA_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.
luap.in/java · source Java 21 · Tomcat 10.1.61 · Struts 6 · Spring Boot 3