The JVM’s classloaders don’t just load classes; they actually decide which version of a class to use when multiple are available, and that decision is a lot more nuanced than you might think.

Let’s see this in action. Imagine you have two JARs, libA.jar and libB.jar, both containing a class named com.example.MyClass.

libA.jar/com/example/MyClass.java:

package com.example;

public class MyClass {
    public void sayHello() {
        System.out.println("Hello from Lib A!");
    }
}

libB.jar/com/example/MyClass.java:

package com.example;

public class MyClass {
    public void sayHello() {
        System.out.println("Hello from Lib B!");
    }
}

Now, let’s run a simple Java program that tries to load and use com.example.MyClass.

First, compile them:

javac com/example/MyClass.java
jar cf libA.jar com/example/MyClass.class
# Modify MyClass.java for Lib B, then recompile and jar
jar cf libB.jar com/example/MyClass.class

Suppose we have a main class MainApp.java:

package com.example;

public class MainApp {
    public static void main(String[] args) {
        try {
            Class<?> myClass = Class.forName("com.example.MyClass");
            Object instance = myClass.getDeclaredConstructor().newInstance();
            myClass.getMethod("sayHello").invoke(instance);
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}

Compile MainApp.java:

javac MainApp.java

Now, here’s where classloaders come into play. The JVM uses a delegation hierarchy. When a classloader is asked to load a class, it first delegates the request to its parent. Only if the parent cannot find the class does the current classloader attempt to load it itself.

The hierarchy looks like this:

  1. Bootstrap ClassLoader: Loads core Java API classes (like java.lang.Object, java.lang.String) from rt.jar (or modules in Java 9+). This is implemented in native code, not Java.
  2. Extension ClassLoader (or Platform ClassLoader in Java 9+): Loads classes from the jre/lib/ext directory or the module path.
  3. System ClassLoader (or Application ClassLoader): Loads application-specific classes from the classpath.

Let’s see how this affects our MyClass.

If we run MainApp with only libA.jar on the classpath:

java -cp ".:libA.jar:MainApp.java" com.example.MainApp

(Note: For simplicity, assuming MainApp.java is compiled and available on classpath. In a real scenario, you’d compile MainApp.java first to MainApp.class and then add that to classpath.)

This will output:

Hello from Lib A!

Now, if we put libB.jar before libA.jar on the classpath:

java -cp ".:libB.jar:libA.jar:MainApp.java" com.example.MainApp

The output is still:

Hello from Lib A!

Why? Because the System ClassLoader (which loads MainApp and its dependencies) processes the classpath entries in order. It finds libB.jar first. When Class.forName("com.example.MyClass") is called, the System ClassLoader is asked to load it. It checks its parent (Extension, then Bootstrap) – they don’t have it. Then, it checks its own classpath entries in order. It finds libB.jar and loads com.example.MyClass from there. The fact that libA.jar also contains the class is irrelevant because the class was already loaded by the System ClassLoader.

But what if we want libB to win? You can’t control the order of the Bootstrap or Extension classloaders, but you can control the System ClassLoader’s classpath.

If you were to run this:

java -cp ".:libA.jar:libB.jar:MainApp.java" com.example.MainApp

The output would be:

Hello from Lib A!

The order of JARs on the classpath dictates which class is loaded first by the System ClassLoader.

The actual mechanism is that the java.lang.ClassLoader class has a findLoadedClass(String name) method. When loadClass(String name) is called, it first checks findLoadedClass. If found, it returns the Class object. If not, it delegates to its parent: parent.loadClass(name). If the parent returns a Class object, that’s what loadClass returns. If the parent returns null (meaning the parent couldn’t find it), then the current classloader calls findClass(String name), which is where it actually looks for the .class file (e.g., in JARs or directories on its classpath).

The surprising part is that the Class.forName(String className) method, by default, uses the classloader that loaded the calling class. So, in our MainApp, Class.forName is implicitly using the System ClassLoader because MainApp was loaded by it. This means the System ClassLoader’s classpath and its delegation order are what matter for resolving com.example.MyClass.

The core problem this system solves is managing dependencies and preventing class conflicts in complex applications, especially those with many libraries. The delegation model ensures that core Java classes are always loaded by the trusted Bootstrap ClassLoader, and it provides a structured way for applications to load their own code.

If you ever need to load a class using a specific classloader, you’d get that classloader instance (e.g., Thread.currentThread().getContextClassLoader() for thread-specific classloaders, or MyClass.class.getClassLoader() to get the classloader that loaded MyClass) and then call myClassLoader.loadClass("com.example.MyClass") or myClassLoader.loadClass("com.example.MyClass", true) which performs the delegation.

The next problem you’ll encounter is understanding how custom classloaders and the context classloader (especially in application servers and frameworks) can override this default behavior, leading to scenarios where the "wrong" class is loaded.

Want structured learning?

Take the full Jvm course →