modules
module-info.java, requires, exports, opens. strong encapsulation.
- chapter
- chapter 12.02
- reading time
- 8 min read
a module is a jar with a module-info.java that answers two questions: which modules do i need (requires), and which of my packages may other code use (exports). a package that isn't exported is invisible from outside, public classes included.
that's strong encapsulation: public stops meaning "the whole planet" and starts meaning "public, inside my walls".
the picture
before java 9, public had one setting: everyone. if your library had a public class in com.acme.internal, anyone could import it, and then you could never change it without breaking them. the package name saying "internal" was a polite request. people ignored it (hi, sun.misc.Unsafe).
a module puts a wall around a jar. think of an office building: the floor plan (module-info.java) says which rooms visitors may walk into (exports), and the visitor list says whose offices you're allowed to visit (requires). rooms not on the plan are still there, staff use them all day. you just can't get in from the lobby.
module shop.orders {
requires transitive shop.money; // i need it, and so will anyone who uses me
exports shop.orders.api; // the front door
// shop.orders.internal: not listed, so sealed
}- → shop.orders
- ⇢ shop.money (implied)
- → java.base (always)
import shop.money.Money; import shop.orders.api.Orders; ... Money m = Orders.total(1000); System.out.println(m);
$ javac -d mods --module-source-path src $(find src -name "*.java")$ java -p mods -m shop.app/shop.app.Main$11.80
transitive, shop.app can't even name Money, the type that Orders.total() returns.two words to keep straight:
- reads
- module A reads module B if A
requiresB (or gets it implied, below). you can only use types from modules you read. - exports
- B makes a package accessible to modules that read it. reading isn't enough: the package also has to be exported.
- implied readability
requires transitive Bin A means everyone who reads A also reads B. use it when B's types show up in A's public API (Orders.total()returns aMoney).
everything a module-info can say
there are only a handful of directives. you'll write the first three 95% of the time.
| directive | means |
|---|---|
requires m; | i read module m. checked at compile time and at startup |
requires transitive m; | i read m, and so does anyone who reads me |
exports p; | public types in package p are usable by anyone who reads me |
exports p to m1, m2; | a qualified export: only those modules get p (the JDK does this a lot) |
opens p; | p is open for deep reflection at runtime, private members included |
requires static m; | needed at compile time, optional at runtime (annotations, optional integrations) |
uses S; / provides S with Impl; | the ServiceLoader plugin system, declared up front |
open module m { } | every package is open for reflection. common for apps, rare for libraries |
the module path also gets checked when the JVM starts, not just when you compile. a missing requires dependency is a startup error, not a NoClassDefFoundError an hour into production. reliable configuration, the JEP calls it.
exports vs opens
this is the one that confuses everyone, because there are really two kinds of "access". exports is about compiling and calling: can your code use these types? opens is about reflection: can a framework reach in and touch private fields? jackson filling a private field from JSON, hibernate setting an entity's id, spring injecting into a private field: that's all deep reflection, and it needs opens.
exports shop.orders.internal;
- ✓compile against itimport shop.orders.internal.Secret;compiles.
- ✓reflect on public membersc.getMethod("hello").invoke(null)prints "public method"
- ✗deep reflection on privatem.setAccessible(true) // what jackson, hibernate, spring doInaccessibleObjectException: ... module shop.orders does not "opens shop.orders.internal" to module shop.app
opens alone doesn't let you compile against the package. it's purely a runtime door.the JDK holds itself to the same rules. java.base exports 58 packages to everyone and keeps the other 138 to itself (57 of those are qualified exports to specific JDK modules). so reaching into String's private bytes doesn't work anymore:
void main() throws Exception {
var f = String.class.getDeclaredField("value");
try {
f.setAccessible(true);
IO.println("got in");
} catch (RuntimeException e) {
IO.println(e.getClass().getSimpleName());
}
}InaccessibleObjectException
this one needs a real JVM (catching a runtime exception), so it doesn't run in the browser. copy it into java Main.java to try it.
the JDK is made of modules too
the JDK was the first big project to be modularized (project jigsaw, java 9). it's 69 modules on temurin 25: the java.* ones are the standard API, the jdk.* ones are this JDK's tools and extras.
import java.lang.module.ModuleFinder;
void main() {
var names = ModuleFinder.ofSystem().findAll().stream()
.map(m -> m.descriptor().name())
.toList();
IO.println(names.size() + " modules in this JDK");
IO.println("java.*: " + names.stream().filter(n -> n.startsWith("java.")).count());
IO.println("jdk.*: " + names.stream().filter(n -> n.startsWith("jdk.")).count());
IO.println("named? " + getClass().getModule().isNamed());
}69 modules in this JDK java.*: 22 jdk.*: 47 named? false
that last line: code that isn't in a module (like this file) lives in the unnamed module, which reads everything. that's the compatibility story, and it's the whole next chapter: classpath to module path.
- no code, just 20 requires transitive
- ├── java.baseeveryone reads this one, no requires needed
- ├── exports java.sql, javax.sql
- │ ├── java.logging
- │ ├── java.xml
- │ └── java.transaction.xa
- ├── java.net.httpHttpClient
- ├── awt, swing, java.beans
- │ ├── java.xml
- │ └── java.datatransfer
- ├── java.naming
- ├── java.management
- ├── java.compiler
- ├── java.scripting
- └── ...12 more
java.base is special: every module reads it without saying so (--describe-module prints it as requires java.base mandated). it's where String, List and Thread live.
how strong encapsulation got turned on, one release at a time+
java 9 shipped modules but kept a crowbar: --illegal-access=permit was the default, so old code reaching into JDK internals got a warning and kept working. java 16 flipped the default to deny (JEP 396), and java 17 removed the crowbar entirely (JEP 403): JDK internals are sealed, and --add-opens/--add-exports are the only way through. sun.misc.Unsafe is the famous exception. it lives in the jdk.unsupported module, which still exports it so the libraries that depended on it had time to move to VarHandles and the FFM API. its memory methods have been on the way out since 23 (JEP 471) and warn by default since 24 (JEP 498).
- a module is a jar plus
module-info.java: what it requires, what it exports. - not exported = invisible from outside, even if it's
public. requires transitivewhen the other module's types appear in your API.exportsis for compiling against you.opensis for reflecting into you.- the JDK's own internals are sealed since 17.
--add-opensis a crowbar, not a fix.