Why architecture matters here

Class loading is where the JVM's late-binding promise is implemented, and its architecture matters because it is the load-bearing wall under three things production systems need. Isolation: an application server running many webapps, or an agent platform running many plugins, must let each carry its own version of Jackson or Guava without conflict. The only mechanism Java offers for that is separate defining loaders — same class name, different loader, different class. Get the loader topology wrong and you get version hell; get it right and independent teams deploy independently on one JVM.

Lazy startup and memory: the JVM loads a class the first time code actually references it, not when the jar appears on the path. A large service ships with fifty thousand classes on the classpath but may load eight thousand; laziness is why that is affordable. It also means class loading errors surface at first-use time, deep in a request, not at deploy time — an architectural trade every operator should know they are living with. Dynamism: instrumentation agents, bytecode-generating proxies, hot-reload developer loops, and plugin marketplaces all work by participating in loading — transforming bytes before definition or defining classes at runtime.

The costs are equally structural. Loader-induced identity means frameworks passing objects across isolation boundaries must share the classes of that boundary through a common parent — the design of 'what is shared vs isolated' is a real architecture decision. Initialization order is lazy and lock-guarded, so cyclic static initializers can deadlock two threads that each hold one class's init lock. And loaders pin everything they define: one forgotten reference to a webapp's loader after undeploy retains every class and static field of the app — the canonical slow leak of redeploying servers.

Advertisement

The architecture: every piece explained

Top row — the built-in hierarchy. The bootstrap loader is part of the VM itself (it appears as null in the API) and defines the core: java.base — Object, String, the primitives' box types. The platform loader defines the rest of the JDK's modules (SQL, XML, crypto providers) — since JDK 9 these load from the modular runtime image, not a monolithic rt.jar. The application loader defines your code from the classpath and module path. Delegation runs upward: when the app loader is asked for a class, it asks the platform loader, which asks bootstrap; only if the parents miss does the asked loader search its own sources via findClass. With JPMS, delegation is module-aware — a class in a named module is loaded by the loader to which that module is mapped, and module layers let containers stack whole module graphs with controlled visibility.

Middle row — from bytes to usable class. Loading reads the classfile bytes and calls defineClass, creating the runtime type keyed to the defining loader. Verification proves the bytecode is well-formed and type-safe — the reason a hostile classfile cannot forge pointers. Preparation allocates static fields at default values. Resolution — lazy in HotSpot — turns symbolic constant-pool references into direct ones on first use; this is where NoSuchMethodError emerges when compile-time and runtime jars disagree. Finally initialization runs <clinit> — static initializers — exactly once, under a per-class init lock, triggered by first active use (new, static access, reflection). The JLS pins this ordering; everything before init is invisible to your code.

Bottom rows — identity and storage. Runtime identity is the (name, loader) pair: instanceof, casts, and method-type checks all compare defining loaders, which is exactly how isolation works and exactly why passing a plugin-loaded object to host code expecting 'the same' interface from another loader explodes. Class metadata — method tables, constant pools, bytecode — lives in metaspace, native memory outside the heap, bounded by MaxMetaspaceSize. Metadata is freed only when its defining loader, all its classes, and all their instances become unreachable together; GC then unloads the whole cohort. The ops strip earns its place: class-load and unload JFR events, metaspace occupancy, and CDS (Class Data Sharing) archives that map pre-parsed class metadata into memory at startup, shaving hundreds of milliseconds and sharing pages across JVMs on a host.

JVM class loading — loader hierarchy + delegation + linkage + metaspacewho defines a class determines what it isBootstrap loaderjava.base, native, no parentPlatform loaderJDK modules beyond baseApplication loaderclasspath + module pathCustom loadersplugins, webapps, isolationLinkage pipelineload, verify, prepare, resolveInitializationclinit, once, under lockRuntime identityclass = (name, defining loader)Metaspaceclass metadata, unloads with loaderOps — CDS archives + class-load JFR events + metaspace monitoring + leak forensicsdelegates updelegates upparentfindClass on missthen initdefinesallocates
Class loading: requests delegate up the loader hierarchy, misses come back down to findClass, and each defined class is linked, initialized, and stored in metaspace keyed to its defining loader.
Advertisement

End-to-end flow

Trace a Spring Boot service from java -jar app.jar to serving a request, watching only classes. The VM bootstraps, defines java.base via the bootstrap loader (fast: CDS maps the archived metadata rather than parsing classfiles), and calls main. Boot's launcher immediately does something instructive: the runnable jar nests its dependencies as jars-within-a-jar, which the JDK's app loader cannot read, so the launcher creates a custom loader that knows the nested-jar format, then runs the real application through it. From here on, 'the application class loader' for your code is Boot's, parented to the JDK's.

The application references ObjectMapper for the first time during context startup. Boot's loader delegates up: platform and bootstrap miss, so it searches nested jars, finds the classfile in jackson-databind, and defines it. Verification passes; preparation allocates statics; resolution waits. First construction triggers <clinit> under the init lock — module registration, default caches — and the class is live, its metadata in metaspace, keyed to Boot's loader. Meanwhile a mocking or observability agent registered a ClassFileTransformer: every classfile's bytes pass through it before defineClass, which is how tracing libraries weave spans into methods with no source change.

Now the interesting seam: the service loads plugins at runtime, each in its own URLClassLoader whose parent is the app loader. Plugin A bundles Guava 31, the host ships Guava 33. When plugin code references a Guava class, delegation asks the host first — and finds Guava 33. If the plugin needs its own version, its loader must be parent-last for that package (search self before parent), a deliberate inversion the plugin framework implements. The contract interfaces the host and plugin share, though, must come from the common parent, or the host cannot hold references to plugin objects: the (name, loader) identity rule dictates the entire sharing design. On plugin undeploy, the framework drops the loader; the next GC that finds no live instances, no cached references, and no lingering threads with the plugin's context loader unloads all of its classes and returns the metaspace.