Most explanations of the Java Platform Module System stop at module-info.java: requires, exports, opens, uses and provides, and the migration path from the class path. That part is covered in depth in Java Modules, in depth, including the full table of command-line escape hatches and the common resolution errors. This page starts where that one ends, with the part of JPMS that exists while the program runs.

At run time the module system is not a compile-time checker that has gone away. It is a set of live objects, Module, ModuleLayer and Configuration, that the JVM consults on every reflective access and every class load across module boundaries, and that your own code can create. Those objects let a host application load plugins in isolated layers, run two versions of a library side by side, and decide exactly which packages each plugin may see. All output below comes from programs run on JDK 23, with local paths shortened to /work.

Advertisement

The module graph is a run-time object

Every class belongs to a Module, which you get from Class.getModule(). A named module carries its descriptor and answers questions such as isExported, isOpen and canRead; these are the same checks the JVM applies when code accesses a type or reflects on a member. Classes from the class path belong to their loader's unnamed module, which reads every module and exports all of its packages, which is why legacy code keeps working.

Modules live in layers. At startup the JVM resolves the root modules against the module path and the system image, producing a Configuration, a checked graph of modules and their readability edges, and then defines it as the boot layer, ModuleLayer.boot(), mapping java.base and other platform modules to the built-in class loaders. Your code can do the same thing: resolve a Configuration from a ModuleFinder over some directories, with an existing layer's configuration as parent, and define it as a child layer. Each layer has parent layers, so the result is a directed acyclic graph of layers in which a module can read modules in its own layer or any ancestor.

Module layers: one boot layer, one child layer per plugin versionBoot layer (built by the JVM from --module-path at startup)java.basebootstrap loaderapp.apiexports com.example.apiapp.hostuses Greeterunnamedclass pathreadsLayer A (parent: boot)plugin.greet (v1)provides Greeter, exports nothingown class loaderLayer B (parent: boot)plugin.greet (v2)same module name, same packageown class loaderrequires app.apiConfiguration.resolve() checks each graph; defineModulesWithOneLoader() turns it into live Module objectsServiceLoader.load(layer, Greeter.class) searches that layer first, then its parents
The boot layer holds the platform, the shared API and the host. Each plugin version is resolved into its own child layer with its own class loader, so two modules with the same name and packages can coexist.

Resolution and definition, step by step

Creating a layer takes two calls. Configuration.resolve takes a ModuleFinder for the new modules, a second finder for anything to look up after the parent (usually empty), and a set of root module names. It performs the same checks as startup: every requires must be satisfied, no module may read two modules that export the same package, and the graph must be consistent with the parent. resolveAndBind also adds modules that provide services used by the resolved modules. Failure throws ResolutionException before any class is loaded.

Definition turns the Configuration into Module objects and assigns class loaders. defineModulesWithOneLoader puts every module in the new layer in one fresh loader; defineModulesWithManyLoaders gives each module its own loader; defineModules takes a function from module name to loader for full control. One loader per layer is simplest, but it fails with LayerInstantiationException if two modules in the layer share a package, and it cannot define java.base or packages under java. The static forms of these methods accept several parent layers and return a ModuleLayer.Controller, which can add reads, exports and opens to modules in that layer and enable native access for them. That is the supported way for a host to grant a plugin extra access without command-line flags.

Advertisement

Worked example: two versions of one plugin

A host defines a Greeter service interface in app.api. Two builds of the same plugin module, plugin.greet, implement it; they have the same module name and the same package, which the class path or a single layer would reject. The host resolves each plugin jar into its own layer whose parent is the boot layer, so both read the one shared copy of app.api.

// app.api/module-info.java
module app.api {
    exports com.example.api;            // contains: public interface Greeter { String greet(String name); }
}

// app.host/module-info.java
module app.host {
    requires app.api;
    uses com.example.api.Greeter;
}

// plugin.greet/module-info.java, identical in v1 and v2; only Impl's message differs
module plugin.greet {
    requires app.api;
    provides com.example.api.Greeter with com.example.greet.Impl;
}

The host loops over plugin directories given on the command line, builds a layer for each, and calls ServiceLoader.load(layer, Greeter.class), which finds providers in that layer and then its parents, never on the class path. It then inspects the module it loaded, with imports omitted here.

public class Host {
    public static void main(String[] args) {
        ModuleLayer boot = ModuleLayer.boot();
        for (String dir : args) {
            ModuleFinder finder = ModuleFinder.of(Path.of(dir));
            Configuration cf = boot.configuration()
                    .resolve(finder, ModuleFinder.of(), Set.of("plugin.greet"));
            ModuleLayer layer = boot.defineModulesWithOneLoader(
                    cf, ClassLoader.getSystemClassLoader());
            for (Greeter g : ServiceLoader.load(layer, Greeter.class)) {
                System.out.println(dir + ": " + g.greet("Ada"));
            }
            Module m = layer.findModule("plugin.greet").orElseThrow();
            System.out.println("  " + m.getName() + " in its own loader: "
                    + (m.getClassLoader() != Host.class.getClassLoader())
                    + ", exports com.example.greet: "
                    + m.isExported("com.example.greet"));
        }
        Module self = Host.class.getModule();
        System.out.println("host reads app.api: "
                + self.canRead(Greeter.class.getModule()));
        System.out.println("java.base opens java.lang to host: "
                + Object.class.getModule().isOpen("java.lang", self));
    }
}
javac -d out --module-source-path src -m app.api,app.host
javac -d cls1 -p out v1/plugin.greet/module-info.java v1/plugin.greet/com/example/greet/Impl.java
jar --create --file plugins-v1/plugin.greet.jar -C cls1 .
# same two commands for v2
java -p out -m app.host/com.example.host.Host plugins-v1 plugins-v2

Running it prints:

plugins-v1: v1 says hello to Ada
  plugin.greet in its own loader: true, exports com.example.greet: false
plugins-v2: v2 says hello to Ada
  plugin.greet in its own loader: true, exports com.example.greet: false
host reads app.api: true
java.base opens java.lang to host: false

Both versions loaded and answered in the same JVM, each in its own class loader. The plugin's implementation package is not exported, yet the host used it through the interface: ServiceLoader may instantiate a provider in a package that is not exported, because the provides clause is the grant. The host reads app.api through its requires clause, and java.base does not open java.lang to it, which matters in the next section. Two design rules make this work. The service interface lives in exactly one layer that every plugin layer has as an ancestor; if a plugin bundled its own copy of app.api, its Greeter would be a different class, and the cast would fail with ClassCastException. And plugins see only what their layer and its ancestors contain, so a plugin cannot reach into another plugin's layer.

Services across layers

ServiceLoader.load(ModuleLayer, Class) searches a layer before its parents, and visits parents depth first, each at most once; the documentation's example is a layer L3 with parents L1 and L2, searched in the order L3, L1, L0 (the boot layer), L2. Within a module, providers come in the order of its provides clause. Two consequences follow. A plugin layer that provides a service shadows the same service from the boot layer for any loader created with that layer, which is how a plugin can override a default. And iterating the loader instantiates every provider; ServiceLoader.stream() returns Provider objects whose type() can be examined, for example for an annotation, before get() creates an instance, which keeps a plugin host from constructing plugins it will not use.

The modules in a layer are found with layer.findModule(name) and their loaders with layer.findLoader(name), which is how a host loads a known entry class reflectively instead of using services. How class loaders delegate between layers, and why a layer and its classes can only be unloaded once nothing references its loader, is covered in JVM class loading architecture.

Strong encapsulation: from permit to removed

When JDK 9 shipped, the JDK's own internal packages were encapsulated, but a launcher option, --illegal-access, defaulted to permit: code on the class path could still reflect into JDK internals that existed in JDK 8, with a warning on first use. JEP 396 changed the default to deny in JDK 16. JEP 403, in JDK 17, strongly encapsulated the JDK's internals without exception and removed the option's effect, apart from critical internal APIs such as sun.misc.Unsafe, which stay accessible through the jdk.unsupported module. A small module that tries deep reflection into java.lang.String shows the result on JDK 23; long lines are wrapped.

// module demo { }   -- an empty named module; class com.example.demo.Peek
Field f = String.class.getDeclaredField("value");
try {
    f.setAccessible(true);   // deep reflection into java.base
    System.out.println("opened: value has " + ((byte[]) f.get("hello")).length + " bytes");
} catch (RuntimeException e) {
    System.out.println(e.getClass().getSimpleName() + ": " + e.getMessage());
}
$ java -p out -m demo/com.example.demo.Peek
InaccessibleObjectException: Unable to make field private final byte[] java.lang.String.value
accessible: module java.base does not "opens java.lang" to module demo

$ java --add-opens java.base/java.lang=demo -p out -m demo/com.example.demo.Peek
opened: value has 5 bytes

$ java --illegal-access=permit -p out -m demo/com.example.demo.Peek
Java HotSpot(TM) 64-Bit Server VM warning: Ignoring option --illegal-access=permit; support was removed in 17.0
InaccessibleObjectException: Unable to make field private final byte[] java.lang.String.value
accessible: module java.base does not "opens java.lang" to module demo

Without a grant, setAccessible fails with InaccessibleObjectException and a message naming exactly the missing opens. With --add-opens java.base/java.lang=demo it succeeds. --illegal-access is now ignored with a warning. Every --add-opens you ship is a list of places where a library depends on internals; see Java reflection for the APIs on the other side of this check, such as MethodHandles.privateLookupIn, which obeys the same rules.

Who may grant access at run time

The Module class has addReads, addExports and addOpens, and their rules are strict. addReads and addExports work only when the caller is in the module being changed, so a module can widen its own surface but not someone else's. addOpens works when the module has already opened the package to at least the caller, which lets a framework that was granted access pass it on to a helper module. Code that wants to open a package in another module has three legitimate routes: the command line or the executable jar's Add-Opens manifest attribute, a ModuleLayer.Controller for layers it created itself, and java.lang.instrument's redefineModule for a Java agent. Anything else is an attempt to defeat encapsulation, and the JVM will refuse it.

Diagnostics

Three commands answer most questions about a module graph without writing code. jar --describe-module reads a jar's descriptor. java --show-module-resolution prints each root and every requires and binds edge as the boot layer is resolved; binds lines are service providers pulled in by uses clauses. jdeps --jdk-internals scans a jar for uses of JDK internals, which is the list of --add-opens and --add-exports you will need.

$ jar --describe-module --file plugins-v2/plugin.greet.jar
plugin.greet jar:file:///work/plugins-v2/plugin.greet.jar!/module-info.class
requires app.api
requires java.base mandated
provides com.example.api.Greeter with com.example.greet.Impl
contains com.example.greet

$ java -p out --show-module-resolution -m demo/com.example.demo.Peek
root demo file:///work/out/demo/
java.base binds java.desktop jrt:/java.desktop
java.base binds jdk.localedata jrt:/jdk.localedata
...

The second listing shows java.base binding java.desktop and other modules even for an empty program, because it uses services they provide. Those bindings are why a jlink image built without --bind-services can be smaller than the module graph a full JDK resolves; trimming them deliberately is covered in Java jlink, in depth.

Failure modes

  • ClassCastException between identical names: the service interface was loaded twice, once in the host layer and once bundled inside a plugin, so the types differ.
  • LayerInstantiationException: two modules in a one-loader layer share a package; use defineModulesWithManyLoaders or merge them.
  • ServiceLoader finds nothing: the loader was built with load(Class) and the thread context loader instead of load(layer, Class), or the provider module was never resolved.
  • IllegalCallerException: code called addExports or addOpens on a module it does not own.
  • Plugin cannot be unloaded: a static cache, thread or listener in the host keeps a reference to a plugin class, keeping its loader and layer alive.
  • Flags that silently do nothing: --illegal-access on JDK 17 and later, kept from an old start script.

Trade-offs

Layers give strong isolation and version coexistence using only the JDK, but they are deliberately static: a layer cannot be changed after it is defined, there is no unload API, and replacing a plugin means building a new layer and dropping every reference to the old one. OSGi offers dynamic install, update and dependency management on top of class loaders, at the cost of a framework and its own model. Plain URLClassLoader isolation is simpler but enforces nothing. For most applications the boot layer alone is enough, and layers earn their complexity only in hosts that load third-party code.

What to do next

  1. Run jdeps --jdk-internals on every dependency and record each needed --add-opens next to the dependency that needs it.
  2. Delete --illegal-access from start scripts and container images; it has been ignored since JDK 17.
  3. If you load plugins, put the shared API in one parent layer and resolve each plugin into its own child layer with Configuration.resolve.
  4. Use ServiceLoader.load(layer, type) and Provider.type() so plugins are discovered per layer and instantiated only when chosen.
  5. Grant plugin access through ModuleLayer.Controller rather than global flags, and keep the grants qualified to one module.
  6. Add java --show-module-resolution output to your build logs so a change in the module graph shows up in review.
Key takeaway: At run time JPMS is a graph of Module objects in layers, checked on every cross-module access. A host can resolve a Configuration and define child layers to isolate plugins, even two versions of the same module, as long as the shared API lives in one ancestor layer. ServiceLoader searches a layer before its parents. Since JDK 17 the JDK's internals are strongly encapsulated and --illegal-access is ignored, so access must be granted explicitly through --add-opens, the jar manifest, a layer controller or an agent.