Module System (JPMS, JEP 261): Project Jigsaw Deep Dive
The Problem JPMS Solves
Before Java 9, the JDK had no real notion of encapsulation at the library level. public meant accessible to everyone — including internal JDK classes like sun.misc.Unsafe and com.sun.internal.*. Large codebases suffered from:
- No reliable encapsulation: Any public class in any JAR was reachable from any other JAR.
- Classpath hell: Duplicate or conflicting classes from different JARs led to unpredictable behaviour.
- Monolithic JDK: The entire 60+ module JDK had to ship with every application.
- No explicit dependencies: Nothing enforced which JARs a library actually needed at runtime.
The Java Platform Module System (JPMS), delivered by JEP 261 in Java 9, addresses all four.
Core Concepts
A module is a named, self-describing unit of code. Each module declares:
- Its name (
module com.example.app) - Which packages it exports (makes accessible to other modules)
- Which other modules it requires (depends on)
- Which packages it opens for reflection
A module is a JAR with a module-info.class at its root, compiled from module-info.java in the source root.
module-info.java Syntax
module com.example.app {
// Declare a dependency on another module
requires java.sql;
requires java.logging;
// Transitive: consumers of com.example.app also get java.xml
requires transitive java.xml;
// Static: required at compile time only (e.g., annotation processors)
requires static lombok;
// Export a package to all modules
exports com.example.app.api;
// Export a package to specific modules only (qualified export)
exports com.example.app.internal to com.example.tests, com.example.plugin;
// Open for deep reflection (e.g., for frameworks like Spring, Hibernate)
opens com.example.app.model;
// Open to specific modules only
opens com.example.app.model to com.fasterxml.jackson.databind;
// Service consumer: find all implementations of MyService
uses com.example.spi.MyService;
// Service provider: register this module's implementation
provides com.example.spi.MyService with com.example.app.MyServiceImpl;
}
Module Types
The JVM supports three module categories that coexist in any application:
1. Named Modules
JARs with a module-info.class. They get strong encapsulation: only exported packages are accessible.
app.jar/
module-info.class
com/example/app/Main.class
com/example/app/internal/Helper.class ← not exported; inaccessible
2. Automatic Modules
Named JARs (with a valid module name derivable from the JAR filename) placed on the module-path without a module-info.class. Automatic modules:
- Export all their packages
- Require all other modules (reads the entire module graph)
- Get a derived name from the JAR filename (
jackson-databind-2.15.0.jar→jackson.databind)
This is the migration path for third-party libraries that have not yet adopted JPMS.
3. Unnamed Module
Everything on the classpath lives in the unnamed module. It:
- Can read all named modules and all automatic modules
- Cannot be explicitly required by named modules (you must use
--add-modulesor--add-reads) - All its packages are accessible to named modules that use
opens/exports
Most applications today still run primarily from the unnamed module (classpath).
JDK Module Structure
The JDK itself is modularised into ~70 modules. The most important:
java.base ← always required; core Java (java.lang, java.util, java.io)
java.sql ← JDBC, javax.sql
java.xml ← XML parsing (DOM, SAX, StAX, XSLT)
java.net.http ← HTTP Client (Java 11+)
java.logging ← java.util.logging
java.management ← JMX
java.desktop ← AWT, Swing
java.compiler ← javax.tools
jdk.jshell ← JShell API
jdk.jfr ← Flight Recorder
View the full list:
java --list-modules
# java.base@11.0.22
# java.compiler@11.0.22
# java.datatransfer@11.0.22
# ... (70+ modules)
Practical: A Multi-Module Maven Project
myapp/
├── pom.xml
├── core/
│ ├── pom.xml
│ └── src/main/java/
│ ├── module-info.java
│ └── com/example/core/
│ ├── UserService.java ← exported
│ └── internal/CacheImpl.java ← not exported
└── web/
├── pom.xml
└── src/main/java/
├── module-info.java
└── com/example/web/
└── UserController.java
core/module-info.java
module com.example.core {
requires java.logging;
exports com.example.core; // exposes UserService
// com.example.core.internal is NOT exported
}
web/module-info.java
module com.example.web {
requires com.example.core; // depends on core module
requires java.net.http;
exports com.example.web;
}
core/pom.xml (key snippet)
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.12.1</version>
<configuration>
<release>11</release>
</configuration>
</plugin>
</plugins>
</build>
Maven automatically detects module-info.java and switches to module-path compilation when the plugin version is 3.6.0+.
Launching a Modular Application
# Compile
javac --module-path libs/ \
--module-source-path src \
-d out \
$(find src -name "*.java")
# Run
java --module-path out:libs \
--module com.example.web/com.example.web.Main
Split Packages — The Most Common Migration Blocker
A split package occurs when the same package name appears in more than one module. JPMS forbids this for named modules.
Common example: many libraries historically shared the javax.xml.bind package. In Java 11, that package was removed from java.xml.bind and must come from an external JAR — but if two JARs on the classpath both contain javax.xml.bind, you have a split package error.
Symptom:
Error occurred during initialization of boot layer
java.lang.LayerInstantiationException: Package javax.xml.bind in both module java.xml.bind and module jaxb.api
Fix: Ensure only one JAR provides any given package. Use jdeps --module-path to detect conflicts:
jdeps --module-path libs/ --multi-release 11 myapp.jar
–add-opens and –add-exports (Escape Hatches)
Many frameworks (Spring, Hibernate, Jackson) use deep reflection to access private fields. Named modules block this by default. The escape hatches:
# Allow framework to reflectively access private fields of java.base
--add-opens java.base/java.lang=ALL-UNNAMED
--add-opens java.base/java.util=com.fasterxml.jackson.databind
# Expose an unexported package
--add-exports java.base/sun.security.util=ALL-UNNAMED
In Maven Surefire (for tests):
<plugin>
<artifactId>maven-surefire-plugin</artifactId>
<version>3.2.5</version>
<configuration>
<argLine>
--add-opens java.base/java.lang=ALL-UNNAMED
--add-opens java.base/java.util=ALL-UNNAMED
</argLine>
</configuration>
</plugin>
In application startup (JVM flag):
java --add-opens java.base/java.lang=ALL-UNNAMED \
--add-opens java.base/java.util=ALL-UNNAMED \
-jar myapp.jar
Building a Custom JRE with jlink
jlink creates a minimal runtime image containing only the modules your application needs. This is one of JPMS’s most practical benefits for containers.
# Find which modules your JAR needs
jdeps --ignore-missing-deps \
--module-path libs/ \
--print-module-deps \
myapp.jar
# Output: java.base,java.logging,java.net.http,java.sql
# Create a custom runtime image
jlink \
--module-path $JAVA_HOME/jmods:out \
--add-modules java.base,java.logging,java.net.http,java.sql,com.example.app \
--output dist/custom-jre \
--compress=2 \
--no-header-files \
--no-man-pages \
--strip-debug
# Run with custom JRE
dist/custom-jre/bin/java --module com.example.app/com.example.app.Main
Size comparison:
Full JDK 11: ~200 MB
Eclipse Temurin JRE 11: ~85 MB
Custom jlink image: ~35 MB (application-specific)
This is why microservices and container images benefit significantly from JPMS even without full modularisation of application code.
Module Reflection for Libraries
If you publish a library, expose what consumers need to reflect on:
module com.example.lib {
// Open for reflection by any module (e.g., Jackson, JAXB)
opens com.example.lib.dto;
// Open only to Jackson
opens com.example.lib.model to com.fasterxml.jackson.databind;
// Exports the public API surface
exports com.example.lib.api;
}
Practical Migration Strategy for Existing Apps
- Do not add module-info.java yet. Run on the classpath (unnamed module) first.
- Fix internal API usages. Replace
sun.misc.BASE64Encoderwithjava.util.Base64, etc. - Update all libraries to minimum Java 11-compatible versions (see Article 2).
- Run with
--add-opensfor any framework that needs reflective access. - Use jdeps to understand your dependency graph.
- Add module-info.java incrementally, starting with the lowest-level modules in your graph.
Article 15 (Migration Guide) covers this end-to-end.
What’s Next
Next: var Keyword (JEP 286, 323): Local Variable Type Inference