skip to content

You are designing a plugin system where each plugin ships its own dependency jars and different plugins may require incompatible versions of the same library. How does giving each plugin its own class loader deliver that isolation, and what must remain shared for the host and plugins to communicate at all?

level: principalimportance: should knowfreq 33%

answer

  1. per-plugin loader = private type namespace
  2. child-first for plugin code, parent-first allowlist for the API
  3. API module depends on JDK only
  4. set/restore TCCL around plugin calls
  5. namespace isolation only — no fault or resource isolation

basics

~20 s

Because a type is (name + defining loader), each plugin loader defines its own private copy of its dependencies, so conflicting versions coexist without clashing. But every type crossing the boundary — the plugin API interfaces and any exchanged data types — must be defined once by a shared ancestor loader that all plugins delegate to.

solid answer

~1 min

Type identity is *(binary name, defining loader)*, so a per-plugin loader gives each plugin a private type namespace. Two plugins can each define `com.lib.Client` from different versions and never collide: they are simply different types. The non-negotiable counterpart is the **shared contract**. Anything that crosses the boundary must be defined exactly once, by a loader that is an ancestor of every plugin loader — otherwise the host's `Plugin` interface and the plugin's `Plugin` interface are different types and the host cannot even call it. So the design is a **split namespace policy**: - **Delegate to parent, always**: the API package(s), the JDK, and any type appearing in a boundary signature. - **Plugin-local, child-first**: everything else the plugin ships, so it wins over the host's copy. Discipline that follows: keep the API surface small and dependency-free; never let a plugin's internal library type leak into an API signature; version the API deliberately, since it is now a published contract. Second-order concerns: the thread context class loader must be set around plugin calls for libraries that use it; unloading a plugin requires dropping every reference into its loader; and native libraries bind to one defining loader, so two plugins cannot both load the same one.

code

java · 16 lines
java
protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException {
    synchronized (getClassLoadingLock(name)) {
        Class<?> c = findLoadedClass(name);
        if (c == null) {
            if (name.startsWith("java.") || name.startsWith("javax.")
                    || name.startsWith("com.host.plugin.api.")) {
                c = getParent().loadClass(name);   // shared contract: one definition
            } else {
                try { c = findClass(name); }        // plugin-private: plugin wins
                catch (ClassNotFoundException e) { c = getParent().loadClass(name); }
            }
        }
        if (resolve) resolveClass(c);
        return c;
    }
}

go deeper

for a junior

State the core fact: each plugin gets its own class loader, so its dependencies are separate types and versions cannot clash; the shared API interfaces must come from the host.

for a middle

Describe the delegation policy concretely — child-first for plugin code, parent-first for JDK and API packages — and explain what breaks if a plugin defines its own copy of the API.

for a senior

Cover the operational reality: TCCL handling, teardown for unloading, native-library binding, resource lookup order, and keeping the API dependency-free so the shared surface stays small.

for a principal

Own the tradeoff. Define what is shared as a versioned platform contract, decide the compatibility policy for it, and be explicit that loader isolation provides namespace and version isolation only — escalating to process isolation when fault or resource containment is required.

## The mechanism you are exploiting A run-time type is the pair *(binary name, defining loader)*. Give each plugin its own loader and each plugin gets its own namespace of types: the same class name defined in two plugin loaders yields two unrelated types with separate statics. That is precisely why version conflicts disappear — plugin A's `com.lib.Client` v1 and plugin B's `com.lib.Client` v3 never meet, so nothing has to reconcile them. This is the same mechanism application servers use to isolate deployments and OSGi generalizes into explicit imports and exports. ## The delegation policy is the design A loader that always delegates upward first cannot host a plugin-private version: the host's copy would win everywhere. So plugin loaders are typically **child-first (parent-last)** for plugin-supplied code, with a carefully chosen **parent-first allowlist**. Getting that allowlist right *is* the architecture: 1. **Always parent-first: the JDK / platform classes.** Never let a plugin define `java.*` (the JVM forbids it anyway) or platform types; everything breaks if `java.util.List` is not the same type everywhere. 2. **Always parent-first: the plugin API package.** The interfaces the host calls, the annotations it scans for, the exception types it catches, and every type appearing in a boundary method signature — parameters, return types, generic arguments, fields of exchanged DTOs. If a plugin defines its own copy of any of these, the host's cast or `instanceof` fails with the notorious "X cannot be cast to X". 3. **Child-first: the plugin's own code and its private dependencies.** Here the plugin's version wins, which is the whole point. A plugin that packages the API jar and is allowed to define it will break in a way that looks like a JVM bug. The build-side counterpart of the allowlist is therefore mandatory: the API dependency must be `provided`/`compileOnly` so it is compiled against but not shipped, and the loader should refuse to define API packages regardless. ## Keep the shared contract minimal and dependency-free Everything shared is everything you can no longer version independently. Practical rules: - The API module should depend on nothing but the JDK. The moment the API's signatures mention a third-party type, that library becomes shared too, and its version becomes a platform-wide decision — reintroducing exactly the conflicts you built isolation to avoid. - Prefer narrow, stable interfaces plus simple data carriers. If richer exchange is needed, cross the boundary with neutral payloads (strings, byte arrays, maps) or host-owned DTOs. - Never let an internal type leak: a method returning the plugin's private `com.lib.Result` is unusable by the host even if the host has a same-named class. - Treat the API as a published contract with its own compatibility policy, because plugins are compiled against it and deployed independently. ## Second-order concerns that decide whether this survives production **Thread context class loader.** Many libraries (service loading, deserialization frameworks, logging backends, JDBC-style discovery) resolve classes through `Thread.currentThread().getContextClassLoader()` rather than their own loader. The host must set the TCCL to the plugin's loader around every call into the plugin and restore it in a `finally`. Skipping this produces "class not found" failures that appear only for certain plugins or certain code paths. **Unloading and lifecycle.** A plugin's classes and their static state can be reclaimed only when its loader, all classes it defined, and all their instances become unreachable. Anything the plugin left behind in host-owned structures pins the whole loader: registered listeners, thread pools with plugin-defined tasks, `ThreadLocal` values holding plugin objects, JDBC drivers registered in a shared registry, shutdown hooks, caches keyed by plugin classes. A supported unload path therefore needs an explicit teardown contract — unregister everything the plugin registered — not just "drop the loader reference". **Native libraries.** A native library is bound to the loader that loaded it; a second loader trying to load the same library fails. If plugins need the same native dependency, the host must own it and expose a Java-level facade from a shared loader. **Resources and services.** Resource lookup and service discovery follow the same delegation policy, so `getResources()` may return the host's copy, the plugin's, or both, depending on ordering. Decide it explicitly rather than discovering it. **Diagnostics.** Include the defining loader in error messages and logs at the boundary. The single most useful habit is logging `class.getClassLoader()` and the code source whenever a boundary cast or lookup fails. ## Alternatives worth weighing Loader-based isolation is in-process: cheap calls, shared heap, but shared failure domain — a plugin can still exhaust memory, spawn threads, or call `System.exit`. If plugins are untrusted or must fail independently, out-of-process isolation (separate JVMs with an IPC contract) or a module system with explicit wiring may be the better call, at the cost of serialization and operational complexity. State that tradeoff explicitly: class loaders give you *version* isolation and *namespace* isolation, not *resource* or *fault* isolation.

  • Why must the shared API module avoid third-party dependencies?
    Any type appearing in a boundary signature must be single-defined and therefore shared. If the API references a third-party library, that library becomes shared platform-wide and its version becomes a global decision every plugin must accept — which is exactly the conflict the isolation was built to remove. Keeping the API JDK-only keeps the shared surface minimal.
  • A plugin is undeployed but its memory is never reclaimed. What is the likely mechanism?
    Something outside the plugin still references into its loader, which keeps the loader, all its defined classes and their static state alive. Typical culprits are listeners or callbacks registered in host structures, ThreadLocal values holding plugin objects on pooled threads, shutdown hooks, and caches keyed by plugin classes. Reliable unloading requires an explicit teardown contract that unregisters everything the plugin installed.
  • When would you not use class loaders for plugin isolation?
    When the plugins are untrusted or must fail independently. Loader isolation separates namespaces and versions, not resources or failures: a plugin shares the heap, the threads and the process, so it can still exhaust memory or halt the JVM. Those requirements point to separate processes with an IPC contract, accepting serialization cost and operational complexity.

Each plugin gets its own workshop stocked with its own tools; the only things that may pass through the shared door are items made from parts the building itself supplies.

saying these in an interview costs you the question

  • Making plugin loaders fully child-first with no parent-first allowlist, so the API gets redefined
  • Putting third-party types in API signatures and calling the result isolated
  • Claiming class-loader isolation also gives memory, thread or fault isolation
  • Forgetting the thread context class loader when calling into plugin code
  • Assuming dropping the loader reference is sufficient to unload a plugin

context