Skip to content
Home Java JPMS Split Package — JVM Fails Fast, Not Silently

JPMS Split Package — JVM Fails Fast, Not Silently

Where developers are forged. · Structured learning · Free forever.
📍 Part of: Advanced Java → Topic 19 of 28
Split packages on module path cause ClassCastException at JVM startup.
🔥 Advanced — solid Java foundation required
In this tutorial, you'll learn
Split packages on module path cause ClassCastException at JVM startup.
  • JPMS enforces strong encapsulation at the JVM level: module-info.java declares dependencies and exposed packages.
  • The module path resolves dependencies eagerly and fails on split packages — no silent surprises.
  • Use exports for direct access and opens for reflective access; never over-export.
✦ Plain-English analogy ✦ Real code with output ✦ Interview questions
Quick Answer
  • JPMS is a deployment unit above JARs, enforcing strong encapsulation at JVM level
  • module-info.java declares dependencies (requires), exposed packages (exports), and services
  • --add-opens grants reflective access at runtime, --add-exports grants compile-time access
  • Split packages (same package in multiple modules) cause boot layer failure — leads to noisy startup errors
  • Performance: module path classloading is slightly faster than classpath due to explicit dependencies
  • Production pitfall: reflection-based frameworks (Spring, Hibernate) need --add-opens for deep introspection — missing these silently breaks DI at runtime
🚨 START HERE

JPMS Quick Debug Commands

Run these commands to diagnose module-related issues in production
🟡

Module not found at startup

Immediate ActionCheck the module path
Commands
java --list-modules | grep module_name
jar --describe-module --file=module.jar
Fix NowAdd the JAR to --module-path or use -p flag. Ensure module name in module-info matches --module.
🟡

Reflective access failure at runtime

Immediate ActionFind which internal package is needed
Commands
java --add-reads java.base=ALL-UNNAMED --add-exports java.base/sun.security.x509=ALL-UNNAMED <main-class> 2>&1 | grep 'Unable to make'
jlink --add-exports java.base/sun.security.x509=ALL-UNNAMED --output myimage
Fix NowAdd --add-opens or --add-exports to JVM arguments. For frameworks, automate with a script that parses error logs.
🟡

Split package error on boot

Immediate ActionIdentify conflicting packages
Commands
jdeps --module-path libs --check <target-module> | grep 'split'
jar -tf both-libs.jar | grep -E 'org/slf4j/impl/.+class$' | sort -u
Fix NowExclude one JAR from module path (put on classpath) or repackage to eliminate overlap.
Production Incident

ClassCastException at Startup: The Hidden Split Package

A microservice fails to start after adding a new HTTP client library; JVM emits a ClassCastException even though both classes appear in the correct JARs.
Symptomjava.lang.ClassCastException: class io.netty.channel.DefaultChannelId cannot be cast to class io.netty.channel.DefaultChannelId (module and classloader mismatch)
AssumptionAssume the issue is a conflicting version of Netty bundled by two dependencies.
Root causeTwo JARs both contain a class in the same package (e.g., org.slf4j.impl). When both are on the module path, the JVM detects a split package and refuses to boot. On the classpath the same configuration would silently pick one version, leading to subtle bugs.
FixUse jdeps --multi-release to inspect module dependency graphs. Add module-info to all JARs that export overlapping packages, or re-package them under unique package names. As a short-term workaround, put one of the conflicting JARs on the classpath (but that loses module encapsulation).
Key Lesson
Split packages are illegal on the module path — the JVM fails fast, not silently.When migrating, ensure every library either has a module-info or is properly shimmed with --add-reads and --add-exports.Use jdeps --module-path before deployment to detect split packages upfront.
Production Debug Guide

Common symptoms and actions to diagnose JPMS-related startup errors

java.lang.module.FindException: Module X not foundCheck that the module is on the module path (not classpath). Use --module-path or -p flag. Verify module name matches exactly.
java.lang.module.ResolutionException: Module Y reads package Z from both module A and module BRun jdeps --module-path --check module.Y on all JARs to find split packages. Re-package one module to avoid overlap.
IllegalAccessError: class com.example.util (in module X) cannot access class sun.reflect (in module java.base does not export)Add --add-exports java.base/sun.reflect=ALL-UNNAMED (or specify the consumer module). Prefer upgrading library to use public JDK APIs.
java.lang.reflect.InaccessibleObjectException: Unable to make field private int Foo.bar accessibleUse --add-opens package/Class=consumer.module. For frameworks: --add-opens java.base/java.lang=ALL-UNNAMED is common but insecure.

If you've ever cracked open a production JVM heap dump and found a third-party library reaching deep into sun.misc.Unsafe, or spent a day debugging a ClassNotFoundException that only appeared when a JAR was repackaged, you already know the pain that JPMS was built to eliminate. The Java Platform Module System, shipped in Java 9 as part of Project Jigsaw after nearly a decade of design work, is the most structurally significant change to the Java platform since generics. It isn't a minor API addition — it's a new unit of deployment that sits above the JAR and below the application.

Before JPMS, the JVM had exactly one accessibility boundary at runtime: public. If a class was public, anyone on the classpath could use it, full stop. That meant internal JDK APIs like sun.reflect and com.sun.* were fair game for library authors who needed performance shortcuts, and there was no tooling that could enforce the architectural boundaries your team drew on whiteboards. The result was a fragile, monolithic JDK and codebases where 'refactoring an internal package' was a multi-sprint project because you never knew who was secretly depending on it.

By the end of this article you'll understand how the module system enforces strong encapsulation at the JVM level, how to author a correct module-info.java for a real multi-module project, how the module path differs from the classpath at the classloader level, where the system genuinely breaks down (split packages, reflective frameworks, legacy migration), and exactly what to say when an interviewer asks you to contrast --add-opens with --add-exports. This is the practical, internals-first guide that the official Javadoc never was.

What is JPMS? — The Core Idea

JPMS introduces a new level of structuring above packages: the module. A module is a named, self-describing collection of code and data that explicitly declares: - What it requires (dependencies on other modules) - What it exports (packages accessible to other modules) - What it provides (service implementations for service interfaces) - What it consumes (uses) of services

This is declared in a module-info.java file placed at the root of the source tree. When compiled, it becomes module-info.class inside the JAR. The JVM uses this metadata to enforce strong encapsulation: a module cannot access another module's internal packages unless they are explicitly exported.

io/thecodeforge/payment/module-info.java · JAVA
1234567891011121314151617
// Example: module-info.java for a payment module in TheCodeForge
module io.thecodeforge.payment {
    requires java.base; // always implicitly required, but good to be explicit
    requires io.thecodeforge.account;
    requires jakarta.persistence;

    exports io.thecodeforge.payment.api;      // public API package
    exports io.thecodeforge.payment.dto;      // DTOs shared over external APIs

    // Keep implementation packages sealed
    // Not exported: io.thecodeforge.payment.internal

    provides io.thecodeforge.common.PaymentGateway with
        io.thecodeforge.payment.StripeGateway;

    uses io.thecodeforge.common.PaymentAuditor;
}
🔥What's the difference between exports and opens?
- exports: Grants compile-time and runtime access to specific packages. - opens: Grants runtime-only reflective access (for frameworks like Spring/Hibernate). Without opens, deep reflection on private fields fails. Example: opens io.thecodeforge.payment.dto to com.fasterxml.jackson.databind;
📊 Production Insight
Don't export everything just because you can.
Over-exporting breaks encapsulation — the whole point of JPMS.
Treat exports like API contracts: once exported, you can't refactor freely.
A common trap: exporting an internal DTO package that then becomes de facto public API — you'll end up with the same coupling problems JPMS was meant to solve.
🎯 Key Takeaway
module-info.java is the new JAR manifest for encapsulation.
exports for direct access, opens for reflective access, and never export internal packages.
The module path is stricter than the classpath — and that's a good thing.
Choosing between export and open
IfFramework (e.g., Jackson) needs to reflect fields in package X
UseUse opens X (or opens X to framework.module)
IfOther modules need to directly call public classes in package Y
UseUse exports Y
IfBoth direct and reflective access needed
UseUse both exports Y and opens Y (or opens Y to ...)

Module Path vs Classpath — How the JVM Loads Modules

When the JVM starts with the module path, it uses a new built-in module layer that merges all discovered module descriptors and resolves dependencies eagerly before any code runs. This is fundamentally different from the classpath approach, where class loading is lazy and the closest match wins.

  • Module path (--module-path or -p): JVM scans all JARs and directories for module-info.class, builds a module graph, checks for split packages, and fails fast on conflicts.
  • Classpath: Classic classloader (URLClassLoader) with linear search — last loaded can override earlier identical packages silently, leading to Heisenbugs.

JPMS also introduces the concept of unnamed module: any code on the classpath becomes part of the unnamed module, which can read all named modules but not the other way around (unless --add-reads is used). This is the bridge during migration.

run.sh · BASH
12345678
# Run with module path
java --module-path mods:libs --module io.thecodeforge.app

# Run with classpath (unnamed module)
java -cp mods:libs io.thecodeforge.app.Main

# Mixed: app on module path, drivers on classpath
java --module-path mods --add-modules io.thecodeforge.app -cp drivers/* io.thecodeforge.app.Main
Mental Model
Think of module path as a dependency graph, not a stack
The module path resolves all dependencies upfront and fails if the graph has edges that violate encapsulation. The classpath is a linear search — no graph validation.
  • Module path: eager resolution, split package detection, strong encapsulation.
  • Classpath: lazy loading, last-loaded-wins, no access control.
  • Unnamed module: a fallback that can read all named modules but its code cannot be read by named modules.
📊 Production Insight
Never mix module path and classpath for the same JAR.
This creates duplicate classes in different classloaders — a classic source of ClassCastException.
If you must keep a legacy library on the classpath, put all JPMS modules on the module path and use --add-reads to bridge them.
The performance difference is negligible, but the safety gain is enormous.
🎯 Key Takeaway
Module path is strict and safe; classpath is lenient and dangerous.
Split packages crash fast on module path but silently corrupt on classpath.
Prefer module path for new code; use classpath only for migration.

Services — Loose Coupling with provides/uses

JPMS supports service loading via ServiceLoader that works across module boundaries. A module declares that it provides an implementation of a service interface, and another declares that it uses that service. The ServiceLoader discovers all implementations from all modules at runtime without compile-time dependency on the implementation class.

This is a departure from the traditional reflection-based discovery (like META-INF/services). The module system enforces that a module cannot provide a service if it doesn't also read the module that exports the service interface.

io/thecodeforge/payment/PaymentGateway.java · JAVA
12345678910111213141516171819
// Service interface in module io.thecodeforge.common
package io.thecodeforge.common;

public interface PaymentGateway {
    PaymentResult process(PaymentRequest request);
}

// Service implementation in module io.thecodeforge.payment
package io.thecodeforge.payment;

public class StripeGateway implements PaymentGateway {
    // ...
}

// Client code using ServiceLoader
ServiceLoader<PaymentGateway> loaders = ServiceLoader.load(PaymentGateway.class);
for (PaymentGateway gateway : loaders) {
    // found implementation from all modules that provide it
}
🔥ServiceLoader vs Reflection
ServiceLoader is the module-safe way to implement plugins. It works with the module system's access control. Reflection on the other hand is blocked by default unless you use --add-opens. Prefer ServiceLoader for extensibility.
📊 Production Insight
ServiceLoader in JPMS only finds implementations from modules that declare provides ... with ....
It does NOT scan the classpath automatically.
If you have a legacy META-INF/services file, it will be ignored unless you combine it with provides in module-info.
We once lost a billing plugin because we forgot to add the provides declaration — the uses was there but the provider was silently missing.
🎯 Key Takeaway
ServiceLoader + provides/uses = module-safe plugin system.
Always pair provides with the uses directive.
Legacy META-INF/services files are ignored by JPMS ServiceLoader — migrate them.

Migration Strategy — Gradual Adoption of JPMS

Migrating a large codebase to JPMS in one go is risky. Oracle recommends a phased approach: 1. Classpath only — add module-info.java but put all JARs on classpath (the module descriptor is ignored). This lets you compile with module path but run with classpath. 2. Run with module path but keep all JARs that are not modularised on the classpath (unnamed module). Use --add-reads and --add-exports to bridge. 3. Make all JARs modularised — either by adding module-info.java to owned JARs or using automatic modules (JARs without module-info become automatic modules, reading all named modules). 4. Enforce strong encapsulation by removing unnecessary --add-exports and --add-reads.

Automatic modules are a temporary relief: a JAR on the module path without module-info becomes an automatic module that exports all its packages and reads all other modules. This can hide split-package issues.

module-info.java (automatic module example) · JAVA
12345
// No module-info.class in the JAR -> automatic module
// The module name is derived from the JAR filename (e.g., guava-31.1-jre.jar -> guava)
// Automatic modules have no control over what they export or read.
// They can read all named modules and all packages are exported to all.
// This is a migration bridge, not a final state.
⚠ Automatic modules can cause confusion
Because automatic modules export everything, they weaken encapsulation. Also, the module name is derived from the JAR filename (after stripping version and extension). If two JARs have the same base name (e.g., jackson-core-2.12.jar and jackson-core-2.13.jar), the module system sees them as the same module and may pick one arbitrarily, causing ClassNotFoundException.
📊 Production Insight
Automatic modules are convenient but dangerous.
They hide split package problems until you convert them to explicit modules.
We had a two-hour outage because an automatic module's name clashed with another — the JVM silently dropped one version.
Always convert owned libraries to explicit modules as soon as feasible.
Use jdeps --generate-module-info to create skeleton module-info from old JARs.
🎯 Key Takeaway
Migrate in phases: classpath → mixed → full modular.
Automatic modules are a migration tool, not a destination.
Test for split packages early: jdeps --check catches most issues.

Common Pitfalls and How to Fix Them

Beyond split packages, the most common production issues with JPMS include: - Transitive dependency on an internal JDK API — libraries that used sun.misc.BASE64Decoder now fail. Fix: use java.util.Base64 or --add-exports. - ClassLoader assumptions — some frameworks assume a single classloader or that all classes are from the classpath. JPMS uses multiple classloaders per module. If you see ClassNotFoundException from a framework that tries to load classes by reflection, add --add-opens and --add-reads. - Module name conflicts — two libraries use the same module name (e.g., com.google.common vs com.guava). The JVM throws an exception. Rename one with a custom module-info or use shaded JARs. - --add-opens security risk — granting ALL-UNNAMED reflective access opens up all packages to all code. In production, restrict to specific modules.

module-info.java (safe opens example) · JAVA
123456789
// Prefer opens to specific consumer modules rather than ALL-UNNAMED
module io.thecodeforge.payment {
    exports io.thecodeforge.payment.api;
    
    // Only Jackson can reflect on our DTOs
    opens io.thecodeforge.payment.dto to com.fasterxml.jackson.databind;
    // Only Hibernate can reflect on our entities
    opens io.thecodeforge.payment.model to org.hibernate.orm;
}
⚠ Don't start with --add-opens ALL-UNNAMED as a habit
That flag essentially reverts to pre-JPMS behaviour. Use it only during migration and tighten later.
📊 Production Insight
The most brittle setup is a mix of classpath and module path with many --add-exports flags.
One flag change can break the entire module graph.
Treat the JVM command line for module flags as infrastructure code — document it, version it, and test in CI.
We automated detection of missing opens by running integration tests with --illegal-access=deny (Java 9–16) or --add-opens tracking (Java 17+).
🎯 Key Takeaway
Fix internal API usage before switching to module path.
Use --add-opens to specific modules, not ALL-UNNAMED.
Automate detection of missing opens with --illegal-access=deny in test.
When a library fails to reflect
IfYou own the library and can modify source
UseAdd opens and exports in module-info.java
IfYou don't own the library but it's modular (has module-info)
UseCreate a JVM argument using --add-opens targeting that module
IfLibrary is non-modular (classpath JAR)
UsePut it on classpath (unnamed module) and use --add-reads if needed
🗂 JPMS Key Concepts Comparison
Understanding the difference between exports, opens, and requires
DirectiveCompile-time accessRuntime direct accessRuntime reflective accessTypical use
exports packageYes (if module is required) YesYes (reflection on public members)Sharing public API types
opens packageNoNoYes (all members including private)Framework reflection (Jackson, Hibernate)
requires moduleYesDepends on exports/opensDepends on exports/opensDeclaring a dependency
requires transitiveYes (propagates to downstream modules)Propagates accordinglyPropagates accordinglyCommon dependencies

🎯 Key Takeaways

  • JPMS enforces strong encapsulation at the JVM level: module-info.java declares dependencies and exposed packages.
  • The module path resolves dependencies eagerly and fails on split packages — no silent surprises.
  • Use exports for direct access and opens for reflective access; never over-export.
  • ServiceLoader works with provides/uses directives in modular mode, not with legacy META-INF/services files.
  • Migrate in phases: classpath → mixed → full modular; automatic modules are a bridge, not a destination.
  • Always restrict --add-opens to specific consumer modules — avoid ALL-UNNAMED in production.

⚠ Common Mistakes to Avoid

    Putting all JARs on module path without verifying module-info
    Symptom

    JVM fails with ResolutionException: Split package error or FindException: Module not found because non-modular JARs don't have module-info, so they become automatic modules with unpredictable names.

    Fix

    Keep non-modular JARs on classpath. Use jdeps to examine each JAR's module status. Convert owned libraries to explicit modules gradually.

    Using --add-opens ALL-UNNAMED in production as a quick fix
    Symptom

    Security vulnerability: all code can reflect into all packages, bypassing strong encapsulation. Also, it hides true module dependencies, making future migration harder.

    Fix

    Resist the urge. For each observed missing reflective access, determine the consumer module and use --add-opens package=consumer.module. Document each flag in a versioned config file.

    Assuming ServiceLoader works like pre-JPMS META-INF/services
    Symptom

    ServiceLoader returns empty even though META-INF/services file exists. This happens because JPMS only respects provides directives, not the legacy file.

    Fix

    Add provides service.Interface with io.thecodeforge.Impl to all modules that should provide implementations. Keep META-INF/services files only as fallback for classpath runs.

    Forgetting to add `requires` for transitive dependencies
    Symptom

    Compilation succeeds but runtime throws NoClassDefFoundError for a class that was supposed to be transitively available.

    Fix

    Use requires transitive on the module that exports the dependency. Example: If module A exports package X that uses types from package Y (in module B), then A should requires transitive B so that any module reading A automatically gets access to B's exported packages.

Interview Questions on This Topic

  • QExplain the difference between --add-exports and --add-opens in JPMS.SeniorReveal
    Use --add-exports when a module needs to access another module's public types at compile time and runtime (e.g., using a public class from an internal JDK package). Use --add-opens when a module needs reflective access to non-public members (private fields, methods) at runtime only — common for frameworks like Spring, Hibernate, Jackson. --add-exports java.base/sun.security.x509=ALL-UNNAMED allows all consumers to access that package's public classes. --add-opens java.base/java.lang=ALL-UNNAMED allows reflection on java.lang internals.
  • QWhat is a split package and why does the JVM reject it on the module path?Mid-levelReveal
    A split package occurs when the same Java package (e.g., com.example.util) is contained in more than one JAR on the module path. On the classpath, the JVM silently picks one and ignores others (last wins). On the module path, the module system must assign each package to exactly one module. If two modules claim the same package, the JVM throws ResolutionException at startup. This is a deliberate design choice to eliminate ambiguity. Fix: Rename packages to be unique to each module, or combine the conflicting JARs into one module.
  • QHow does ServiceLoader work in a modular environment compared to pre-JPMS?SeniorReveal
    In pre-JPMS, ServiceLoader scanned the classpath for META-INF/services files. In JPMS, ServiceLoader only discovers services from modules that declare a provides directive in their module-info.java. The classpath-based scanning is disabled when the application runs fully on the module path. You must declare every provider explicitly. module io.thecodeforge.app { uses io.thecodeforge.spi.Plugin; } module io.thecodeforge.plugin { provides io.thecodeforge.spi.Plugin with io.thecodeforge.plugin.Impl; } If you still need classpath-based discovery, put the providers on the classpath (unnamed module) and use --add-reads.
  • QWhat is an automatic module and when should you use it?Mid-levelReveal
    An automatic module is a JAR placed on the module path that does not contain a module-info.class. The module system assigns it a module name derived from the JAR filename (e.g., commons-lang3-3.12.0.jar becomes module commons.lang3). Automatic modules export all their packages and can read all other modules (both named and other automatic). They are a migration aid. Use automatic modules only during the transition period when you cannot yet add module-info.java to a third-party JAR. They defeat encapsulation and can cause name clashes if two JARs have similar filenames.
  • QHow would you resolve a ClassCastException where two instances of the same class are from different classloaders?SeniorReveal
    This typically happens when the same class is loaded by both the module path and the classpath (unnamed module). For example, if a framework on the classpath loads a class that is also on the module path, the JVM treats them as distinct types. The fix is to ensure all instances of that class come from the same classloader — either both on the module path or both on the classpath. Check your JVM arguments for mixed placement. Also verify no duplicate JARs exist. Use -verbose:class to see which classloader loaded each instance.

Frequently Asked Questions

Can I use JPMS with Java 8 or older?

No. JPMS was introduced in Java 9 and is not backwards compatible with older Java versions. If you need to support Java 8, you cannot use module-info.java. However, you can still design your code with modules in mind (e.g., strict package visibility, no reliance on internal APIs) and add module-info.java later when you migrate to Java 9+.

What happens if I put a modular JAR on the classpath?

The JAR's module-info.class is ignored entirely. The JAR behaves like a regular classpath JAR: all public classes are accessible, no strong encapsulation, no split package detection. This is part of the migration strategy — you can compile with modules but run with classpath initially.

How do I handle a library that uses internal JDK APIs?

First check if a newer version of the library exists that uses public APIs. If not, you need to grant runtime access via --add-exports and possibly --add-opens. For example, --add-exports java.base/sun.security.x509=ALL-UNNAMED. This is a workaround, not a permanent solution.

Does JPMS affect performance?

The module path classloading is slightly faster because dependencies are resolved eagerly and the class loader has less searching to do (due to explicit package to module mapping). In most applications the difference is negligible. The real performance benefit comes from the ability to use jlink to create custom runtime images that include only needed modules, reducing memory footprint and startup time.

Can I export a package to only specific modules?

Yes. Use exports package to moduleA, moduleB; or opens package to moduleC; in module-info.java. This restricts the package's visibility to only the named modules. It's a good practice to limit exposure.

🔥
Naren Founder & Author

Developer and founder of TheCodeForge. I built this site because I was tired of tutorials that explain what to type without explaining why it works. Every article here is written to make concepts actually click.

← PreviousDependency Injection in JavaNext →Java Memory Leaks and Prevention
Forged with 🔥 at TheCodeForge.io — Where Developers Are Forged