The JVM doesn’t just have classes; it has a sophisticated, layered system for finding and loading them, and the most surprising thing is that your code usually can’t load core Java classes directly.
Let’s watch this in action. Imagine you have a simple Java file, HelloWorld.java:
public class HelloWorld {
public static void main(String[] args) {
System.out.println("Hello, world!");
// Let's try to load a core Java class explicitly
try {
Class<?> stringClass = Class.forName("java.lang.String");
System.out.println("Successfully loaded String class: " + stringClass.getName());
} catch (ClassNotFoundException e) {
System.err.println("Failed to load String class: " + e.getMessage());
}
}
}
Compile and run this:
javac HelloWorld.java
java HelloWorld
You’ll see:
Hello, world!
Successfully loaded String class: java.lang.String
"But wait," you might say, "you just said your code can’t load core Java classes directly!" That’s where the nuance comes in. Class.forName("java.lang.String") appears to be your application code loading java.lang.String. However, the Class.forName method itself is part of the Java API, and the classloader that loads java.lang.String is not the one that loaded your HelloWorld class. This is the critical distinction.
The JVM uses a delegation model for class loading, managed by a hierarchy of classloaders. At the top is the Bootstrap ClassLoader. This isn’t a Java class; it’s typically implemented in native code (like C++ in OpenJDK) and is responsible for loading the core Java classes found in rt.jar (or modules in Java 9+). Think of classes like java.lang.Object, java.lang.String, java.lang.System, and the fundamental java.lang.ClassLoader itself. It doesn’t have a parent.
Next in line is the Extension ClassLoader (also known as the Platform ClassLoader in Java 9+). This loader is responsible for loading classes from the jre/lib/ext directory (on older JVMs) or from modules that provide platform-level functionality. It’s a Java class (sun.misc.Launcher$ExtClassLoader) and its parent is the Bootstrap ClassLoader.
Finally, you have the Application ClassLoader (also known as the System ClassLoader). This is the one that loads your application’s .class files. It’s a Java class (sun.misc.Launcher$AppClassLoader) and its parent is the Extension ClassLoader. When you run java HelloWorld, the java command sets up the Application ClassLoader to look for classes in the current directory (or specified by the -cp or -classpath argument).
The delegation model works like this: When a classloader is asked to load a class, it first delegates the request to its parent classloader. Only if the parent classloader cannot find and load the class does the current classloader attempt to load it itself.
So, when HelloWorld (loaded by the Application ClassLoader) calls System.out.println(), it’s actually invoking a method on java.lang.System. The JVM needs to load java.lang.System. The Application ClassLoader receives this request. It delegates to its parent, the Extension ClassLoader. The Extension ClassLoader delegates to its parent, the Bootstrap ClassLoader. The Bootstrap ClassLoader can find and load java.lang.System because it’s a core Java class. Once loaded by the Bootstrap ClassLoader, the class is available to the entire JVM, including your HelloWorld application.
Now, consider Class.forName("java.lang.String") within your main method. This call is made from the HelloWorld class. The Class.forName method, when called with just a class name, uses the classloader that loaded the calling class to perform the lookup. So, the Application ClassLoader is asked to load java.lang.String. It delegates to the Extension ClassLoader, which delegates to the Bootstrap ClassLoader. The Bootstrap ClassLoader finds and loads java.lang.String. The result is then returned to your main method.
This hierarchy is why your application code can’t directly load java.lang.String in a way that bypasses the Bootstrap ClassLoader. If you tried to write your own classloader that didn’t delegate to its parents, you’d quickly run into ClassNotFoundException for almost everything.
The most counterintuitive aspect of this system is how Class.forName(String className) behaves versus Class.forName(String name, boolean initialize, ClassLoader loader). When you use the simpler Class.forName(String className) form, it’s actually a convenience method that implicitly calls Class.forName(className, true, ClassLoader.getCallerClassLoader()). The crucial part is ClassLoader.getCallerClassLoader(). This method dynamically determines which classloader initiated the call to Class.forName. In our HelloWorld example, the main method is running, and its classloader is the Application ClassLoader. So, the Class.forName call effectively becomes Class.forName("java.lang.String", true, applicationClassLoader). The Application ClassLoader then starts the delegation chain, which eventually leads to the Bootstrap ClassLoader loading java.lang.String. This is why it appears your application code loaded it, but the underlying mechanism is still the delegated hierarchy.
The next common point of confusion is understanding how custom classloaders interact with this hierarchy, especially when dealing with dependencies or custom plugin systems.