mattone
dopo mattoneTHE BOOK SERIES
IT/EN
← Java guide

Java 25 · 22/39

21. Java Classloaders: Finding a Class While the Program Runs

Java 25 · Complete guide · Draft under review

This guide retains the book’s draft status. Editorial review and comprehension checks with independent readers remain to be completed.

Search the whole guide →

Why a Class Must Be Loaded

When we compile Java source, javac produces a .class file containing bytecode. This file is not already a running class in the JVM: the application must still find the bytes, check them and insert them into its execution space. The classloader participates in this work. It is the component from which the JVM can request a definition when needed. Its task is not limited to reading a file: a definition can come from a module, a JAR or, in specialized applications, a source managed by a custom loader.

Why load classes on demand rather than prepare them all at startup? A program may contain features that will never be used in a particular execution; loading them all would have an unnecessary cost. Loading belongs to runtime. If new Service() appears in the source, the compiler must know the type to check the program, but does not create the object or load the class into the JVM during compilation. These are different phases. Similarly, Class.forName(name) expresses a request in which the name may come from a string at runtime; it is not the only way Java dynamically loads a class.

Definition – Loading, Linking, Initialization. Loading means obtaining a class definition. Linking includes the checks and preparation necessary for its integration into the JVM; reference resolution can occur according to the VM's rules. Initialization means performing static initialization under the conditions specified by the language. Simply saying “the class is loaded” does not imply that its static block has already executed. This distinction helps us interpret Class.forName examples, error messages and observed times.

The Loaders Provided by the Platform

The complete program observes the loader of an application class, java.sql.Driver and java.util.ArrayList. With the JDK 25 distribution used for verification, the first is associated with the application loader, the second with the platform loader and the third with the bootstrap loader. In the third case Class.getClassLoader() returns null, which the example prints as bootstrap. We must not confuse that null with the absence of the class: ArrayList exists, and it is precisely its bootstrap loader that is not represented as an ordinary ClassLoader object returned by the method.

Application -> jdk.internal.loader.ClassLoaders$AppClassLoader
java.sql.Driver -> jdk.internal.loader.ClassLoaders$PlatformClassLoader
java.util.ArrayList -> bootstrap

These lines document the observation on a concrete JVM, rather than an output format to promise for every implementation. The names jdk.internal.loader... are implementation details: the reader's code must not depend on those strings. The public contract offers ClassLoader.getSystemClassLoader() and ClassLoader.getPlatformClassLoader(), as well as Class.getClassLoader(). The application loader normally searches for classes and resources on the application path; the platform loader serves platform classes; bootstrap is at the base of the hierarchy. The precise set of classes visible to each is also influenced by the module system introduced in modern Java editions.

In JDKs before Java 9 there was an extension classloader, folders such as jre/lib/ext and the archive rt.jar. That model does not describe Java 25: we now encounter the platform classloader and the modular runtime image. When reading older documentation or projects, we can recognize those historical names, but should not look for rt.jar or configure a removed extension mechanism. The parent–child relationship remains useful; the concrete components occupying some positions in the hierarchy change.

              bootstrap
                  ↑
               platform
                  ↑
             application
                  ↑
          possible child loader

Figure 21.1 – Typical delegation relationship. The arrow points toward the parent consulted first by the child. It does not represent inheritance between loaded classes. A custom loader can choose special behaviors; the figure describes the default model we study here.

Delegation between classloaders in Java 25
Figure 21.2 – The path of the request and response in the built-in loaders.

The delegation hierarchy does not coincide with the hierarchy of the loaders' Java classes. For example, in the public API URLClassLoader extends SecureClassLoader, which extends ClassLoader: this is a relationship between types. Saying instead that the application loader consults the platform loader as its parent describes a relationship between loader objects during a search. We cannot infer from the name platform that its Java class extends the concrete class of the bootstrap loader; the latter does not even appear as an ordinary object returned by Class.getClassLoader() for the classes it defines. The distinction avoids reading an arrow indicating a delegation request as an inheritance diagram.

Inheritance of API classes and delegation between loaders
Figure 21.3 – On the left, an example of public inheritance verifiable in the API; on the right, the typical delegation relationship of the built-in loaders.

Delegation, Step by Step

Suppose the application loader receives the binary name java.util.ArrayList. First it checks whether it has already loaded that definition. If not, the ordinary implementation of loadClass asks the parent; the search rises to bootstrap, which can find the class. The result returns along the chain. The application loader does not define a second copy of ArrayList. If instead the name belongs to an application class unknown to the parents, control returns to the application loader, which tries findClass. This is the logic called parent first.

The public algorithm of ClassLoader.loadClass(String, boolean) follows three essential steps: findLoadedClass checks a definition already loaded by the current loader; the parent or bootstrap receives the request; finally findClass is the point at which the current loader searches for its own definition. The argument resolve also requests resolution through resolveClass when true. It is incorrect to interpret false as “the class will not be loaded”: the method can still load and define it, postponing only that resolution operation. Furthermore, concurrent code can request classes from the same loader: the implementation synchronizes work with an appropriate loading lock. Chapter 20A showed why a single CAS protects a single decision on state; loading instead includes search, delegation, definition and resolution. It should not be mechanically replaced with an atomic counter.

Diagram – A Request That Goes Up and Back Down. The child checks what it has already defined; the parent tries to find the class; if the parent fails, the child searches its own bytes. The positive response or exception returns to the caller. Delegation protects fundamental classes and facilitates shared definitions, but does not guarantee that the process contains only one definition for every name.

The last sentence is important. The identity of a class in the JVM depends on the binary name and the loader that defined it. Two distinct loaders can define two classes with the same name; for the JVM these are different types. This possibility is useful, for example, when a container isolates components or library versions, but requires great care in passing objects from one context to another. A ClassCastException between two apparently equal names can arise precisely from this difference in loaders. Delegation avoids duplication in the ordinary case of the same chain; it does not, however, make a class name unique across the JVM.

Seeing the Hierarchy Without Depending on Addresses

The program calls getParent() on the system loader and prints the result. A JVM may represent the object with the name of an internal class and a suffix resembling an address; that suffix changes between executions and has no teaching value. The meaningful check is that, in the common configuration, the system loader's parent is the platform loader. getParent() can also be null on reaching the bootstrap level. A robust test would compare the objects returned by the public APIs, rather than their textual representations.

Searching for resources follows a related but distinct problem. getResource("configuration.txt") searches for an item on the path visible to the loader and returns a URL or null; getResourceAsStream can directly provide a stream to close. The resource is not necessarily an ordinary filesystem file, particularly if it is in a JAR or modular image. This is why the path must be written with / separators and interpreted relative to the search root specified by the API, rather than manually concatenating an absolute path dependent on the operating system. If the resource is mandatory, a null result must become a handled error with a useful message, rather than a NullPointerException in subsequent reading.

Class Not Found: Exception or Error?

ClassNotFoundException and NoClassDefFoundError report different problems, although both can relate to an unavailable class. ClassNotFoundException is a checked exception: a method such as Class.forName("example.Missing") or loadClass("example.Missing") receives a name and may fail to find the requested definition. The caller must handle or declare the exception. In the earlier program we use Class.forName("java.util.ArrayList"), which finds a real class and prints its name; substituting a nonexistent string allows us to observe ClassNotFoundException in a controlled way.

NoClassDefFoundError is instead an Error that can appear when the JVM needs a definition necessary for execution but cannot make it available. To see it, we compile A and B, then remove A.class before running B, which directly uses new A(). The bytecode of B contains a reference to A; at execution the definition is missing. The diagnostic may include a ClassNotFoundException as its cause, but the failure observed by code using B is NoClassDefFoundError. There may also have been an earlier initialization error leaving the class unusable; therefore we do not reduce the type's meaning to “file deleted”.

We can repeat the first case with a short program. The string indicates a class we have not compiled; the exception is therefore caught and the program remains in control of the situation:

public class MissingClass {
    public static void main(String[] args) {
        try {
            Class.forName("example.plugin.Absent");
        } catch (ClassNotFoundException e) {
            System.out.println(e.getClass().getSimpleName() + ": " + e.getMessage());
        }
    }
}

The output is ClassNotFoundException: example.plugin.Absent. ClassLoader.loadClass can also report the same exception. If we want to try the system loader and platform loader separately, we must give each call its own try or continue after the first error: three requests in the same try block would stop at the first exception. The message shows the requested name, whereas stack trace lines depend on the JDK and environment and are not output to memorize.

For the second case we use two separate sources, with B using A directly:

public class A {
    public String message() {
        return "A available";
    }
}
public class B {
    public static void main(String[] args) {
        System.out.println(new A().message());
    }
}

Compiling both, java -cp build/errors B prints A available. After moving A.class outside the execution path, the same command fails with NoClassDefFoundError: A. The source of A can remain where it is: during execution the JVM searches for the compiled definition, rather than automatically recompiling that source. To keep the experiment isolated, from directory examples/C21 we can use these commands and restore the file at the end:

mkdir -p build/errors
javac --release 25 -d build/errors errors/A.java errors/B.java
java -cp build/errors B
mv build/errors/A.class build/A.class.moved
java -cp build/errors B
mv build/A.class.moved build/errors/A.class

The second launch must fail: in this one experiment its nonzero exit code is the sought result. The complete trace may change between JVM versions, while the distinction between an unsatisfied request by name and a missing necessary definition remains the point to understand.

Guided exercise – Two Different Times. Compile two classes into the same output path. Run B once with both present, then temporarily move A.class outside that path and repeat execution. Restore the file at the end. The result depends on the execution phase, rather than on the fact that the source B.java is still available. This is an experiment to perform in a test folder, without removing project libraries.

Another useful difference concerns initialization. The one-argument form of Class.forName loads and initializes the requested class according to its contract. The three-argument form allows us to choose the initialization boolean and loader. ClassLoader.loadClass normally loads without directly requesting initialization. Later, an operation requiring active use of the class can initialize it. It is therefore incorrect to label Class.forName “dynamic loading” and new “static loading” if the latter suggests loading at compile time: both reach a running JVM, but express different requests.

Customizing a Loader Without Losing Delegation

There are cases where definitions come from a source unknown to the standard loader: a plugin system, a special archive or controlled bytecode generation. The library offers the abstract class ClassLoader. In the common case we extend the loader and override findClass(String name), leaving delegation behavior to loadClass. findClass must find the bytes corresponding to the requested name and call defineClass with a valid array. If it cannot find the definition, it throws ClassNotFoundException. The search result must be checked, opened resources must be closed and we must define which bytes to consider trustworthy.

Further reading – What Weaving Is. Weaving is transformation of a class's bytecode to add or modify behavior, for example a timing measurement or a check at method entry. It can happen after compilation, modifying .class files, or during loading through suitable tools. A custom loader can participate in the second path, but the simple example below does not modify the bytecode: it reads an already compiled definition from a different directory. Separating the two mechanisms avoids confusing “finding bytes” and “transforming bytes”.

A Custom Loader We Can Try

Let us now perform a complete experiment. The class DirectoryLoader searches for a .class file under the directory indicated at startup. The other source, Message, is compiled into a separate directory: it must not be on the application's class path, otherwise the parent loader could find it before our findClass. This separation is the condition that allows us to actually observe the new loader.

import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;

public class DirectoryLoader extends ClassLoader {
    private final Path directory;

    public DirectoryLoader(Path directory, ClassLoader parent) {
        super(parent);
        this.directory = directory.toAbsolutePath().normalize();
    }

    @Override
    protected Class<?> findClass(String name) throws ClassNotFoundException {
        Path file = directory.resolve(name.replace('.', '/') + ".class").normalize();
        if (!file.startsWith(directory)) {
            throw new ClassNotFoundException(name);
        }
        try {
            byte[] bytecode = Files.readAllBytes(file);
            return defineClass(name, bytecode, 0, bytecode.length);
        } catch (IOException e) {
            throw new ClassNotFoundException(name, e);
        }
    }

    public static void main(String[] args) throws ReflectiveOperationException {
        if (args.length != 1) {
            throw new IllegalArgumentException("Specify the plugin directory");
        }
        ClassLoader loader = new DirectoryLoader(
                Path.of(args[0]), DirectoryLoader.class.getClassLoader());
        Class<?> type = Class.forName("example.plugin.Message", true, loader);
        Object instance = type.getDeclaredConstructor().newInstance();
        System.out.println(type.getMethod("text").invoke(instance));
        System.out.println(type.getClassLoader().getClass().getSimpleName());
    }
}

The binary name example.plugin.Message becomes the path example/plugin/Message.class under the directory received by the constructor. Files.readAllBytes closes the file internally after reading; if the file does not exist or reading fails, findClass retains the cause inside ClassNotFoundException. The check with normalize prevents the calculated path from trivially leaving the chosen directory, but does not turn this class into a security barrier for hostile input. defineClass checks that the bytes represent a class compatible with the requested name; format errors are not disguised as “file missing”.

The small plugin contains a public method and has no application dependencies:

package example.plugin;

public class Message {
    public String text() {
        return "hello from the plugin";
    }
}

From directory examples/C21 we can compile and launch as follows, with two distinct output paths:

mkdir -p build/app build/plugin
javac --release 25 -d build/app DirectoryLoader.java
javac --release 25 -d build/plugin plugin/example/plugin/Message.java
java -cp build/app DirectoryLoader build/plugin

The expected result is hello from the plugin, followed by DirectoryLoader. The first line comes from the plugin's method; the second shows the type of loader that defined that class. If we also compiled Message into build/app, the parent first model could have it defined by the parent, and the second line would change. The example therefore makes both loading from bytes and the effect of delegation observable.

defineClass is not a search method. It transforms the bytes of a valid class into a Class<?> object associated with the loader calling it; it can fail if the format is invalid or runtime constraints are violated. findLoadedClass avoids defining again, in the same loader, a class already defined; getParent makes the delegation chain observable. To explain the relationship between these methods, there is no need to copy the JDK's internal implementation line by line: we only need to follow the request from the name to the bytes and then to the Class object, distinguishing the public contract from private implementation details.

Method Question it answers Relevant result
loadClass(name) Which visible class corresponds to the name? Class<?> or ClassNotFoundException
findLoadedClass(name) Has this loader already defined it? Class<?> or null
findClass(name) Can this loader obtain the bytes? Class<?> or ClassNotFoundException
defineClass(name, byte[], ...) Do these bytes define a valid class here? a Class<?> object or a definition error
getParent() To whom does this loader normally delegate? a ClassLoader or null at bootstrap level
getResource(name) Which visible resource has that name? a URL or null

The table distinguishes each method's action from its possible answer. findLoadedClass does not search the filesystem: it examines what the loader has already defined. findClass, in the base implementation, does not invent a source of bytes: if we want to read from a particular archive, we must provide the behavior. defineClass is protected and final in its commonly used forms: the custom loader invokes it, rather than rewriting its meaning. getResource must also be treated as an optional search, because a null URL is an expected answer.

A modular application adds another level to the reasoning. The module system establishes which packages a module exports and which modules another can read; the loader establishes which definition a class comes from. The two questions interact but do not replace each other. Finding a class's bytes does not imply being able to use any of its public types from any module, or being able to access every private member through reflection. This distinction will be useful when, in the chapter on reflection, we encounter opens and InaccessibleObjectException.

Think of a plugin implementing a shared Service interface. If the application and plugin see the same interface definition thanks to a common loader, the application can receive the instance as Service. If instead the plugin loads its own copy of the interface class, with the same name but a different loader, the cast can fail. Before modifying delegation policy, it is therefore best to decide which APIs are common and which classes must remain isolated. The arrow diagram is more than theory: it explains a concrete rule for designing boundaries between components.

Further reading – Isolation and Trust. Loading bytes received from an external source does not automatically make that code safe. A custom loader is a tool for resolution and type identity, rather than a general sandbox. Even a child first loading choice, sometimes adopted to isolate plugins, requires careful design: it must not allow platform classes to be replaced or shared APIs to be broken. In our example we maintain delegation to the parent; the plugin bytes must nevertheless be considered executable code with the process's permissions.

From the Loaded Class to Reflection

Once it has obtained a Class<?> object, the program can request information about the type and, when the access contract permits, use the Reflection API. The loader answers “which class definition am I using?”; reflection answers questions such as “which constructors, methods or annotations does it expose?”. The difference between type identity and mere equality of names remains essential. If a plugin defines a common interface with a loader incompatible with the application's loader, the two sides may see equal names but different types. Before returning to the Class object in Chapter 24, Chapter 23 proposes optional further reading on the Vector API: it tackles an independent problem and can also be read after reflection.

To Verify

Explain why ArrayList.class.getClassLoader() returns null in the example, which step of the default model allows an application class to be found after the parent fails, and why two loaders can produce distinct types with the same name. Verify the second answer in the directory-loader program, keeping the plugin outside the application's class path: if you put it on the wrong path, which loader finds it? If you can predict and explain the difference, the delegation figure has become a reasoning tool rather than a drawing to remember by heart.

Try the examples

Running the programs requires JDK 25. Download the individual Java files linked in the chapter or the complete example project, which includes instructions and a launcher script. The explanations also compare expected output: predict it before running the program.

Massimiliano Tarquini · CC BY-NC 4.0

Back to the top ↑