W3docs

Java Class Loading

How the JVM finds and loads classes with class loaders — bootstrap, platform, system, and custom loaders.

Java Class Loading

Before the JVM can run a single line of your code, it has to find the .class file, read its bytecode, verify it, and turn it into a live Class object in memory. That job belongs to a class loader. Class loading is what makes java.lang.String available without you doing anything, what lets a JAR on the classpath show up at runtime, and what powers plugin systems, application servers, and hot-reload tools. This chapter shows how the loaders are organized, how delegation works, and why a class's identity is more than just its name.

The class-loader hierarchy

Loaders are arranged as a chain of parents, each responsible for a different source of classes. On a modern JDK (9+) there are three built-in loaders:

LoaderLoadsReported as
BootstrapCore JDK classes (java.*, javax.* base modules)null
PlatformThe rest of the JDK platform modulesa PlatformClassLoader
System / ApplicationYour code from the classpath/module pathan AppClassLoader

Every class remembers the loader that defined it. You can ask any class which loader produced it:

ClassLoader appLoader = MyApp.class.getClassLoader();   // AppClassLoader
ClassLoader strLoader = String.class.getClassLoader();  // null = bootstrap
ClassLoader parent    = appLoader.getParent();          // PlatformClassLoader

The bootstrap loader is written in native code, not Java, which is why String.class.getClassLoader() returns null rather than an object — there is no Java ClassLoader instance to hand back.

The delegation model

Class loaders follow the parent-first delegation model. When asked to load a class, a loader does not immediately try to find it. It first asks its parent, which asks its parent, all the way up to bootstrap. Only if no ancestor can supply the class does the original loader attempt to define it itself.

// Conceptual shape of ClassLoader.loadClass:
protected Class<?> loadClass(String name, boolean resolve) {
  Class<?> c = findLoadedClass(name);     // already loaded? reuse it
  if (c == null) {
    try {
      c = parent.loadClass(name);         // delegate UP first
    } catch (ClassNotFoundException e) {
      c = findClass(name);                // only now load it myself
    }
  }
  return c;
}

This delegation guarantees that core types are loaded once, by the highest loader that can supply them. It is why you cannot override java.lang.String by dropping your own String.class on the classpath — the bootstrap loader claims the name first.

Loading, linking, initialization

Bringing a class to life happens in three phases, and they are not the same thing:

  • Loading — read the bytecode and create the Class object.
  • Linkingverify the bytecode is well-formed, prepare static fields with default values, and resolve symbolic references.
  • Initialization — run static initializers and static field assignments (the class's <clinit> method).

The key practical fact: initialization is lazy and happens exactly once. A class is only initialized on first active use — the first new, the first static method call, or the first read of a non-constant static field.

class Config {
  static final Map<String, String> SETTINGS = load();  // runs once, on first touch
  static Map<String, String> load() {
    System.out.println("Config initialized");
    return Map.of("env", "prod");
  }
}
// "Config initialized" prints only when Config is first actively used.

Custom class loaders

You can extend ClassLoader to load classes from anywhere — a database, a network stream, generated bytecode, or an encrypted JAR. The two methods that matter are findClass (locate and define the bytes) and defineClass (hand the raw bytes to the JVM, which returns a Class).

class BytesLoader extends ClassLoader {
  private final byte[] bytecode;
  BytesLoader(byte[] bytecode) { this.bytecode = bytecode; }
  @Override
  protected Class<?> findClass(String name) {
    return defineClass(name, bytecode, 0, bytecode.length);
  }
}

URLClassLoader is the built-in version of this idea — point it at JARs or directories and it loads classes on demand:

URL jar = Path.of("plugin.jar").toUri().toURL();
try (URLClassLoader loader = new URLClassLoader(new URL[]{ jar })) {
  Class<?> plugin = loader.loadClass("com.example.Plugin");
  Object instance = plugin.getDeclaredConstructor().newInstance();
}

Class identity: name plus loader

Here is the subtlety that trips people up: a class's runtime identity is its fully-qualified name and the loader that defined it. Load bytes for Widget through two different loaders and you get two distinct Class objects — not equal, not assignment-compatible — even though both came from identical bytecode. This is exactly how application servers isolate two deployed apps that both ship a class called com.acme.Util.

A worked example: loaders, delegation, laziness, and identity

This program needs no external classes — it uses the loaders already present in any JVM. It walks the loader chain, proves core classes come from the bootstrap loader, shows delegation returning the same Class object, watches a static initializer fire lazily and only once, then defines the same hand-built bytecode through two loaders to prove the name-plus-loader identity rule.

java— editable, runs on the server

What to take from the run:

  • The printed loader chain is the live class-loader hierarchy with your code at the bottom: ClassLoadingDemo was defined by an application-level loader whose getParent() is the next loader up. Each loader knows only its parent, and the chain always climbs toward bootstrap.
  • String.class.getClassLoader() prints null, the JVM's way of saying "loaded by the bootstrap loader." Core JDK types always report null here; an object would imply they came from a lower loader, which they never do.
  • app.loadClass("java.lang.StringBuilder") == StringBuilder.class is true. Delegation sent the request up to the loader that already owns StringBuilder, so you got back the identical Class object, not a duplicate — proof that delegation prevents the core types from being loaded twice.
  • Lazy <clinit> running prints once, between the --- referencing Lazy now --- marker and the first Lazy.VALUE = 42, and never again on the second read. Initialization is lazy (it waited until first use) and idempotent (the static block runs exactly once per loader).
  • a and b are both named Widget yet a == b is false and a.isAssignableFrom(b) is false. Two loaders defined the same bytecode into two distinct types — concrete proof that runtime class identity is fully-qualified name plus defining loader, the mechanism behind classpath isolation in app servers.

Practice

Practice

Two separate custom class loaders each load identical bytecode for a class named 'com.acme.Widget'. What is true of the resulting Class objects a (from loader 1) and b (from loader 2)?