Servlet containers, application servers and plugin frameworks frequently invert the default order so that a component's own loader searches its bundled JARs before asking its parent. Why do they deliberately break parent-first delegation, and what problems does that decision create?
answer
- Sharing vs isolation — no single ordering satisfies both
- Shared allow-list (java.*, boundary API) parent-first; rest child-first
- Duplicate types → ClassCastException naming the same class twice
- Statics duplicated per loader; memory multiplied
- Loader retention on undeploy; parallel-capable to avoid deadlock
basics
~20 sTo isolate library versions: each component gets the exact dependency versions it bundles, rather than whatever the container happens to ship. The cost is duplicate class definitions — the same name defined by several loaders — producing cast failures at boundaries, duplicated static state, and memory retained per component.
solid answer
~1 min**Why invert.** With strict parent-first, whatever the container puts on its own class path wins for every component. Two applications needing different major versions of the same library cannot both run, and upgrading the container silently changes every deployed application's dependency set. Child-first delegation makes each component's bundled version win, so components are independently versionable and deployable — the actual goal is isolation, not cleverness. **What it costs.** Once a type can be defined by more than one loader, runtime identity fragments. Passing an instance of a locally-defined type to code that resolved the parent's copy throws `ClassCastException` naming what looks like the same class twice. Static state — caches, registries, counters — exists once per loader. Every component pins its own copy of the duplicated classes in memory. And any framework that instantiates classes by name must be told explicitly which loader to use. **How it is made survivable.** Define a shared-package allow-list that always delegates parent-first: `java.*` and the platform, plus the API packages that form the contract between container and component. Everything else may be child-first. That boundary must be a documented, deliberate decision — the classes crossing it are the ones that must have exactly one definition.
code
java · 19 lines@Override
protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException {
synchronized (getClassLoadingLock(name)) {
Class<?> c = findLoadedClass(name);
if (c == null) {
if (name.startsWith("java.") || isBoundaryApi(name)) {
c = super.loadClass(name, false); // must stay single-definition
} else {
try {
c = findClass(name); // component's own JARs win
} catch (ClassNotFoundException e) {
c = super.loadClass(name, false); // fall back to the container
}
}
}
if (resolve) resolveClass(c);
return c;
}
}go deeper
Know that some containers deliberately let a component's own JARs win, so each application can use its own library versions.
Explain the duplicate-type consequence and why a shared-package list must still delegate the platform and the boundary API to the parent.
Add the operational failures you would expect to debug: cross-boundary cast errors, per-loader static state, loader retention after undeploy, and how you would confirm each.
Treat the shared-versus-isolated namespace split as an explicit architectural contract, weigh it against simply running one component per process, and define the diagnostics and lifecycle rules that make the choice maintainable.
## The tension the design has to resolve A container hosting multiple independently-built components has to satisfy two conflicting requirements: 1. **Sharing.** Component and container must agree on the types they exchange — the servlet API, the plugin interface, the platform classes. Those types must have exactly one definition, which is what parent-first delegation guarantees. 2. **Isolation.** Component A needs version 2 of a library; component B needs version 5; the container ships version 3. Under strict parent-first the container's version wins for everyone, and both components are broken. Requirement 1 wants the parent to win; requirement 2 wants the child to win. There is no ordering that satisfies both universally — so real systems partition the namespace and apply a different rule to each part. ## What inversion actually looks like A container's component loader overrides `loadClass` with roughly this policy: - Already loaded by me? Return it. - Is the package on the **shared list** (`java.*`, the platform, the container-to-component API)? Delegate to the parent, always. - Otherwise: search **my own** bundled JARs first. - Not found locally? Fall back to the parent. So it is rarely pure child-first — it is child-first *for the component's private namespace* and parent-first for everything that must stay shared. Getting that allow-list right is the whole design problem. ## What the isolation buys - **Independent versioning.** Each component pins its dependency versions in its own archive. Container upgrades no longer silently change application behaviour. - **Independent deployment and undeploy.** A component's classes are reachable only from its own loader; discarding the loader can discard the whole component. - **Blast-radius containment.** A library defect in one component does not force a fleet-wide upgrade. This is why the pattern persists despite its costs: it is the only way to run multiple independently-released components in one process without a global dependency resolution. ## What it costs **Duplicate types.** The central consequence. A runtime type is its name plus its defining loader, so a library class defined by two component loaders is two types. Errors look absurd — a `ClassCastException` stating that `com.acme.Order` cannot be cast to `com.acme.Order` — until you notice modern JVM messages append each type's loader. Any object that crosses a component boundary must be of a type from the shared set, or it will not fit. **Duplicated static state.** Statics are per-class, hence per-loader. A registry, a cache, a JDBC driver registration, a metrics counter, a singleton — each exists once per component. Code that assumes process-wide uniqueness quietly stops being unique, which is a much subtler failure than a cast error. **Memory multiplied.** Ten components bundling the same large framework hold ten copies of its classes and its static caches. **Name-based instantiation becomes ambiguous.** Every framework that reads a class name from configuration must be told *which* loader resolves it. This is exactly the pressure that produced the thread context class loader, and inverted hierarchies are where context-loader bugs concentrate. **Lifecycle fragility.** Discarding a component means making its loader unreachable. Any reference held elsewhere — a thread's context loader, a registered driver, a thread-local, a shutdown hook, a listener in a parent-loaded singleton — keeps the entire component's classes alive. Container-hosted deployments are the classic home of this failure. **Deadlock risk.** When loaders can delegate in more than one direction, concurrent loading can deadlock unless loaders are registered as parallel-capable and use per-name locks rather than loader-wide synchronisation. ## Designing it responsibly - **Make the shared set explicit and small.** Only what genuinely crosses the boundary: the platform, the API contract, and the types in its signatures — including transitively, since an exported interface returning a type drags that type into the shared set. - **Never allow inversion for `java.*`.** The platform enforces this anyway at `defineClass`, but the policy should refuse it early and clearly. - **Design the boundary API to be narrow.** Fewer types crossing means fewer types that must be shared, which means more freedom to isolate. - **Make the policy inspectable.** A diagnostic that prints which loader defined a given name, and the loader chain, converts a mystifying cast error into a two-minute investigation. - **Watch loader lifetime.** Track loader instances; a monotonically growing count across redeploys means components are being retained. ## When *not* to invert If the process runs one application, inversion buys nothing and costs everything: build-time dependency management already solves version conflicts, and you have introduced runtime failure modes for no benefit. Modern deployment favours one component per process precisely because it moves this problem to the build and the image, where it is far cheaper to reason about. Reach for inverted delegation only when multiple independently-released components genuinely must share a process — and then treat the shared-package list as a first-class, documented, reviewed contract. ## Interview framing "They invert it to get per-component dependency versioning. The price is that a type can be defined more than once, so identity fragments — cast failures at boundaries, static state duplicated per component, memory multiplied, and retention if any long-lived reference pins a loader. The way to keep it survivable is a small, explicit shared-package allow-list covering the platform and the boundary API, with everything else child-first. And if the process only hosts one application, don't do it at all."
- Which packages belong on the shared, always-delegate list, and how do you decide?Start from the types that cross the boundary: the platform packages, the API the container invokes on the component and vice versa, and every type appearing in those signatures — transitively, because an interface returning a type drags that type into the shared set. Everything else can be component-private. The practical technique is to keep the boundary API narrow and stable, since each type you share is one the component can no longer version independently.
- After several redeploys, a container's memory grows and old component classes are never reclaimed. What is the structural cause?Something outside the component still references its class loader, so the loader, all the classes it defined, and their static state remain reachable. Typical culprits are a pooled thread whose context class loader was set to the component's loader and never restored, a registration in a parent-loaded singleton such as a driver or listener registry, and thread-locals whose value type was defined by the component. Structurally, inverted delegation makes each component's classes loader-private, which is exactly why any stray reference retains all of them.
A shopping centre where every shop brings its own staff and stock but everyone must use the centre's fire-exit signage and public address system. The shared list is the signage; anything a shop supplies privately can differ — until two shops try to hand each other a receipt printed to their own private format.
saying these in an interview costs you the question
- Presenting child-first delegation as strictly better, without naming the duplicate-type and static-state costs
- Believing an inverted loader can also override java.* classes; defineClass rejects those regardless
- Assuming a singleton or registry remains process-wide unique when its class is defined per component loader
- Adopting inverted delegation in a single-application process, where build-time dependency management already solves the problem
- Leaving the shared-package list implicit or ad hoc, so nobody can say which types are guaranteed single-definition