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.Unsafewere 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
| Concept | What it means |
|---|---|
| Module | A named group of packages + a module-info.java descriptor |
requires | Declares a compile-time and runtime dependency on another module |
exports | Makes a package visible to other modules |
opens | Allows deep reflective access to a package at runtime |
uses / provides | Service-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.javais 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:
exports | opens | |
|---|---|---|
| Normal API access | Yes | Yes |
Reflective access (setAccessible) | No | Yes |
| Visible at compile time | Yes | No (only runtime) |
Warning: Avoid
opensto everyone (opens com.foo;without atoclause) 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:
| Module | Contains |
|---|---|
java.base | java.lang, java.util, java.io — always required implicitly |
java.sql | JDBC API |
java.desktop | AWT, Swing |
java.logging | java.util.logging |
java.xml | JAXP, DOM, SAX |
jdk.jshell | The JShell REPL |
Because java.base is implicit, you never need to write requires java.base;.
jlink: Custom Runtime Images
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
requiresit. - Automatic module — a JAR placed on the module path without a descriptor gets a name derived from its filename (e.g.
guava-32.jar→guava). 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.
Related Topics
- Modern Java (9–21) — overview of all major Java releases and their headline features
- Java 17 LTS Features — sealed classes, pattern matching, and more that build on the modular foundation
- Java 21 LTS Features — virtual threads, record patterns, and the latest LTS additions
- Class Loaders & Class Loading — understand how the JVM finds and loads classes, and how modules change that process
- Packages — the package system JPMS builds on top of
- Access Modifiers —
public,protected,private— and how modules add a fourth layer of visibility