skip to content

A single JVM process hosts several deployed applications that each need a different version of the same library. How is the class loader hierarchy typically arranged to make that possible, and what constraints does that arrangement put on types shared between the host and the applications?

level: seniorimportance: should knowfreq 40%

answer

  1. One loader per deployment, siblings under a shared parent
  2. Identity = name + defining loader
  3. Shared API defined once by the common ancestor
  4. Same-name ClassCastException = duplicated type
  5. Statics per loader; undeploy = drop the whole graph

basics

~20 s

The host gives each deployment its own loader as a sibling under a shared parent. Because a type's identity is its name plus defining loader, each application gets a private copy of the library. Any type crossing the boundary must be defined by the common ancestor, or the two sides see incompatible types.

solid answer

~60 s

The standard shape is a **tree, not a line**: one shared loader holds the host's API and common libraries; beneath it, one loader per deployed application, siblings of each other. Each application loader searches its own bundled libraries, so two applications can each define `com.acme.Widget` from different JARs and the JVM treats them as two unrelated types. That isolation comes with a strict boundary rule. Runtime type identity is the pair *(binary name, defining loader)*. Any type that both the host and an application must exchange, the servlet-style API, a plugin interface, a shared DTO, must be defined **once, by the common ancestor loader**, and must not be duplicated in the application's own bundle. If an application ships its own copy of the shared interface, its objects are unassignable to the host's version of the same interface and you get a `ClassCastException` whose message names the same class twice. Two further consequences: static state is per loader, so each application gets its own singletons and caches; and anything the host retains across an undeploy pins that application's loader.

code

java · 5 lines
java
Object plugin = registry.lookup("reporting");
System.out.println(plugin.getClass().getName());          // com.acme.PluginApi impl
System.out.println(plugin.getClass().getClassLoader());   // app-A loader
System.out.println(PluginApi.class.getClassLoader());     // shared loader
// different loaders for the interface -> cast fails despite identical names

go deeper

for a junior

Recall that each application gets its own loader so each can have its own copy of a library, and that classes with the same name from different loaders are different types.

for a middle

Draw the tree with sibling loaders under a shared parent, explain the identity pair, and state the packaging rule that the shared API must come from the parent only.

for a senior

Reason about the whole boundary: keep the shared surface small, diagnose same-name ClassCastException by printing loaders, and design an undeploy contract so a discarded loader actually becomes unreachable.

for a principal

Weigh in-process isolation against separate processes or containers, since the loader tree buys density at the cost of a fragile shared-type contract, lifecycle discipline, and shared failure domains such as heap and GC.

## Why one process needs more than one namespace A class loader is not just a fetcher of bytes; it is a **namespace**. The JVM identifies a loaded type by the pair *(binary name, defining loader)*. Two loaders may each define a class called `com.acme.Widget`, and the runtime treats those definitions as entirely unrelated types: no assignability, no shared statics, no shared identity. That property is exactly what lets a single JVM host several applications with conflicting dependency versions. Without it, one process could contain only one `com.acme.Widget`, and hosting would require one process per application. ## The arrangement: a tree with siblings The chain of built-in loaders (application over platform over bootstrap) is a straight line, but nothing forces the structure to stay linear below that. Hosting platforms extend it downward and sideways: ``` bootstrap | platform | application (host runtime + shared API) / \ app-A loader app-B loader (A's own JARs) (B's own JARs) ``` Each deployment gets its own loader, and those loaders are **siblings**: neither is the other's parent, so neither can see the other's classes. A request that an application loader cannot satisfy from its own bundle goes to the shared parent, which is where the host's API and genuinely common libraries live. The result is: shared things are shared exactly once; private things are private per application. This is the shape used by application servers, OSGi-style module systems (with a richer wiring model), IDE and build-tool plugin systems, and any in-process plugin host. ## The boundary rule Isolation is only half the design. The other half is deciding **which types cross the boundary**, and those types have a hard constraint: they must be defined by a loader that is an ancestor of every participant. Suppose the host defines interface `PluginApi` in the shared loader and calls `plugin.execute()`. If application A's bundle also contains its own copy of `PluginApi`, then A's loader may define that copy locally instead of delegating, producing a second, unrelated `PluginApi` type. A's implementation class implements *A's* copy, while the host holds a reference typed as the *host's* copy, and the cast fails. The error message is famously confusing because both types print with the same fully qualified name. So the packaging rules that follow are concrete: 1. The shared API artifact is provided by the host and must **not** be bundled inside applications (in Maven terms, `provided` scope). 2. Anything else an application needs should be bundled, so it stays private. 3. Objects handed across the boundary must have types from the shared set, or be reduced to types the shared loader defines (strings, primitives, collections of shared types, serialized forms). The practical design pressure is to keep the shared surface small: every type promoted to the shared loader becomes a version everyone must agree on, which is precisely the coupling the isolation was meant to remove. ## Static state and lifecycle Because statics belong to a class, and a class belongs to a defining loader, each application gets its **own** copy of every static field in every class it defines privately. Two applications using the same logging library configure it independently, each holds its own singletons and caches, and a fix applied through one application's static configuration does not touch the other. That is usually the desired behaviour, but it surprises people who expect "the library" to be a single thing in the process. Undeploy is the other lifecycle consequence. Discarding an application means discarding its loader and letting the whole graph become unreachable: the loader, every class it defined, and every object of those classes. If anything with a longer lifetime keeps a reference into that graph, a shared cache keyed by an application class, a listener registered with a shared library, a thread created by the application, the loader cannot be released and the deployment's classes stay resident. Robust hosts therefore define an explicit shutdown contract: unregister listeners, stop threads, clear shared caches. ## Where a type gets defined can differ from where it is requested One subtlety worth stating: the loader that a request *starts* from is not necessarily the loader that *defines* the resulting class. If app-A's loader consults its parent and the shared loader defines the class, the class's defining loader is the shared one, and `getClassLoader()` reports that. Both applications then genuinely share a single type. Conversely, if each application defines its own copy, each is its own type. The design question in every hosting platform is exactly this: for each package, who gets to define it. ## Diagnosing boundary mistakes Symptoms cluster: - `ClassCastException` naming the same class on both sides, or `LinkageError` about a duplicate definition. - A service lookup that finds nothing because it ran with a loader that cannot see the implementation. - Configuration applied in one application having no effect in another. The fast diagnostic is to print, for each of the two objects involved, `obj.getClass().getClassLoader()` together with the code source. Different loaders for the same name confirms a duplicated type, and the fix is packaging: remove the shared API from the application bundle, or move a genuinely shared type up to the common ancestor.

  • Why does the ClassCastException print the same class name on both sides of the message?
    Because the two types genuinely have the same binary name but different defining loaders, and runtime identity is the pair of the two. Modern JVMs append the loader (and module) of each type to the message precisely so the situation is diagnosable. It means one artifact is present both in the shared parent and inside the application bundle.
  • An application is undeployed but its classes remain in memory. What is usually holding them?
    Something outliving the deployment keeps a reference into the application's object graph, which pins its loader and therefore every class the loader defined. Common culprits are threads started by the application, listeners or callbacks registered with a shared library, and caches in the shared layer keyed by application objects. The host needs an explicit shutdown contract to unregister them.
  • How do you decide which types belong to the shared parent loader?
    Only the interfaces and value types that must cross the boundary, kept deliberately small, because every shared type becomes a version all deployments must agree on. Everything else stays bundled per application so versions can diverge freely. If a type keeps being promoted upward, that is a signal the boundary API is doing too much.

saying these in an interview costs you the question

  • Assuming identically named classes are the same type regardless of which loader defined them
  • Bundling the shared host API inside each application instead of scoping it as provided
  • Making application loaders parents of one another rather than siblings under a shared loader
  • Expecting a library's static configuration to be process-wide when each application defines its own copy
  • Thinking undeploy frees memory even while a shared component holds a reference into the application

context