How it works

The Introduction said the launcher reconstructs, in process, what java -p modulepath -cp classpath -m module/main would have done. This chapter shows how: the shape of the jar it reads, the sequence it runs at start-up, the one class loader it builds, and how it serves classes and resources without holding their bytes.

The executable-jar layout

A launcher jar is an ordinary jar whose Main-Class is the launcher, plus a fixed set of entries the launcher knows how to read:

app.jar
├── META-INF/MANIFEST.MF   # Main-Class: build.jenesis.launcher.Launcher
├── build/jenesis/launcher/…   # the launcher's own classes
├── application.properties   # the descriptor: mainClass, mainModule, classpath, modulepath
└── jars/
    ├── demo.app-0-SNAPSHOT.jar/…   # the application's own module, exploded
    ├── org.slf4j-2.0.16.jar/…   # a modular or automatic dependency, exploded
    └── <group>%2F<artifact>%2F<version>.jar/…   # a dependency that names no module, exploded

Each dependency is exploded into its own subfolder, so nothing is merged: every one keeps its own module-info, META-INF/services files and resources. Each class is then a direct entry of the outer jar, readable with a plain java.util.zip.ZipFile, so there is no nested-jar addressing.

The subfolder name is the file name the dependency had when the build resolved it. The Jenesis build tool names a resolved jar after the module it carries, at the version the closure resolved (org.slf4j-2.0.16.jar), names an aliased jar <alias>-<version>.jar - or <alias>.jar when a module path could not derive that version from the file name - and falls back to the URL-encoded coordinate (<group>%2F<artifact>%2F<version>.jar) for a jar that declares no module at all. It names the application's own jar by the same rule, after its module and the project's version - 0-SNAPSHOT when a project published to Maven declares none - or after its coordinate without a module. The name follows what the jar declares, not where it lands: a jar with an Automatic-Module-Name is named for that module even when it goes on the class path.

Every dependency sits in that one jars/ store, and the descriptor decides how each is read: the modulepath key names the entries resolved as modules - the jars that describe one, when the application is modular - and classpath names the rest. A non-modular application therefore lists every dependency under classpath and builds no module layer at all.

The descriptor

application.properties is the small text file that tells the launcher what to run. The build tool writes four keys:

Key Meaning
mainClass the fully qualified class whose main is invoked
mainModule the module owning mainClass, when the application is modular
classpath the jars/ entries to read as a class path, in search order
modulepath the jars/ entries to resolve as modules; empty when the application is not modular

A project that keeps a dependency private in a module layer also gets that layer's modulepath.<layer> key, and a classpath.<layer> key when the layer holds jars with no module identity. The launcher understands a few more - bundled agents, module-access grants, and signer reconstruction - which are for a jar you assemble yourself. The Reference chapter lists them all.

How a launch proceeds

Running java -jar app.jar starts the launcher's main, which then:

  1. finds itself - it locates the running jar from its own CodeSource and opens it. A packaged jar and an exploded directory of the same layout both work.
  2. reads the descriptor and indexes the entries - it loads application.properties and records the entry names under each jars/<entry>/ subfolder. It also reads each dependency's manifest, and for a jar the modulepath key names, its module-info.class and META-INF/services files, since those describe the module. Class bytes are not read here.
  3. builds one class loader over the entries classpath names. This loader's unnamed module is the analogue of everything a -cp class path would carry. It holds no class bytes, only the index.
  4. reconstructs the module layer, if modulepath names any entries. An in-memory module finder resolves them and defines a child ModuleLayer against the boot layer, mapping every module to that same loader. When a mainModule is declared, the layer grants the launcher access to the main class's package, so main runs even if the package is not exported - exactly as java -m module/Class allows.
  5. invokes main - it sets the thread context class loader, runs any bundled agents, and calls the main method.

Which modules are resolved

The module layer is resolved the way java -m <mainModule> resolves it: the main module is the root, and its requires closure is pulled in, services included. That works only for a self-contained graph - a modular main module over a module path of explicit named modules, with nothing on the class path.

An automatic module breaks that, because it declares no requires, so a named module it uses only internally would never be resolved. A class path breaks it too. In either case the launcher roots every bundled module instead, the in-jar equivalent of --add-modules ALL-MODULE-PATH. You never configure this; the launcher decides from what the jar contains.

One loader, two kinds of module

The reconstruction rebuilds a real module graph over a single class loader - named modules in the child layer and the unnamed module over the class path - which is what one application loader has under java -p modulepath -cp classpath, and what keeps the JDK's own rules:

  • an automatic module can read the class path, while a strict named module cannot;
  • a package owned by a module shadows the same package on the class path.

The in-memory module finder builds a descriptor for each jar the modulepath key names: from its module-info.class, or derived for an automatic module from its Automatic-Module-Name or its file name, with the providers in META-INF/services scanned in. Its version is derived as a module path derives it: the rest of the file name after the first dash followed by a digit, kept only when it parses as a module version. A bundled automatic module therefore reports the identity it would report under java -p, in Module::getDescriptor and in stack traces. The boot layer is immutable, so a fresh child layer is the only way to add modules at run time - and the right one, because they stay real named modules. What these rules mean in practice is the subject of Running & troubleshooting.

A jar that names no module

A dependency that declares neither a module-info nor an Automatic-Module-Name has no name to derive. The build tool gives such a jar a name through a module alias, and it renames the resolved file to <alias>-<version>.jar before packaging, or to <alias>.jar when a module path could not derive that version from the file name. Inside the launcher jar the subfolder carries the same name, and the automatic-module rule derives the declared name - and the version, where there is one - from it. Nothing else is needed.

The launcher also understands a manifest header for a jar that kept its coordinate-encoded name. The module that declared the alias carries, in its own manifest:

Jenesis-Aliases: org.kohsuke.args4j=args4j/args4j

Each entry maps a module name onto the <groupId>/<artifactId> it applies to, matched against the coordinate in a bundled jar's file name. The launcher offers that jar as an automatic module under the declared name, so a requires and a qualified opens naming it both resolve inside the layer. Only a jar with no identity of its own is considered - a jar that names itself keeps its own name - and two names claimed for one jar is an error rather than a choice the launcher makes for you.

Reading the jar on demand

Because every class and resource is a direct entry of the outer jar, the launcher never merges anything into memory or spills it to disk. It opens the outer jar (a ZipFile) or the exploded directory at start-up, indexes the entry names, and reads an entry's bytes only when first needed, discarding them afterwards. Heap use is therefore roughly the size of the entry-name index rather than the dependencies' bytes.

Two details make this transparent to the application:

  • Resources come back as ordinary URLs. The loader hands out standard jar: and file: URLs, so ClassLoader.getResources - and therefore ServiceLoader - works through the JDK's own handlers, with no custom URL scheme to configure.
  • Multi-release jars are honoured. For a dependency whose manifest says Multi-Release: true, the launcher serves the highest META-INF/versions/<n>/ entry the running JVM supports, just as the JDK does for a real jar.
The one lasting cost of reading on demand is open file handles for the process lifetime: the launcher's own ZipFile stays open while the application runs, as it must, and each module layer the application defines from the jar opens it once more. That, and the rest of the launcher's boundaries, are covered in Running & troubleshooting.