Skip to content
Java modern java 7 min read

Java 9: Modules (JPMS)

Java 9 introduced the Java Platform Module System (JPMS) — also called Project Jigsaw — which lets you divide your application into named modules with explicit dependencies and controlled visibility. It solves long-standing problems like classpath hell, accidental use of internal APIs, and monolithic runtimes.

Why Modules? The Problem They Solve

Before JPMS, the JDK itself was one giant rt.jar, and any class could reference any other class regardless of whether that was intentional. Two major pain points arose:

  • Classpath hell — duplicate or conflicting JARs on the classpath with no version enforcement.
  • Encapsulation leakage — internal JDK APIs like sun.misc.Unsafe were freely accessible to application code, making them impossible to change without breaking things.

JPMS solves both by making dependencies explicit and by enforcing package-level boundaries at the JVM level, not just at the IDE level.

Note: Modules are optional for application code — you can still compile and run plain JARs on the classpath in Java 9+. JPMS becomes powerful when you deliberately opt in.

Core Concepts at a Glance

ConceptWhat it means
ModuleA named group of packages + a module-info.java descriptor
requiresDeclares a compile-time and runtime dependency on another module
exportsMakes a package visible to other modules
opensAllows deep reflective access to a package at runtime
uses / providesService-loader mechanism inside the module system

Your First module-info.java

Every module is anchored by a module-info.java file placed at the root of the source tree (not inside a package).

src/
  com.example.greet/
    module-info.java
    com/example/greet/Greeter.java
// module-info.java
module com.example.greet {
    exports com.example.greet;   // makes this package visible to other modules
}
// com/example/greet/Greeter.java
package com.example.greet;

public class Greeter {
    public String hello(String name) {
        return "Hello, " + name + "!";
    }
}

Now a second module can declare a dependency on it:

// module-info.java for the consumer module
module com.example.app {
    requires com.example.greet;  // compile-time + runtime dependency
}
// com/example/app/Main.java
package com.example.app;

import com.example.greet.Greeter;

public class Main {
    public static void main(String[] args) {
        Greeter g = new Greeter();
        System.out.println(g.hello("JPMS"));
    }
}

Output:

Hello, JPMS!

Compiling and Running Modular Code

Use --module-source-path to compile all modules at once, then --module to run:

# Compile
javac --module-source-path src -d out $(find src -name "*.java")

# Run
java --module-path out --module com.example.app/com.example.app.Main

Tip: Modern build tools (Maven, Gradle) handle this automatically once a module-info.java is present. You rarely need to write these flags by hand.

exports and qualified exports

By default every package inside a module is strongly encapsulated — nothing outside can see it, even via reflection. You make a package public with exports:

module com.example.library {
    exports com.example.library.api;           // visible to everyone
    exports com.example.library.internal to    // visible only to these modules
        com.example.app, com.example.tests;
}

The second form is called a qualified export. It is perfect for framework internals that should be accessible to sibling modules but not to the public.

requires Variants

module com.example.app {
    requires com.example.greet;           // standard: compile + runtime
    requires transitive com.example.util; // re-exports the dep to YOUR consumers
    requires static com.example.plugin;   // compile-time only (optional at runtime)
}

requires transitive is useful when your API returns types from another module — without it, callers would need to add that module themselves.

opens and Reflection

Strong encapsulation blocks reflection too. Frameworks like Spring, Hibernate, and JUnit use reflection heavily, so JPMS provides opens:

module com.example.app {
    opens com.example.app.model;            // allows deep reflection at runtime
    opens com.example.app.model to org.hibernate.orm; // qualified open
}

The difference between exports and opens:

exportsopens
Normal API accessYesYes
Reflective access (setAccessible)NoYes
Visible at compile timeYesNo (only runtime)

Warning: Avoid opens to everyone (opens com.foo; without a to clause) unless you have a good reason. It defeats the encapsulation JPMS provides.

Services: uses and provides

JPMS has first-class support for the ServiceLoader pattern, replacing manual META-INF/services files:

// In the interface module
module com.example.spi {
    exports com.example.spi;
}
// In the implementation module
module com.example.impl {
    requires com.example.spi;
    provides com.example.spi.Processor
        with com.example.impl.FastProcessor;
}
// In the consumer module
module com.example.app {
    requires com.example.spi;
    uses com.example.spi.Processor;
}
// Runtime lookup — no hard import of the implementation needed
ServiceLoader<Processor> loader = ServiceLoader.load(Processor.class);
loader.forEach(p -> System.out.println(p.getClass().getName()));

This enables clean plugin architectures where the consumer never depends on a specific implementation module at compile time.

The JDK Is Now Modular Too

The platform itself was split into modules. You can list them all:

java --list-modules

The most important ones you will encounter:

ModuleContains
java.basejava.lang, java.util, java.io — always required implicitly
java.sqlJDBC API
java.desktopAWT, Swing
java.loggingjava.util.logging
java.xmlJAXP, DOM, SAX
jdk.jshellThe JShell REPL

Because java.base is implicit, you never need to write requires java.base;.

Once your app is modular, you can use jlink to create a stripped-down JRE containing only the modules your application actually needs:

jlink \
  --module-path $JAVA_HOME/jmods:out \
  --add-modules com.example.app \
  --output myapp-runtime

The resulting myapp-runtime/bin/java is a self-contained runtime — often 30–80 MB instead of the full 300+ MB JDK. This is a major benefit for Docker images and embedded deployments.

Under the Hood

Module Graph and Readability

When the JVM boots a modular application it builds a module graph — a directed graph where each node is a module and each edge is a requires relationship. The JVM then computes readability: module A reads module B if there is a direct or transitive path from A to B. Any attempt to load a class from an unreadable module throws a java.lang.module.FindException at startup, not a NoClassDefFoundError buried deep in a stack trace. Fail fast is the goal.

Unnamed and Automatic Modules

Not all JARs on the module path have a module-info.java. JPMS handles them in two ways:

  • Unnamed module — a JAR on the classpath becomes part of a single unnamed module. It can read all named modules but no named module can requires it.
  • Automatic module — a JAR placed on the module path without a descriptor gets a name derived from its filename (e.g. guava-32.jarguava). It reads every other module and exports all its packages. This eases migration.

Bytecode Impact

module-info.java compiles to a module-info.class file using a special constant-pool entry (Module attribute, introduced in the class file format version 53 / Java 9). The JVM reads this at class-loading time to enforce readability and export rules before any bytecode executes. There is virtually zero runtime overhead for most applications — the module graph is resolved once at startup.

Strong Encapsulation and —add-opens

Legacy code or frameworks that relied on internal JDK APIs (e.g. sun.reflect) may break. You can temporarily work around this with command-line flags:

java --add-opens java.base/java.lang=ALL-UNNAMED --module-path out --module com.example.app/...

This is a migration escape hatch, not a long-term solution. Track the warnings with --illegal-access=warn (Java 11 and earlier) and plan to eliminate them.

Common Pitfalls

  • Forgetting exports — your public class is invisible to other modules until you export its package.
  • Split packages — two modules providing the same package name is illegal and causes a startup error. Refactor to eliminate the overlap.
  • Circular requires — module A requiring B while B requires A is forbidden. Introduce a shared interface module.
  • Mixing classpath and module path carelessly — classes on the classpath go into the unnamed module and cannot be requiresd by named modules.
Last updated June 13, 2026
Was this helpful?