ArchitectMind
  1. Home
  2. Tutorials
  3. Book 1 · Core Java & Modern Java
  4. Volume V
  5. Module Runtime
Book 1 · Volume V — Java Platform Evolution & Advanced Language Features · Topic 1.2

Module Runtime

Module path, class path vs module path, readability and encapsulation

9 min readStage 1 · Foundations6 interview questions
Prerequisites
1.1 JPMS Fundamentals
Covers
Module path · Class path vs module path · Readability · Encapsulation
Related
B1V4 6.2 (setAccessible) · Book 2 (class loaders, layers)
On this page
  1. 1. Concept / Definition
  2. 2. Why It Exists
  3. 3. Core Concepts
  4. 4. How It Works Internally
  5. 5. APIs / Features / Syntax
  6. 6. Practical Examples
  7. 7. Common Problems & Mistakes
  8. 8. Performance & Production Considerations
  9. 9. Architecture & Design Considerations
  10. 10. Interview Questions & Answers

1Concept / Definition

At run time the JVM builds a module graph from the modules it finds on the module path, while everything on the traditional class path is placed in a single unnamed module. Readability edges record which modules may see which, and encapsulation rules decide which packages are accessible through those edges. Together they determine whether each class reference and each reflective access succeeds.

2Why It Exists

For twenty years Java found classes through the class path, a flat list of directories and JARs searched in order. It had no idea which JAR depended on which, so a missing library was discovered only when a class was first used, often in production, and two versions of the same library could both be present, with the first one silently winning. Every public class was usable by every other class, so internal packages such as sun.misc became de facto APIs that the JDK could never change.

The module runtime fixes these problems by making dependencies and boundaries part of what the JVM checks. Each module declares what it requires and what it exports, and the JVM resolves and verifies the whole graph before main runs. That gives reliable configuration, because missing or duplicate modules stop the launch, and strong encapsulation, because internal packages stay internal. It also made it possible to split the JDK itself into about 70 modules and to build small custom runtimes with jlink.

3Core Concepts

Module path

The module path (--module-path / -p) lists directories or JARs treated as modules. A JAR with module-info.class becomes an explicit named module; a JAR without one becomes an automatic module, named from its Automatic-Module-Name manifest entry or its file name, which reads every other module and exports all its packages — a bridge for libraries that haven't been modularised. The application is launched with -m module/mainclass.

Explicit, automatic and unnamed modules
Figure 1 — Explicit, automatic and unnamed modules
How the JVM resolves the module graph at startup
Figure 2 — How the JVM resolves the module graph at startup

Class path vs module path

The class path is a flat, unchecked list: all types are in the unnamed module, all public types are visible to all code, duplicate classes silently shadow each other, and missing dependencies surface only when a class is first used. The module path is checked at startup: missing modules, duplicate modules and split packages stop the launch. The two coexist: the unnamed module reads all named modules, but named modules cannot read the unnamed module — which is why modularisation proceeds bottom-up.

The two ways of supplying code to the JVM
Figure 3 — The two ways of supplying code to the JVM

Readability

Module A reads module B if A requires B, if A reads a module that requires B transitively, or if readability is added at run time (Module.addReads, or --add-reads on the command line). Readability is the precondition for any access: without it, even exported public types are inaccessible. Automatic modules and the unnamed module read everything; named modules read only what they declare.

Encapsulation

Given readability, access to a type requires its package to be exported to the reader (or opened, for deep reflection). Since Java 16 (JEP 396) the JDK's internal packages are strongly encapsulated by default, and since Java 17 (JEP 403) the --illegal-access switch is gone: reflective access to JDK internals throws InaccessibleObjectException unless explicitly granted with --add-opens java.base/java.lang=ALL-UNNAMED (or --add-exports for compile-time access). The supported alternatives — VarHandle, MethodHandles, the FFM API (Java 22) — replace most former uses of sun.misc.Unsafe.

The path to strong encapsulation
Figure 4 — The path to strong encapsulation
A pragmatic migration path
Figure 5 — A pragmatic migration path

4How It Works Internally

Resolution at startup

When the JVM starts, it begins with the root modules, the module named with -m plus any added with --add-modules, and follows their requires clauses through the module path and the system image. The result is a configuration, a graph of resolved modules and readability edges. Resolution fails fast with a clear error if a required module is missing, if two modules have the same name, or if two modules in the graph contain the same package.

Layers and class loaders

The resolved configuration is turned into a ModuleLayer, which maps each module to a class loader. JDK modules are spread across the bootstrap, platform and application loaders; application modules normally go to the application loader. Because a package belongs to exactly one module in a layer, the class loader can find a class by looking up its package's module directly, instead of searching JARs in order as it does for the class path.

Access checks

Access rules are enforced in two places. At link time, when bytecode refers to a class in another module, the JVM checks that the referring module reads the target and that the package is exported to it; otherwise it throws IllegalAccessError. At run time, reflection such as setAccessible(true) checks that the package is opened to the caller's module; otherwise it throws InaccessibleObjectException. Command-line flags such as --add-reads, --add-exports and --add-opens add edges to the graph before these checks run.

5APIs / Features / Syntax

The runtime is controlled through the module descriptor, a handful of launcher options and the java.lang.Module API.

JAVADescriptor and runtime API
// module-info.java
module com.shop.orders {
    requires transitive com.shop.common;   // re-exported to readers
    requires static org.jspecify;           // compile time only
    exports com.shop.orders.api;            // public API
    exports com.shop.orders.spi to com.shop.app;  // qualified
    opens com.shop.orders.model to com.fasterxml.jackson.databind;
    uses com.shop.orders.spi.PricingRule;
}

// inspecting the module graph at run time
Module m = OrderService.class.getModule();
m.getName();                    // "com.shop.orders"; null if unnamed
m.isNamed();
m.getDescriptor().exports();
m.canRead(Object.class.getModule());  // true: all read java.base
ModuleLayer.boot().modules().size();

Launcher options: -p or --module-path adds module locations, -m names the main module, --add-modules adds root modules (for example ALL-MODULE-PATH), and --add-reads, --add-exports and --add-opens grant extra access for compatibility.

6Practical Examples

SHELLRunning and diagnosing modular applications
// Launch on the module path
// $ java -p mods:libs -m com.shop.app/com.shop.app.Main

// Mixed mode: application on the class path, JDK modular (most enterprise apps)
// $ java -cp app.jar:libs/* com.shop.app.Main

// Diagnosing resolution
// $ java -p mods --show-module-resolution -m com.shop.app
// $ java --list-modules
// $ java -p mods --describe-module com.shop.orders

// Temporary escape hatches for a legacy library (document and track removal!)
// $ java --add-opens java.base/java.lang=ALL-UNNAMED \
//        --add-exports java.base/sun.nio.ch=ALL-UNNAMED -cp app.jar Main

// Automatic module name in a library's MANIFEST.MF (stable name before full modularisation)
// Automatic-Module-Name: com.shop.common

The commands in this example cover the full life of a modular application: launching on the module path, the mixed mode most enterprise systems use, diagnosing why a module was or was not resolved, and the temporary flags used to keep a legacy library working. The descriptor below shows the other side, granting a framework the reflective access it needs without opening the whole module.

JAVAOpening a package to a framework
module com.shop.orders {
    requires com.fasterxml.jackson.databind;
    exports com.shop.orders.api;

    // Jackson needs deep reflection on DTOs, but only there
    opens com.shop.orders.api.dto to com.fasterxml.jackson.databind;
}

// Without the opens clause, deserialising a DTO fails with:
// InaccessibleObjectException: Unable to make field private ... accessible:
// module com.shop.orders does not "opens com.shop.orders.api.dto" to
// module com.fasterxml.jackson.databind

A qualified opens ... to keeps the encapsulation benefit: only Jackson can reflect on the DTO package, and other packages stay closed even to it.

7Common Problems & Mistakes

  • InaccessibleObjectException after a JDK upgrade — a library reflects into JDK internals; upgrade the library before adding --add-opens.
  • Named module cannot read the unnamed module — a modular JAR depending on class-path JARs; put those on the module path as automatic modules.
  • Automatic module names derived from file names (guava-33.0.jar → guava) change unexpectedly — libraries should declare Automatic-Module-Name.
  • Split packages when moving JARs to the module path — often from old javax.* or test-fixture JARs.
  • Accumulated --add-opens flags in start scripts that nobody understands — keep a documented list with owners.

8Performance & Production Considerations

Resolving the module graph adds a small cost at startup, measured in milliseconds for typical applications, and the JDK caches the resolution of its own modules in the CDS archive. In return, class lookup on the module path is faster than class path scanning, because the loader knows which module owns each package.

The bigger production gains are elsewhere. jlink builds a runtime that contains only the modules the application needs, which shrinks container images from hundreds of megabytes to tens and reduces the attack surface. Startup failures caused by missing or duplicate modules appear in the first second of a deployment instead of hours later. Keep the list of --add-opens and --add-exports flags short, documented and reviewed at every JDK upgrade, because each one is a compatibility debt that can break in a future release.

9Architecture & Design Considerations

Most Spring Boot and Jakarta EE applications run on the class path: frameworks rely on reflection and classpath scanning, and fat JARs don't map neatly to modules. They still benefit from strong encapsulation of the JDK and from jlink runtimes. Libraries and platforms, by contrast, should publish an Automatic-Module-Name at least, and ideally full descriptors, so consumers can modularise. The key architectural lesson of JPMS — explicit, enforced module boundaries — applies whether or not the enforcement mechanism is JPMS itself.

10Interview Questions & Answers

Q1KnowledgeConcept

What is the unnamed module?

The unnamed module is the module that holds every class loaded from the class path. Each class loader has one, and in a normal application the application class loader's unnamed module contains all your class-path JARs. It reads every other module and exports all of its packages, so class-path code works much as it did before Java 9. The reverse is not true: named modules cannot declare a dependency on the unnamed module, because it has no name to put in a requires clause. This is why existing applications run unchanged and why modularisation usually starts with the lowest-level libraries.

Q2UnderstandingConcept

What is an automatic module?

An automatic module is an ordinary JAR without a module-info.class that is placed on the module path instead of the class path. The JVM turns it into a named module automatically. Its name comes from the Automatic-Module-Name entry in the JAR's manifest or, if there is none, is derived from the file name, for example guava-33.0.jar becomes guava. An automatic module reads every other module, including the unnamed module, and exports and opens all of its packages. It acts as a bridge, so modular code can depend on libraries that have not been modularised yet.

Q3UnderstandingConcept

What does it mean for a module to read another?

Module A reads module B when the module graph has a readability edge from A to B. That edge exists if A declares requires B, if A requires a module that declares requires transitive B, or if it is added at run time with Module.addReads or the --add-reads launcher option. Readability is the first condition for access: even a public type in an exported package is inaccessible if its module is not read. The second condition is that the package is exported, or opened for deep reflection, to the reading module. Every module reads java.base automatically.

Q4ApplicationTricky

Why can't a named module depend on JARs on the class path?

A named module can only depend on modules it can name in a requires clause, and the unnamed module, which holds all class-path JARs, has no name. So the module graph contains no edge from a named module to the unnamed one, and a class reference from modular code to a class-path class fails. The fix is to move the dependency onto the module path, where it becomes an explicit module if it has a descriptor or an automatic module if it does not. This is why migration usually goes bottom-up: libraries first, then the applications that use them.

Q5Senior EngineerConcept

What changed in Java 16 and 17 regarding encapsulation?

Java 9 introduced strong encapsulation but turned it off by default for JDK internals, printing an illegal-access warning instead. Java 16, through JEP 396, made strong encapsulation the default, so reflective access to internal JDK packages failed unless the --illegal-access option relaxed it. Java 17, through JEP 403, removed the effect of --illegal-access entirely. Since then, code that reflects into JDK internals throws InaccessibleObjectException unless each package is explicitly opened with --add-opens, or exported with --add-exports for compile-time access. sun.misc.Unsafe remains accessible through the jdk.unsupported module, but its memory-access methods are being deprecated.

Q6Technical ArchitectScenario

How would you handle a critical dependency that fails on Java 21 because of illegal reflective access?

First check whether a newer version of the dependency supports Java 21, because most widely used libraries fixed these issues years ago; upgrading is the real fix. If no compatible version exists, add the narrowest flag that works, such as --add-opens java.base/java.lang=ALL-UNNAMED for the one package named in the exception, not a long list copied from the internet. Document every flag with the library that needs it, an owner and a ticket. Add a test that runs without the flag, so you notice when it can be removed, and plan to replace the library if it is unmaintained.

Newsletter

Get new tutorials every week

New ArchitectMind tutorials and interview questions, once a week. No spam, unsubscribe anytime.

Almost done! Check your inbox and click the confirmation link to complete your subscription.