Demysifying Java ClassLoaders: A Deep dive into the JVM’s loading mechanism

Loading

Have you ever wondered how your Java application finds all those imported classes? Or maybe you’ve faced the dreaded ClassNotFoundException and felt lost in classloader confusion? Perhaps you’ve dealt with dependency conflicts or library version issues in large applications. All these problems come from one key mechanism in Java: classloaders.

In this article, I’ll take you into the exciting world of Java’s classloading. We will explore how class loaders operate. We’ll talk about their hierarchy. You’ll learn how to use them to solve real-world problems in your apps.

What are ClassLoaders, anyway?

A classloader is what is sounds like. It’s a part of the Java Virtual Machine (JVM) that loads class files. When you run a Java program, the JVM doesn’t load all classes at once. Instead, it loads them on demand when they’re referenced. This lazy — loading strategy is efficient. However, it needs a way to find and load class file when required.

That’s where classloaders come in. They’re responsible for

  1. Finding class file — wheter they’re in your local file system, inside JARs, or even across a network.
  2. Loading the data class — reading the binary from the class file.
  3. Defining the class — turning that binary data into a class object that the JVM can use.

The ClassLoader hierarchy: A three — tier system

Java doesn’t use just one classloader; it uses a hierarchy of them. This hierarchy uses a delegation model. When a classloader needs to load class, it first asks its parent classloader. If the parent cant’ load it, then the classloader tries to load the class itself.

The standard hierarchy consists of three main classloaders:

Boostrap ClassLoader (Primordial ClassLoader)

The native code (not Java) implements this as the root of the classloader hiearchy. It loads core Java classes from the rt.jar file and other core libraries in the $JAVA_HOME/jre/lib directory. In Java 9+ with the module system, it loads classes from the modular JDK runtime.

Key classes loaded by the Bootstrap ClassLoader include:

  1. java.lang.Object
  2. java.lang.String
  3. java.lang.System
  4. And other fundamental classes in the java.* package

Extensions ClassLoader (Platform ClassLoader in Java 9+)

This ClassLoader is a child of the Bootstrap ClassLoader. It loads classes from Java extension directories such as $JAVA_HOME/jre/lib/ext. It can also load classes from any directory specified by the java.ext.dirs system property. In Java 9+, it was renamed to the Platform ClassLoader and load platform modules. 

This classloader typically handles:

  1. Security extension class.
  2. JDBC drivers bundled with JDK.
  3. Other standard extension.

Application ClassLoader (System ClassLoader)

This classloader loads classes from the classpath. The classpath is set by the CLASSPATH environment variable or the -classpath command-line option. It’s responsible for loading your application class and third — party libraries.

The application class loader is what you’ll interact with most often. It load:

  • Your own application classes.
  • Third — party libraries.
  • Any other classes specified in the classpath.

The delegation model: parent — first loading

When someone asks a class loader to load a class, the class loader first asks its parent to do so. Here’s how it works:

  1. When a class loader receives a request to load a class, it first delegates the request to its parent.
  2. The parent class loader follows the same process, delegating to its parent.
  3. This delegation continues all the way up to the Bootstrap ClassLoader.
  4. If the bootstrap class loader can’t find the class, it delegates back to its child.
  5. Each class loader in the chain attempts to find the class in its assigned locations.
  6. If a class loader finds the class, it returns it. If not, the request goes down the chain.
  7. If no class loader can find the class, a ClassNotFoundException is thrown.

This model makes sure that system classes cannot be overriden by application classes. This helps keep security and consistency.

Custom class loaders: When the standard isn’t enough

Most situations are handled by built — in classloaders. However, you may need to implment your own class loader in some cases:

  • Loading classes from non — standrd locations (databases, networks, ecrypted sources).
  • Implementing hot deployment or class reloading capabilities
  • Creating isolated environments where classes with the same name can coexists.
  • Implementing plugins or module systems.
  • Transforming class bytecode on-the-fly before loading.

To create a custom class loader, extend the ClassLoader class. Override the findClass method. You may need to override other methods based on your needs.

Practical example: building a simple custom classloader

Let’s create a simple custom class loader that loads classes from a specific directory outside of the standard classpath:

public class DirectoryClassLoader extends ClassLoader {
    private final Path directory;

    public DirectoryClassLoader(Path directory, ClassLoader parent) {
        super(parent);
        this.directory = directory;
    }

    @Override
    protected Class<?> findClass(String name) throws ClassNotFoundException {
        try {
            String classFileName = name.replace('.', '/') + ".class";
            Path classFilePath = directory.resolve(classFileName);
            
            if (Files.exists(classFilePath)) {
                byte[] classBytes = Files.readAllBytes(classFilePath);
                return defineClass(name, classBytes, 0, classBytes.length);
            }
        } catch (IOException e) {
            // Log the exception
        }
        
        throw new ClassNotFoundException("Could not find class: " + name);
    }
}

To use this class loader:

public class CustomClassLoaderDemo {
    public static void main(String[] args) {
        try {
            // Path to directory containing class files
            Path classesDirectory = Paths.get("/path/to/classes");
            
            // Create our custom classloader
            DirectoryClassLoader customLoader = new DirectoryClassLoader(
                    classesDirectory, 
                    CustomClassLoaderDemo.class.getClassLoader()
            );
            
            // Load class using our custom loader
            Class<?> dynamicClass = customLoader.loadClass("com.example.DynamicClass");
            
            // Create an instance
            Object instance = dynamicClass.getDeclaredConstructor().newInstance();
            
            // Use reflection to call methods
            Method method = dynamicClass.getMethod("sayHello");
            method.invoke(instance);
            
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}

This custom class loader searches for class files in a specific directory. It does not use the standard classpath.

Understanding context ClassLoaders: A thread’s best friend

In a typical Java app, each thread uses the class loader that loaded its initial class. But this can be tough, especially in frameworks that use Service Provider Interface (SPI) or in application servers.

The thread context class loader provides a way to override the thread’s default class loader:

// Get current thread's context classloader
ClassLoader contextClassLoader = Thread.currentThread().getContextClassLoader();

// Set a different classloader
Thread.currentThread().setContextClassLoader(myCustomClassLoader);

// Don't forget to restore the original classloader when done
Thread.currentThread().setContextClassLoader(contextClassLoader);

Java EE and Jakarta EE frameworks use context class loaders. They help load resources in a manner that ensures accuracy between different modules.

Class loading in practice: Common scenarios and problems

Scenario: ClassNotFoundException vs NoClassDefFoundError

One common source of confusion is the difference between two exceptions

  • ClassNotFoundException — This error happens when you try to load a class with methods like Class.forName() or ClassLoader.loadClass(), but the class can’t be found.
  • NoClassDefFoundError — Thrown when JVM tries to load a class that was present during compilation but is missing at runtime.

Here’s a simple example to illustrate the difference:

// This will throw ClassNotFoundException
 try {
     Class.forName("com.example.NonExistentClass");
 } catch (ClassNotFoundException e) {
     System.out.println("Class not found: " + e.getMessage());
 }
 
 // This would cause NoClassDefFoundError
 // Assuming DependencyClass was available during compilation but isn't at runtime
 DependencyClass dependency = new DependencyClass();

Scenario 2: Dealing with duplicate classes

When your application or its dependenceis have the same class multiple times, your might face conflicts. The class loader will load the first one it finds based on the order of entries in the classpath.

Tools like Maven and Gradle manage conflicts using dependency management. Also, knowing about class loader can help fix issues when they occur.

// See which classloader loaded a particular class
Class<?> clazz = SomeClass.class;
ClassLoader loader = clazz.getClassLoader();
System.out.println("Class " + clazz.getName() + " was loaded by: " + loader);

// Get the actual JAR file location
URL location = clazz.getProtectionDomain().getCodeSource().getLocation();
System.out.println("Class is loaded from: " + location);

Scenario 3: ClassLoader isolation for plugins

Class loader isolation is key when building a plugin system. It helps prevent conflicts between plugins.

// Create isolated classloaders for each plugin
Map<String, ClassLoader> pluginLoaders = new HashMap<>();

// For each plugin JAR
for (File pluginJar : pluginJars) {
    URL[] urls = new URL[] { pluginJar.toURI().toURL() };
    
    // Create isolated classloader with limited parent delegation
    ClassLoader isolatedLoader = new URLClassLoader(urls, restrictedParentLoader);
    pluginLoaders.put(pluginJar.getName(), isolatedLoader);
    
    // Load and initialize the plugin
    Class<?> pluginClass = isolatedLoader.loadClass("com.example.plugin.PluginMain");
    // ...
}

Many application servers, IDEs, and plugin systems use this pattern. It help keep components isolated from each other.

Diving deeper: Classloading in modern Java

Modules in Java 9+

Java 9 brought the Java Platform Module System (JPMS). This update made a minor change to class loading. Modules define explicit dependencies and have their own classloaders. The module system has stricter access controls. Also, they updated the three — tier class loader hierarchy.

  • Bootstrap class loader -> Still loads Java core classes.
  • Platform class loader (formely Extension) -> Loads plafrom modules.
  • Application ClassLoader -> Loads application modules
// Get a module's classloader
ClassLoader moduleLoader = MyModule.class.getModule().getClassLoader();

// Check if a class is accessible to a module
Module myModule = MyClass.class.getModule();
Module otherModule = OtherClass.class.getModule();
boolean isAccessible = myModule.canRead(otherModule)

Multi-Release JARs

Java 9 introduced multi — release JARs. These can hold different class implementations for various Java versions. The class loader will automatically select the appropriate version based on the running JVM.

META-INF/
  MANIFEST.MF
  versions/
    9/
      com/example/MyClass.class
    11/
      com/example/MyClass.class
com/
  example/
    MyClass.class  # Base versio

The manifest must include:

Manifest-Version: 1.0
Multi-Release: true

Advanced techniques: bytecode manipulation

One powerful application of custom classloaders is bytecode manipulation. Libraries like ASM, Javaasist, and ByteBuddy can modify class bytecode as it’s being loaded.

public class InstrumentingClassLoader extends ClassLoader {
    @Override
    protected Class<?> findClass(String name) throws ClassNotFoundException {
        byte[] classBytes = loadClassData(name);
        
        // Modify the bytecode
        byte[] transformedBytes = transform(classBytes);
        
        // Define the class with the modified bytecode
        return defineClass(name, transformedBytes, 0, transformedBytes.length);
    }
    
    private byte[] transform(byte[] originalBytes) {
        // Use a bytecode manipulation library like ASM
        ClassReader reader = new ClassReader(originalBytes);
        ClassWriter writer = new ClassWriter(reader, ClassWriter.COMPUTE_FRAMES);
        ClassVisitor visitor = new MyClassTransformer(writer);
        reader.accept(visitor, 0);
        return writer.toByteArray();
    }
    
    // Load class file from the classpath
    private byte[] loadClassData(String name) throws ClassNotFoundException {
        // Implementation omitted
    }
}

This technique has extensive applications in:

  • Aspect — oriented programming (AOP) framework like Spring AOP.
  • Performance monitoring and profiling tools.
  • Mocking frameworks like Mockito.
  • JPA implementations and other frameworks that need enhance classes.

Real — world application: A hot reloading class loaders

Let’s create a practical example: a class loader that reloads classes automatically when they change. This is helpful during development.

public class HotReloadClassLoader extends ClassLoader {
    private final Path sourceDirectory;
    private final Map<String, Long> classModificationTimes = new ConcurrentHashMap<>();
    
    public HotReloadClassLoader(Path sourceDirectory, ClassLoader parent) {
        super(parent);
        this.sourceDirectory = sourceDirectory;
    }
    
    @Override
    public Class<?> loadClass(String name) throws ClassNotFoundException {
        // Let the parent handle system classes
        if (name.startsWith("java.") || name.startsWith("javax.")) {
            return super.loadClass(name);
        }
        
        // Try to load class from our directory
        String classFilePath = name.replace('.', '/') + ".class";
        Path classFile = sourceDirectory.resolve(classFilePath);
        
        if (Files.exists(classFile)) {
            try {
                // Check if class has been modified
                long lastModified = Files.getLastModifiedTime(classFile).toMillis();
                Long previousModified = classModificationTimes.get(name);
                
                // If class exists in memory and hasn't been modified, use it
                if (previousModified != null && previousModified >= lastModified) {
                    try {
                        return findLoadedClass(name);
                    } catch (Exception e) {
                        // Fall through to reload
                    }
                }
                
                // Read the class file
                byte[] classBytes = Files.readAllBytes(classFile);
                
                // Update modification time
                classModificationTimes.put(name, lastModified);
                
                // Define the class
                return defineClass(name, classBytes, 0, classBytes.length);
            } catch (IOException e) {
                throw new ClassNotFoundException("Error loading class " + name, e);
            }
        }
        
        // Delegate to parent if not found
        return super.loadClass(name);
    }
}

This class loader checks if the class file changed since the last load. If it has, it will reload the file.

Performance considerations

Classloading can impact application performance. Here are some considerations:

  1. Startup time: Excessive class loading during statup can slow application initialization. Tools like Class Data Sharing (CDS) or AppCDS can help by preloading commonly used classes.
  2. Memory usage: Each loaded class consumes memory, and classes are rarely unloaded. In environments with many class loaders (e.g., applicationt servers), this can lead to signficant memory usage.
  3. Classloader leaks: If class loader aren’t properly managed, they can cause memory leaks. This occurs when objects from a “temporary” classloader keep references. These references stop garbage collection.

Troubleshooting class loader issues

When diagnosing class loader issues, these tools and techniques can help.

    -verbose:class       # Print class loading details
    -XX:+TraceClassLoading
    -XX:+TraceClassUnloading

Debugging utilities

    // Print classloader hierarchy
    public static void printClassLoaderHierarchy(ClassLoader loader) {
        if (loader == null) {
            System.out.println("Bootstrap Classloader (null)");
            return;
        }
        
        System.out.println(loader);
        printClassLoaderHierarchy(loader.getParent());
    }
    
    // Check which classloader loaded a class
    Class<?> clazz = SomeClass.class;
    System.out.println("Class " + clazz.getName() + 
                      " was loaded by: " + clazz.getClassLoader());

Find where a class is being loaded from.

URL resource = MyClass.class.getClassLoader().getResource("com/example/MyClass.class");
System.out.println("Class is loaded from: " + resource);

Conclusion

Clas loaders are a fundamental but often overlooked part of the Java platform. Knowing how they work helps you tackle tough dependency problems. It als lets you use advanced features like hot reloading and plugin systems. Plus, you can debug tricky class loading issues.

Most developers can manage without knowing class loaders well. However, understanding how they work gives you strong tools. This knowledge helps you build more flexible, maintainable, and robust Java applications.

Next time you get ClassNotFoundException or need dynamic loading, you’ll know what to do.

References

1. Java Language Specification — Chapter 12: Execution
2. Liang, S., & Bracha, G. (1998). Dynamic class loading in the Java virtual machine
3. Oracle Java Documentation — ClassLoader (Java SE 17 & JDK 17)
4. OSGi Alliance Specifications — Dynamic Module System for Java
5. JEP 238: Multi-Release JAR Files
6. JEP 261: Module System (Java Platform Module System)

Subscribe to Egor Voronianskii | Java Development and whatsoever

Sign up now to get access to the library of members-only issues.
Jamie Larson
Subscribe