Java · advanced
Java ClassNotFoundException at runtime: compare the launch classpath
A runtime ClassNotFoundException means the JVM can compile against a class but cannot locate it when the application launches. The diagnostic core is comparing the compile-time classpath against the launch classpath and identifying exactly which artifact, module, or classloader chain is missing the requested class.
The symptoms
- •java.lang.ClassNotFoundException is thrown on the first thread that triggers lazy loading of a referenced class, often during service initialization or reflection.
- •The class is present in the IDE or build output (target/classes or build/libs) but absent from the launched JAR, WAR, or container image.
- •Reproducible only in packaged form (fat JAR, shaded JAR, WAR, Docker image) while mvn exec or java -cp from the source tree works correctly.
- •Stack trace points to Class.forName, ClassLoader.loadClass, or a framework's reflective instantiation rather than a direct constructor call.
- •Re-running with -verbose:class shows the failing class is never queried against any registered loader, indicating it is not on any classpath at launch.
Likely causes
- •Shaded or fat JAR lost entries because of a duplicate, overlapping, or filtering ServicesTransformer configuration that dropped the class.
- •Runtime classpath built from a different module set than compile classpath, often after a BOM upgrade or a multi-module Maven/Gradle change that altered runtime scope.
- •A provided or optional scoped dependency at compile time was not included in the packaged artifact or container layer.
- •Classloader hierarchy mismatch: a child loader (WebApp, Spring Boot LaunchedURLClassLoader, OSGi bundle) hides a class that the parent would otherwise resolve.
- •Class name passed to Class.forName uses a JVM internal form (with dots replaced by slashes or a trailing .class) and the wrong loader is queried.
- •Class file present but its package directory casing differs on a case-sensitive filesystem or inside a case-insensitive ZIP entry lookup path.
First ten minutes
- 01Capture the full ClassNotFoundException stack trace and identify the call site that invoked Class.forName or loadClass.
- 02Print the JVM's effective classpath used at launch: System.getProperty("java.class.path") for the application loader, and the URLClassLoader.getURLs() array for custom loaders.
- 03Resolve the package of the missing class and list every archive on the launch classpath to determine which container is expected to host it.
- 04Compare the compile-time dependency graph against the packaged runtime graph using mvn dependency:tree (runtime scope) and the resolved module set from the launcher.
- 05Reproduce locally by launching the exact packaged artifact (java -jar, container entrypoint) and confirm the same exception before any further changes.
- 06Decide whether the class is missing entirely from the artifact, present but unreachable due to a classloader boundary, or present but corrupted/renamed.
Evidence to collect
- •The fully qualified class name from the ClassNotFoundException message.
- •The complete stack trace showing the reflective call site that triggered the lookup.
- •The contents of java.class.path and any custom ClassLoader.getURLs() at the moment of failure.
- •The resolved runtime dependency graph (mvn dependency:tree -Dscope=runtime or Gradle runtimeClasspath configuration).
- •The list of entries inside the packaged artifact (JAR/WAR/image layer) that match the expected package path.
- •The output of -verbose:class filtered for the failing package, to confirm whether any loader ever saw the class.
Where to look
- •Boundary between the build tool's resolved runtime dependencies and the launcher artifact actually placed on disk or in a container.
- •Boundary between the parent ClassLoader and any child ClassLoader established by the framework (Tomcat WebAppClassLoader, Spring Boot LaunchedURLClassLoader, OSGi).
- •Boundary between Maven/Gradle scopes (compile, runtime, provided, optional) and what the chosen packaging plugin includes.
- •Shading or assembly plugin descriptors where transformers can sign, move, or drop classes and resources.
- •Container image layers where COPY or multi-stage builds may have stripped build-only dependencies before runtime.
Diagnostic steps
- 01Reproduce the failure with the packaged artifact, then re-run with -verbose:class and grep for the missing class; absence across all loaders proves the class is not on the launch classpath.
- 02Run mvn dependency:tree -Dscope=runtime (or the Gradle equivalent) and confirm whether the artifact owning the missing class appears with scope runtime; absence here explains the omission.
- 03Inspect the packaged JAR with unzip -l <artifact>.jar | grep <expected/package/path> to confirm whether the class file exists in the archive at all.
- 04If the class is inside the JAR, check the MANIFEST.MF Class-Path attribute and the launcher script's -cp to verify the secondary JARs are reachable from the working directory at runtime.
- 05If a custom ClassLoader is in the stack, print its parent chain via ClassLoader.getParent() and confirm whether the parent would resolve the class if delegation were not blocked.
- 06For shaded artifacts, list the contents of the shade plugin's output and compare against the unshaded dependency; differences in package or signature reveal transformer effects.
- 07For container deployments, inspect the runtime image layer and confirm whether the dependency directory was copied; a builder-only COPY instruction is a common cause.
Common mistakes
- •Assuming the IDE's classpath matches the launch classpath; IDEs resolve provided and test-scope artifacts that the packaged runtime does not.
- •Adding the missing dependency at compile scope only, leaving the packaged runtime graph unchanged and the same exception recurring.
- •Chasing the wrong classloader: when the class is absent from every loader, adding a custom parent will not help because no loader has the bytes to begin with.
- •Modifying shaded JAR output by hand or repackaging without rerunning the build, masking the real cause behind a one-off fix.
- •Comparing WAR contents to source tree instead of to the resolved runtime dependency graph, missing a transitive that was excluded by packaging rules.
Safe fixes
- •If the dependency is missing from runtime scope, add it to the runtime configuration (Maven <scope>runtime</scope> or Gradle runtimeOnly) and rebuild the packaged artifact.
- •If a shaded transformer dropped the class, adjust the maven-shade-plugin ServicesTransformer or Filters configuration to preserve the affected packages and rebuild.
- •If the classloader hierarchy is the cause, set the framework loader to parent-first delegation (Tomcat <Loader delegate="true"/>) only after confirming the parent actually contains the class.
- •If the container image omits the dependency, update the Dockerfile so runtime layers include the directory produced by the build stage, not only build-time caches.
- •For Class.forName name format errors, pass the binary name (dots, no trailing .class) and explicitly supply the ClassLoader that owns the class.
Prove the fix
- 01Re-launch the exact packaged artifact and confirm the ClassNotFoundException no longer appears in stderr or application logs over a full startup cycle.
- 02Run with -verbose:class and confirm the previously missing class is now reported as loaded by the expected loader.
- 03Re-run mvn dependency:tree -Dscope=runtime (or the Gradle equivalent) and confirm the owning artifact is present in the runtime graph with the expected scope.
- 04Inspect the packaged archive with unzip -l and confirm the class entry exists at the expected package path.
- 05Repeat the launch inside the target container image and confirm the same class resolves under the same loader chain.
Prevention and next steps
- •Treat compile-time and runtime dependency graphs as separate artifacts; gate releases on a diff of the runtime graph and the packaged archive contents.
- •Add a startup self-check that loads a representative set of classes via Class.forName(name, true, loader) and fails fast with a clear message listing the effective classpath.
- •Pin shading and assembly plugin configurations in version control and review transformer changes with the same rigor as code changes.
- •Standardize container builds so the runtime image is reconstructed from the same dependency graph the build produced, with no manual layer copying.
Safe commands and checks
unzip -l <artifact.jar> | grep <expected/package/path> java -verbose:class -jar <artifact.jar> 2>&1 | grep <fully.qualified.ClassName> mvn -q dependency:tree -Dscope=runtime jar tf <artifact.jar> | grep <expected/package/path> java -cp <artifact.jar> -Xshare:off -verbose:class <main.class> 2>&1 | head -n 200