The Java ClassLoader hierarchy is designed to load classes into the JVM, but its inherent delegation model can paradoxically make it difficult to isolate dependencies, especially in complex applications.

Let’s see this in action with a scenario. Imagine you have two independent libraries, library-a and library-b, both depending on different versions of a common library, say common-lib.

library-a depends on common-lib:1.0 library-b depends on common-lib:2.0

If you try to load both library-a and library-b into the same JVM using the default ClassLoader hierarchy, you’re likely to run into NoSuchMethodError or ClassCastException because the JVM will only load one version of common-lib based on which library gets its class loaded first by the parent ClassLoader.

Here’s how a simplified dependency tree might look:

Application
  |
  +-- System ClassLoader (Bootstrap)
  |     |
  |     +-- rt.jar (Java core classes)
  |
  +-- Extension ClassLoader
  |
  +-- Application ClassLoader
        |
        +-- library-a (depends on common-lib:1.0)
        |
        +-- library-b (depends on common-lib:2.0)

When library-a needs common-lib, the Application ClassLoader will ask its parent (Extension, then Bootstrap). If common-lib:1.0 is found there, it’s loaded. Later, when library-b needs common-lib, the Application ClassLoader again asks its parent. If common-lib:1.0 is already loaded, the JVM won’t load common-lib:2.0, leading to conflicts.

The core problem is the "parent-first" delegation policy. A ClassLoader always asks its parent to load a class before attempting to load it itself. This is great for ensuring that core Java classes are loaded only once and are universally available, but it breaks down when different application modules require conflicting versions of the same transitive dependency.

To solve this, you need a way to isolate these dependencies. This is where custom ClassLoaders or advanced build/runtime tools come in. The goal is to create separate "class loading environments" for modules that have conflicting dependencies.

One common approach is using the maven-shade-plugin or maven-dependency-plugin in Maven. You can configure these plugins to "relocate" packages from conflicting libraries. For example, you could tell the build to rename the com.example.common package from common-lib:2.0 to com.example.common_b.

Here’s a snippet of how you might configure the maven-shade-plugin to achieve this:

<plugin>
    <groupId>org.apache.maven.plugins</groupId>
    <artifactId>maven-shade-plugin</artifactId>
    <version>3.2.4</version>
    <executions>
        <execution>
            <phase>package</phase>
            <goals>
                <goal>shade</goal>
            </goals>
            <configuration>
                <artifactSet>
                    <includes>
                        <include>com.example:common-lib</include>
                    </includes>
                </artifactSet>
                <relocations>
                    <relocation>
                        <pattern>com.example.common</pattern>
                        <shadedPattern>com.example.common_b</shadedPattern>
                        <includes>
                            <include>com.example:common-lib</include> <!-- Only relocate for this specific artifact -->
                        </includes>
                    </relocation>
                </relocations>
            </configuration>
        </execution>
    </executions>
</plugin>

In this configuration, when the shaded JAR is built, any classes originating from com.example:common-lib will have their package names rewritten from com.example.common.* to com.example.common_b.*. If library-a uses common-lib:1.0 (which is not relocated), and library-b uses the relocated common-lib:2.0, they will now be operating with distinct, non-conflicting sets of classes. library-a will see com.example.common.* and library-b will see com.example.common_b.*. This effectively creates two separate "worlds" for the common library within the same JVM.

Alternatively, you can use runtime dependency management solutions like Apache Felix or OSGi, which provide sophisticated ClassLoader isolation mechanisms. These frameworks allow you to define bundles (modules) and manage their dependencies, including version conflicts, by creating distinct ClassLoader hierarchies for each bundle or group of bundles. Each bundle gets its own ClassLoader, which can delegate to parent ClassLoaders, but also has access to its own set of libraries, preventing version clashes.

The core idea behind these solutions is to bypass the default "parent-first" delegation. Instead, they might implement a "child-first" delegation or provide mechanisms for explicit version selection. For example, in OSGi, a bundle’s Import-Package manifest header specifies the exact package and version it needs, and the framework’s resolver ensures that only compatible versions are provided.

A subtle but critical point is that relocation only works if the conflicting libraries are not loaded by a parent ClassLoader that already has a version of the common library. If your application’s main JAR is loaded by the System ClassLoader and it pulls in common-lib:1.0, and then you try to shade common-lib:2.0 into a separate JAR, the System ClassLoader might still win the race, negating your relocation efforts. This is why packaging strategies (e.g., creating an uber-JAR with the shaded dependencies) are crucial.

The next challenge you’ll face is managing transitive dependencies of your relocated libraries, as they too might need to be relocated or carefully excluded.

Want structured learning?

Take the full Java course →