skip to content

In a large plugin-based JVM application — say, an IDE or an application server hosting many independently developed plugins — different plugins each need a different, incompatible version of the same library, and forcing one aligned version across the whole application isn't realistic. What techniques let two incompatible versions of the same library coexist at runtime in a single JVM process, and what do they cost?

level: principalimportance: nice to knowfreq 30%

answer

  1. class identity = name + classloader pair
  2. OSGi/Eclipse plugin model formalizes isolation
  3. shading = Maven Shade / Gradle Shadow plugin, rewrite package names
  4. ClassCastException across loader boundary gotcha
  5. shaded copy becomes a private fork you must re-shade to update

basics

~20 s

You can trick the JVM into treating the two copies as different libraries entirely — either by loading each plugin's classes with its own separate loader so they don't share names, or by physically renaming one copy's internal package names so they no longer look like the same library. Both add real complexity and memory/build overhead in exchange for avoiding a forced single-version pick.

solid answer

~60 s

Two main techniques exist. First, classloader isolation: give each plugin (or module) its own child classloader instead of sharing one flat application classpath, so the JVM's name-based class identity ('same class' = same fully-qualified name loaded by the same classloader) is scoped per-plugin rather than global — two plugins can each load their own version of the same-named library class without colliding, at the cost of added complexity around cross-plugin communication (shared types must live in a shared parent loader) and higher memory/PermGen-metaspace usage from duplicated class definitions. Second, shading/relocation: physically rewrite one copy's package names at build time (e.g. via the Maven Shade plugin or Gradle Shadow plugin) so `com.google.common.X` becomes `com.myapp.shaded.guava.X`, making it a textually different class the JVM never confuses with the original — cost is a larger artifact, a build-time rewrite step that can break reflection/serialization relying on original class names, and an extra thing to keep synchronized on future upgrades. Real systems like OSGi and application servers (e.g. Eclipse's plugin system, or Java EE application server module isolation) formalize classloader isolation as a first-class deployment model rather than an ad hoc workaround.

go deeper

for a junior

Not expected to know this material in depth; a passing awareness that 'special tricks exist for really hard version conflicts' is sufficient.

for a middle

Should have heard of shading/uber-jars as a way to bundle a private dependency copy, even without deep detail on classloader mechanics.

for a senior

Should understand the name+classloader identity model well enough to explain why isolation techniques work, and know shading's basic cost profile.

for a principal

Should be able to weigh classloader isolation against shading as architectural choices for a plugin-shaped system, cite a real formalized model (e.g. OSGi), and articulate the debugging/maintenance costs each imposes on the organization long-term.

## The constraint: identity is a pair, not a version The fundamental constraint that makes diamond conflicts painful on the JVM is that **class identity** is determined by the pair (fully qualified class name, defining classloader) — not by name and version. Two isolation techniques exploit different halves of that pair to let two incompatible versions of "the same" library coexist without colliding: - change the **classloader**, or - change the **name**. ## Changing the classloader half Classloader isolation changes the classloader half. Instead of one flat application classpath shared by everything (the default, simplest setup, and the one where diamond conflicts bite hardest), a plugin-hosting application gives each plugin its own **child classloader**, typically delegating upward to a shared parent loader only for a deliberately small set of shared API types the host exposes to all plugins. Because the JVM treats a class loaded by classloader `P1` as a different type from a class with the identical fully-qualified name loaded by classloader `P2`, two plugins can each bundle and load their own version of, say, Guava, without either one seeing or interfering with the other's copy — each plugin's code links against its own version's actual bytecode, so no NoSuchMethodError-style mismatch occurs, because there was never a shared, single resolved version to disagree about in the first place. This is exactly the model formalized by **OSGi** (used historically by Eclipse's plugin architecture and various enterprise application servers), where each bundle declares its own dependency versions and gets its own classloader with tightly controlled import/export boundaries between bundles. ## What classloader isolation costs The cost of classloader isolation is substantial and multi-dimensional. 1. **The isolation boundary.** First, anything that needs to cross the isolation boundary — a type passed from the host application into a plugin, or between two plugins — must be defined in a shared ancestor classloader, or you get baffling `ClassCastException`s where two objects that are "the same class" by name are nonetheless considered different types by the JVM because they were loaded by different classloaders (a classic OSGi/plugin-system gotcha). This forces careful API design: the shared surface between host and plugins has to be kept deliberately small and stable, essentially becoming its own semver-disciplined contract layered on top of the isolation mechanism. 2. **Memory and startup.** Second, memory and startup cost rise because each isolated copy of a library's classes is loaded and JIT-compiled independently rather than shared — in a system with many plugins each carrying their own copy of a common library, this multiplies both memory footprint and class-loading time. 3. **Tooling and debugging.** Third, tooling and debugging get harder: stack traces, reflection-based frameworks, and serialization can all behave unexpectedly across classloader boundaries, and diagnosing "why can't this cast succeed" bugs specifically requires understanding this loader-identity model, which is a much less common piece of JVM knowledge than ordinary dependency management. ## Changing the name half: shading **Shading** (also called **relocation**) changes the name half instead. Build-time tooling — the Maven Shade plugin or Gradle's Shadow plugin are the standard examples — physically rewrites the bytecode of one copy of a library, renaming its packages (and updating every reference to those packages throughout the rewritten jar) so that, for instance, Guava's `com.google.common.collect.ImmutableList` becomes `com.myapp.internal.shaded.guava.collect.ImmutableList` in the shaded copy. Because the class now has a genuinely different fully-qualified name, it no longer collides with any other copy of Guava on the same flat classpath — no classloader tricks are needed, and it works within the ordinary single-classloader deployment model most applications already use. This is why shading is common for library authors specifically: a library that internally depends on, say, a specific old version of Guava can shade it privately so that consuming applications never see or conflict with that internal dependency at all, regardless of what version of Guava the application itself uses. ## What shading costs Shading's costs are different in character from classloader isolation's. - The rewritten artifact is **larger**, since it now bundles a full private copy of the shaded library rather than sharing one. - The build step itself adds time and a class of subtle **correctness risk**: relocation is a mechanical bytecode/text rewrite, and anything that refers to a class by name as a string rather than through normal compiled references — reflection-based frameworks, some serialization formats, resource files bundled inside the jar that reference class names in text (like `META-INF/services` provider-configuration files) — can silently fail to get rewritten correctly unless the shading tool is specifically configured to handle that case, producing bugs that only appear once the shaded artifact runs, not at build time. - And because the relocated copy is now effectively a **fork** the shading team owns going forward, staying current with upstream security patches requires deliberately re-shading a newer version rather than getting the update for free the way a normal dependency bump would. ## Choosing between the two Both techniques exist for the same underlying reason: when the actual constraint really is "two consumers need genuinely incompatible versions and neither can be upgraded to converge," picking a single winner via forcing or version-selection guarantees breaking one consumer, whereas isolation sidesteps the choice entirely by making the JVM's collision rule (same name, same loader) simply not apply. The trade-off principals weigh is architectural: | Technique | What it is suited to | |---|---| | **Classloader isolation** | The heavier, more general mechanism suited to genuinely plugin-shaped systems that need this kind of isolation as an ongoing, structural property (OSGi containers, IDEs, app servers) | | **Shading** | The lighter, more targeted mechanism suited to a single library author hiding one specific internal dependency from the outside world without imposing any isolation model on consumers at all |

  • Why can't you just always use shading instead of dealing with classloader isolation at all, given that it works within a normal single-classloader setup?
    Shading works well when one side of the conflict can be privately hidden away — typically a library author shading its own internal dependency so it never leaks out to consumers — but it doesn't help when both incompatible versions genuinely need to be full, first-class, independently usable copies visible to different parts of a live system, such as two plugins in an IDE that both need to expose their version's API surface to the plugin host. Classloader isolation is the mechanism suited to that structurally ongoing, symmetric case; shading is suited to a one-sided, build-time-fixed case.
  • What's the classic bug that classloader isolation introduces, and why does it happen?
    A `ClassCastException` between two objects that both claim to be, say, `com.example.Widget`, thrown even though the code visibly looks correct — it happens because the JVM's notion of class identity includes the defining classloader, so a `Widget` loaded by plugin A's classloader is a genuinely different type from a `Widget` loaded by plugin B's classloader, even with identical bytecode and the same fully-qualified name. The fix is ensuring any type that needs to cross that boundary is defined once, in a shared ancestor classloader both plugins delegate to.
  • Does adopting classloader isolation or shading remove the need for a BOM or careful alignment strategy elsewhere in the project?
    No — these are complementary, not substitutes. A BOM/alignment strategy is still the right default for the bulk of a project's ordinary shared dependencies where convergence on one version is feasible and desirable; isolation techniques are reserved specifically for the narrow cases where convergence genuinely isn't achievable, and applying them project-wide 'just in case' would add unnecessary complexity and overhead for conflicts that a normal BOM would have prevented more cheaply.

Classloader isolation is like giving each tenant in an apartment building their own private water heater instead of one shared building system — nobody's setting collides with anyone else's, but now you're maintaining many heaters instead of one, and any shared pipe between units has to be very carefully agreed upon. Shading is more like putting a different tenant's furniture in storage under a relabeled tag so it never gets mixed up with someone else's identical-looking furniture — cheap and simple, but now you own a duplicate you have to remember to update yourself.

saying these in an interview costs you the question

  • Thinks the JVM identifies classes purely by name with no classloader component
  • Suggests classloader isolation as a default strategy rather than a targeted, costly escape hatch
  • Doesn't know shading is a build-time bytecode rewrite, not a runtime mechanism
  • Can't explain the classic cross-loader ClassCastException gotcha
  • Assumes shaded dependencies stay automatically up to date

context