Part 3 of 16

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.jarjackson.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-modules or --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

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

  1. Do not add module-info.java yet. Run on the classpath (unnamed module) first.
  2. Fix internal API usages. Replace sun.misc.BASE64Encoder with java.util.Base64, etc.
  3. Update all libraries to minimum Java 11-compatible versions (see Article 2).
  4. Run with --add-opens for any framework that needs reflective access.
  5. Use jdeps to understand your dependency graph.
  6. 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