skip to content

What happens during module resolution on the module path, and how does it honor module-info compared to the classpath's lookup?

level: seniorimportance: should knowfreq 30%

answer

  1. Resolution = root set → follow requires → transitive module graph
  2. Honored at launch: missing module, duplicate name, split package → fail fast
  3. Access = reads (requires) AND exports; public alone isn't enough
  4. requires transitive re-exports readability to consumers
  5. Classpath: no graph, no validation, lazy first-match lookup

basics

~20 s

At startup the JVM reads each module's module-info, starts from a root module, follows its requires clauses to build a dependency graph, and verifies there are no missing modules, duplicate names, or split packages. The classpath does none of this — it just searches JARs in order when a class is needed.

solid answer

~50 s

On the module path, the JVM runs **module resolution** at launch. It picks a **root set** (the main module from `-m` plus anything from `--add-modules`), reads each module's `module-info`, and walks the `requires` edges transitively to compute the **module graph** — the exact set of modules that will be observable and which module reads which. During this it enforces **reliable configuration**: every required module must be present (else fail), no two modules may have the same name, and no package may belong to more than one module (no split packages) — all checked **before** `main` runs. Accessibility then follows the graph: module B can use a type in module A only if A `exports` that package *and* B `requires` A. The classpath, by contrast, honors nothing in any descriptor; it has no graph, no validation, and resolves a class lazily by scanning entries in order the first time the class is referenced, so problems surface late and access is unrestricted.

code

java · 10 lines
java
module com.example.web {
    requires com.example.core;              // edge in the resolved graph
    requires transitive com.example.model; // consumers of web also read model
    exports com.example.web.handler;       // only this package is accessible
    // com.example.web.internal stays hidden even though its types are public
}

// Resolution at launch fails fast, e.g. if com.example.core is absent:
//   java -p mods -m com.example.web/com.example.web.Main
//   -> Error: module com.example.core not found, required by com.example.web

go deeper

for a junior

Knows the JVM reads module-info and checks dependencies at startup, unlike the classpath which just searches for classes when needed.

for a middle

Describes the root set, transitive requires graph, and the missing/duplicate/split-package fail-fast checks.

for a senior

Explains accessibility as reads + exports, the role of requires transitive, observability/--add-modules, and contrasts with the classpath's lazy lookup.

for a principal

Reasons about module layers and runtime configurations for plugin isolation, designs export/opens surfaces as long-term API contracts, and understands resolution's impact on jlink images and startup.

## Two completely different resolution models ### Classpath: lazy, flat, descriptor-blind The classpath is just an ordered list of locations. There is **no resolution phase**. When the JVM first needs class `C`, it asks the class loader, which scans the classpath entries in order and loads the first `C/.class` it finds. There is no notion of a package belonging to anyone, no declared dependencies, and no validation: a missing class is discovered only when something touches it (`NoClassDefFoundError`), and a duplicate class is resolved silently by position. ### Module path: eager resolution that honors `module-info` A module's `module-info.class` (compiled from `module-info.java`) declares: - `requires X` — this module reads module X. - `requires transitive X` — this module reads X *and* re-exports that readability to its own consumers. - `exports p` — package `p` is accessible to readers (optionally `exports p to M` for a named friend list). - `opens p` — package `p` is open to deep reflection (optionally `to M`). - `provides`/`uses` — service-loader wiring. At **launch**, before `main`, the JVM performs **resolution**: 1. **Root set.** Start from the initial module (the `-m <module>` you launch, or roots added by `--add-modules`, plus `java.base` which everything implicitly requires). 2. **Transitive closure.** For each module in the working set, read its `requires` clauses and add those modules, repeating until no new modules appear. The result is the **resolved module graph**: nodes = modules, edges = `reads` relationships. 3. **Reliable-configuration checks** (fail-fast): - **Missing module**: a `requires` names a module not found on the module path → `FindException`, startup aborts. - **Duplicate module name**: two modules with the same name in the same layer → error. - **Split package**: the same package supplied/exported by two modules → error. (This is the big difference from the classpath's silent first-wins.) - **Cycles** in `requires` are not permitted at declaration level. ## Accessibility = readability + export After resolution, whether code in module B can reference a public type `A.p.T` requires **two** things, both honored from the descriptors: 1. B must **read** A (B `requires A`, directly or transitively), and 2. A must **export** package `p` (to everyone, or specifically to B). If either is missing, you get an `IllegalAccessError` / compile error even though `T` is `public`. "public" alone is no longer enough — this is **strong encapsulation**. The classpath honors none of this: any `public` type anywhere is callable. ## Observability Resolution also defines which modules are **observable/resolved**. A module on the module path that nothing requires (and that isn't a root) may not be resolved at all — it simply isn't in the graph. This is why services or optional modules sometimes need `--add-modules` to be pulled in. The classpath has no equivalent: every entry is always 'there' to be searched. ## Module layers (advanced) The boot layer is created at startup from the module path. Frameworks can create additional **module layers** at runtime (`ModuleLayer`) with their own configuration and loaders — enabling, e.g., plugin systems with isolated module graphs. There is no classpath analogue. ## Summary contrast | Aspect | Classpath | Module path | |---|---|---| | Reads descriptors | No | Yes (`module-info`) | | Resolution timing | None / lazy per-class | Eager, at launch | | Builds a graph | No | Yes (reads edges) | | Validates config | No | Missing/duplicate/split → fail fast | | Accessibility rule | public = usable | reads + exports required | | Optional/unused entries | always searchable | may be unresolved unless added | The through-line: the module path *honors* `module-info` to compute and validate a graph up front and to gate access by it; the classpath ignores all of that and just searches.

  • Why isn't a public type in another module always accessible on the module path?
    Accessibility needs both readability (your module requires the other) and an export of that package. A public type in a non-exported package, or in a module you don't require, is inaccessible — strong encapsulation.
  • What does requires transitive do that plain requires doesn't?
    It re-exports readability: any module that requires yours automatically reads the transitively-required module too. Use it when your public API exposes types from that dependency.
  • Why might a module on the module path not be loaded at all?
    If it's not a root and nothing in the graph requires it, resolution never adds it (it's unresolved). You pull such modules in explicitly with --add-modules.

Module resolution is an event RSVP check done before the doors open: the host confirms every named guest exists, no two have the same name, and no two claim the same seat — then locks in who may talk to whom. The classpath is an open-door party where the bouncer only reacts when someone you can't find is paged mid-event.

saying these in an interview costs you the question

  • Saying public types are always accessible across modules — access needs reads + exports
  • Describing resolution as lazy/per-class — it's eager at launch on the module path
  • Believing every JAR on the module path is automatically resolved (unused, non-root modules may not be)
  • Treating requires and requires transitive as equivalent

context