Command Palette

Search for a command to run...

PHASE 15Advanced Java 9+ ~28 min· topic 3 of 6

Topic 15.3

The Module System (JPMS)

In one line

The Java Platform Module System (JPMS, Java 9) groups packages into named modules described by a module-info.java file. A module states which modules it requires and which packages it exports; everything else is hidden, even from reflection, which gives libraries real encapsulation, reliable startup checks and small custom runtimes with jlink.

Think of it like this

Think of a block of flats. Before modules, every flat in Java's building had its doors unlocked: any neighbour could walk into any room, including the kitchen you never meant to share. With modules, each flat (a module) has a front door with a sign listing which rooms visitors may enter (exports), a list of neighbours it relies on (requires), and a separate rule for the plumber who may inspect the pipes (opens, for reflection). Rooms not listed are private, and no visitor gets in.

Words you'll meet

New words in this topic, in plain English. Come back here whenever one feels fuzzy.

Module
A named group of packages with a module-info.java file saying what it needs and what it shares.
module-info.java
The file that declares a module: its name, its requires, exports, opens, uses and provides lines.
requires
A line saying this module depends on, and can read, another module.
exports
A line making the public types in one package usable by code in other modules.
opens
A line allowing other code to use deep reflection, including private members, on a package at runtime.
Module path
Like the class path, but for modules: the list of folders and JARs where the JVM looks for modules.
Unnamed module
The module that all class-path code belongs to. It can read everything and shares everything.
Automatic module
An ordinary JAR placed on the module path. Java gives it a name and treats it as exporting all its packages.
jlink
A JDK tool that builds a small custom Java runtime containing only the modules your application uses.

Step by step

01Two modules, one hidden package

Our example has a library module com.shop.money with an API package com.shop.money and an internal package com.shop.money.internal, and an application module com.shop.app that uses the library. Each module has its own folder named after it, with module-info.java at the top.

Module names follow the reverse-domain convention of packages, usually matching the main package. They must be unique on the module path.

src/com.shop.money/module-info.javawhole filejava
module com.shop.money {
    exports com.shop.money;          // the API: Money
    // com.shop.money.internal is NOT exported: Rounding stays private to this module
}
Two modules, one hidden packagediagram
Rendering diagram…

02Requiring a module

The app module says it needs the money module. Without that line, even exported packages are invisible to it: a module only sees modules it reads.

src/com.shop.app/module-info.javawhole filejava
module com.shop.app {
    requires com.shop.money;         // read the money module (java.base is implicit)
}

03Compile and run on the module path

--module-source-path tells javac the folder layout (one folder per module) and -m names the module to compile; dependencies are compiled automatically. At run time, --module-path replaces -cp, and -m module/mainclass names what to run.

At startup the JVM resolves the module graph: it starts from the root module, follows every requires, and fails immediately if a module is missing or two modules contain the same package (a split package).

terminal
$ javac -d out --module-source-path src -m com.shop.app && java --module-path out -m com.shop.app/com.shop.app.Main
── expected output ──
total: Rs 248.50
module: com.shop.app

04What the compiler says when you cross the line

If the app imports com.shop.money.internal.Rounding, compilation fails even though Rounding is a public class. If the app forgets requires com.shop.money, even the exported API is invisible. Both messages are precise about the reason.

terminal
$ javac -d out-app --module-path out/com.shop.money src/com.shop.app/module-info.java src/com.shop.app/com/shop/app/Main.java
── expected output ──
src/com.shop.app/com/shop/app/Main.java:3: error: package com.shop.money.internal is not visible
import com.shop.money.internal.Rounding;
^
(package com.shop.money.internal is declared in module com.shop.money, which does not export it)
1 error

05exports versus opens

exports is about compile-time and normal access: other modules can use the package's public types and their public and protected members. opens is about deep reflection at runtime: other modules may call setAccessible(true) on any member, including private ones, but can't compile against the package unless it's also exported.

Frameworks that fill private fields need opens. A typical Spring or Hibernate application opens its entity package: opens com.shop.app.model to org.hibernate.orm.core;. exports and opens can both be qualified with to, which limits them to named friends.

src/com.shop.app/module-info.javawhole filejava
module com.shop.app {
    requires com.shop.money;
    requires transitive java.sql;                   // users of com.shop.app also read java.sql
    exports com.shop.app.api;                       // public API
    exports com.shop.app.spi to com.shop.plugins;   // only this module may use it
    opens com.shop.app.model to com.fasterxml.jackson.databind;  // reflection for JSON
    uses com.shop.app.spi.PricingRule;              // looks up services
}

06Services: uses and provides

A service is an interface with one or more implementations discovered at runtime. The consumer module declares uses PricingRule; and calls ServiceLoader.load(PricingRule.class). A provider module declares provides PricingRule with FestivalDiscount;. The consumer never names the implementation class, so the two modules stay decoupled.

On the class path the same ServiceLoader reads META-INF/services/<interface name> files instead. JDBC drivers, logging back-ends (SLF4J 2, Topic 15.6) and java.net.spi providers all use this mechanism.

Checkout.javawhole filejava
import java.util.ServiceLoader;

ServiceLoader<PricingRule> rules = ServiceLoader.load(PricingRule.class);
for (PricingRule rule : rules) {                     // every provider on the module path
    price = rule.apply(price);
}

07Inspecting and slimming: jdeps and jlink

jdeps reads compiled classes and reports which modules they depend on, which is the first step when migrating an application or when picking modules for jlink. jdeps --jdk-internals app.jar lists any uses of JDK internal APIs that will break.

jlink links your modules and the JDK modules they need into a standalone runtime image with its own bin/java. Our two modules plus java.base make an image of about 43 MB on Windows; a full JDK 21 is several times larger. Smaller images mean faster container pulls and a smaller attack surface.

terminal
$ jdeps --module-path out -s -m com.shop.app
jlink --module-path out --add-modules com.shop.app --output img --strip-debug --no-header-files --no-man-pages
img/bin/java --list-modules
── expected output ──
com.shop.app -> com.shop.money
com.shop.app -> java.base
com.shop.app
com.shop.money
java.base@21.0.9

Try it yourself

  1. 1

    Ask about another package

    In "Which module is this class in", add System.out.println(base.isExported("java.time")); and System.out.println(base.isExported("sun.nio.ch"));. Predict both (true, false), then run.

  2. 2

    Add a provider

    In "A module descriptor, built in code", add .provides("com.shop.orders.spi.PricingRule", List.of("com.shop.orders.internal.FestivalDiscount")) before build(), and print d.provides(). Note that packages must contain the provider's package or build() throws; here it does.

  3. 3

    Build two real modules

    On your own JDK, create the com.shop.money and com.shop.app folders from the walkthrough, compile with javac -d out --module-source-path src -m com.shop.app, and run with java --module-path out -m com.shop.app/com.shop.app.Main. Then remove requires com.shop.money; and read the compiler error.

Code & diagrams

Which module is this class in, and what may it see? Java 9+ New tab

Your class-path code is in the unnamed module. java.base requires nothing: it is the root every other module depends on.

Sign in to run this example in your browser.

Expected output

Main is in a named module: false
String and List live in: java.base, java.base
java.base exports java.util: true
java.base exports jdk.internal.misc: false
java.base opens java.lang for reflection: false
my module reads java.base: true
java.base requires: []
blocked: class Main cannot access class jdk.internal.misc.Unsafe (in module java.base) because module java.base does not export jdk.internal.misc
A module descriptor, built in code Java 9+ New tab

The builder added "requires mandated java.base" by itself, exactly as javac does for every module-info.java.

Sign in to run this example in your browser.

Expected output

module com.shop.orders {
    requires java.sql
    requires mandated java.base
    requires transitive com.shop.money
    exports com.shop.orders.api
    exports com.shop.orders.spi to [com.shop.payments]
    opens com.shop.orders.model
    uses com.shop.orders.spi.PricingRule
}
all packages: [com.shop.orders.api, com.shop.orders.internal, com.shop.orders.model, com.shop.orders.spi]
Describe a JDK modulebash

uses java.sql.Driver is why JDBC drivers are found automatically through ServiceLoader.

$ java --describe-module java.sql
java.sql@21.0.9
exports java.sql
exports javax.sql
requires java.xml transitive
requires java.logging transitive
requires java.transaction.xa transitive
requires java.base mandated
uses java.sql.Driver
Opening a package for a frameworkjava
// module-info.java of a Spring Boot or Jackson-based service
module com.shop.app {
    requires com.shop.money;
    requires com.fasterxml.jackson.databind;

    exports com.shop.app.api;
    opens com.shop.app.dto to com.fasterxml.jackson.databind;   // Jackson may set private fields here
}

Break it on purpose

Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.

Break #1

Use an internal package of another module

In com.shop.app, import com.shop.money.internal.Rounding and call it, although com.shop.money doesn't export that package.

terminal
$ javac -d out-app --module-path out/com.shop.money src/com.shop.app/module-info.java src/com.shop.app/com/shop/app/Main.java
── what you'll see ──
src/com.shop.app/com/shop/app/Main.java:3: error: package com.shop.money.internal is not visible
import com.shop.money.internal.Rounding;
^
(package com.shop.money.internal is declared in module com.shop.money, which does not export it)
1 error

Break #2

Forget requires

Delete requires com.shop.money; from the app's module-info.java.

terminal
$ javac -d out-app --module-path out/com.shop.money src/com.shop.app/module-info.java src/com.shop.app/com/shop/app/Main.java
── what you'll see ──
src/com.shop.app/com/shop/app/Main.java:3: error: package com.shop.money is not visible
import com.shop.money.Money;
^
(package com.shop.money is declared in module com.shop.money, but module com.shop.app does not read it)
1 error

Myth vs fact

Myth

Java 9 forced every application to become modular.

Fact

Class-path applications run unchanged in the unnamed module. What changed for everyone is that JDK internals became inaccessible to reflection (fully enforced from Java 17).

Myth

Modules are the same as Maven modules.

Fact

A Maven module is a build unit that produces a JAR. A JPMS module is a runtime and compile-time boundary declared by module-info.java. One Maven module usually produces one JPMS module, but neither requires the other.

Myth

exports also allows reflection on private members.

Fact

exports allows normal access to public types. Deep reflection on private members needs opens.

Myth

Modules add versions and solve dependency conflicts.

Fact

JPMS deliberately has no version selection; two versions of the same module can't both be on the module path. Choosing versions stays the build tool's job (Topic 15.4).

Pro corner

Extra depth for experienced readers. New to this? Skip it for now and come back later.

  • ▸

    Split packages (the same package in two modules) are forbidden on the module path, and the JVM refuses to start. On the class path they were silently allowed, which is a common migration blocker for old JARs such as some javax.annotation artifacts.

  • ▸

    The unnamed module can read all named modules, but named modules can't read the unnamed module, so a modular JAR can't depend on a class-path JAR. Automatic modules bridge the gap: they read every module, including the unnamed one.

  • ▸

    --add-exports, --add-opens, --add-reads and --patch-module are runtime and compile-time escape hatches; test runners use --patch-module to put test classes inside the module under test. Add-Opens in a JAR manifest works only for executable JARs launched with java -jar.

  • ▸

    jlink performs whole-program link-time steps such as stripping debug info and compressing resources, and its output is a plain runtime directory you can copy into a FROM scratch-style container image. Combine it with jdeps --print-module-deps to compute the module list for class-path applications too.

Remember this

  1. 1

    Before Java 9, public meant public to the whole world. A library with packages com.shop.money (its API) and com.shop.money.internal (helpers) had to make the helpers public so its own packages could share them, and then every user could call them too. A module adds a level above packages: a public class is only usable outside the module if its package is exported.

  2. 2

    A module is declared in module-info.java at the root of its source tree. requires X; says this module reads module X. exports p; makes public types in package p usable by other modules; exports p to Y; is a qualified export to named modules only. opens p; allows deep reflection (private members) on package p at runtime, which frameworks like Hibernate and Jackson need; an open module opens every package. requires transitive X; passes X on to anyone who requires you. uses/provides … with … declare services for ServiceLoader.

  3. 3

    The JDK itself is split into about 70 modules. **java.base** (java.lang, java.util, java.io, java.time, …) is required implicitly by every module; others include java.sql, java.net.http, java.logging and java.desktop. java --list-modules prints them, and java --describe-module java.sql shows what one exports and requires.

  4. 4

    Code on the class path still works exactly as before: it lands in the unnamed module, which reads every module and exports and opens all its packages. A plain JAR placed on the module path becomes an automatic module, named from its Automatic-Module-Name manifest entry or its file name, which exports everything. This is how the ecosystem migrates gradually, and why most applications today still run on the class path.

  5. 5

    What modules give you: strong encapsulation (internals are truly hidden, from both the compiler and reflection), reliable configuration (the JVM checks at startup that every required module is present, and that no two modules contain the same package, instead of failing with NoClassDefFoundError an hour later), and **jlink**, which builds a trimmed runtime image containing only the modules your app needs, often 40 to 60 MB instead of a full JDK of over 300 MB.

  6. 6

    The most visible effect, even for class-path applications, is that the JDK's own internals are sealed. Since Java 16, sun.* and jdk.internal.* packages can't be reached by reflection without --add-opens, and since Java 17 there is no global switch to undo it (Topic 15.2). sun.misc.Unsafe remains reachable for now through the jdk.unsupported module.

Explain it without notes

01

What problem does the module system solve that packages and access modifiers couldn't?

02

What is the difference between exports and opens?

03

What happens to code on the class path in a modular JDK?

04

What does requires transitive mean, and when should you use it?

Practice

01

Write a program that prints the module name of String, java.util.logging.Logger and your own Main class ("unnamed" if it has none).

02

Write the module-info.java for a library com.acme.cache that exposes com.acme.cache.api, hides com.acme.cache.impl, depends on java.logging, and allows Jackson to reflect on com.acme.cache.api.dto.

03

Using ModuleDescriptor.newModule, build a module named demo that exports demo.api, and print whether build() added java.base to its requires.

Trade-offs

  • ↔

    Modules give real encapsulation and startup checks, but every dependency must be modular or automatic, and reflection-heavy frameworks need opens declarations.

  • ↔

    jlink images are small and self-contained, but each image is tied to one platform and must be rebuilt for JDK security updates.

  • ↔

    Staying on the class path is simpler and what most frameworks expect, but you give up encapsulation of your own internals and get no startup check for missing dependencies.

Done when you can

  • Done when you can write a module-info.java with requires, exports, opens and requires transitive.

  • Done when you can compile and run modules with --module-source-path and --module-path.

  • Done when you can explain the unnamed and automatic modules and why class-path apps still work.

  • Done when you can explain exports versus opens and read the compiler's "not visible" errors.

  • Done when you know what jdeps, jlink and ServiceLoader with uses/provides are for.