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

  1. 01Capture the full ClassNotFoundException stack trace and identify the call site that invoked Class.forName or loadClass.
  2. 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.
  3. 03Resolve the package of the missing class and list every archive on the launch classpath to determine which container is expected to host it.
  4. 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.
  5. 05Reproduce locally by launching the exact packaged artifact (java -jar, container entrypoint) and confirm the same exception before any further changes.
  6. 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

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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.
  6. 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.
  7. 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

  1. 01Re-launch the exact packaged artifact and confirm the ClassNotFoundException no longer appears in stderr or application logs over a full startup cycle.
  2. 02Run with -verbose:class and confirm the previously missing class is now reported as loaded by the expected loader.
  3. 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.
  4. 04Inspect the packaged archive with unzip -l and confirm the class entry exists at the expected package path.
  5. 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