Java Module 17 – Modules, packaging and distribution
jlink: custom Java runtime images
Find the modules a Java app needs with jdeps, link a runtime image with jlink, measure it against the full JDK 25, and ship the app with its own runtime.
What you will learn
- Find the modules an application needs with jdeps --print-module-deps
- Build a minimal runtime image with jlink and measure its size
- Package an application with its own runtime image
Before you start
What you’ll learn · Before you start
What you will learn
- Find the modules an application needs with jdeps --print-module-deps
- Build a minimal runtime image with jlink and measure its size
- Package an application with its own runtime image
Before you start
On this page
jlink builds a runtime image: a folder with a java command and only the modules a program needs, instead of
the whole JDK. You give it the program’s modules with --module-path, name the root modules with --add-modules,
and it copies those modules and every module they require into the folder named by --output.
jdeps --print-module-deps lists the JDK modules a program uses, which is the list jlink needs. The image runs on a
computer without any JDK, so a program can ship with exactly the Java it was tested on, and a container or installer
gets smaller. jlink arrived with Java 9 (JEP 282). The JDK used here, Eclipse Temurin 25.0.4.1 for macOS on Apple
silicon, ships without JMOD files, so its jlink copies modules out of the JDK’s own run-time image, a capability that
JEP 493 added and that each JDK build may enable or not.
Find the modules with jdeps
The program is the shop’s billing app from the module lesson, kirana.app and kirana.bill, with one new feature:
--xml exports the bill as XML for the accountant, with the javax.xml.stream API of the java.xml module.
app/src/module-info.java · app/src/module-info.java
/** The shop's program: it prints a bill, or exports it as XML for the accountant. */
module kirana.app {
requires kirana.bill;
requires java.xml;
} app/src/com/example/kirana/app/Main.java
package com.example.kirana.app;
import com.example.kirana.bill.Bill;
import java.io.StringWriter;
import javax.xml.stream.XMLOutputFactory;
import javax.xml.stream.XMLStreamException;
import javax.xml.stream.XMLStreamWriter;
/** Prints the bill; --xml prints it as XML; --split N divides the total between N people. */
public class Main {
public static void main(String[] args) throws XMLStreamException {
Bill bill = new Bill("Sharma Kirana Store")
.add("Basmati rice, 25 kg", 12, 2_450_00)
.add("Toor dal, 30 kg", 4, 4_875_00)
.add("Sugar, 50 kg", 10, 2_250_00);
String option = args.length > 0 ? args[0] : "";
switch (option) {
case "--xml" -> System.out.println(xml(bill));
case "--split" -> System.out.println("Each pays " + bill.totalPaise() / Integer.parseInt(args[1]) + " paise");
default -> System.out.println(bill.lines().size() + " lines, " + bill.total());
}
}
static String xml(Bill bill) throws XMLStreamException {
StringWriter out = new StringWriter();
XMLStreamWriter xml = XMLOutputFactory.newFactory().createXMLStreamWriter(out);
xml.writeStartElement("bill");
for (Bill.Line line : bill.lines()) {
xml.writeStartElement("line");
xml.writeAttribute("quantity", Integer.toString(line.quantity()));
xml.writeAttribute("paise", Long.toString(line.paise()));
xml.writeCharacters(line.item());
xml.writeEndElement();
}
xml.writeEndElement();
xml.close();
return out.toString();
}
} from session import run
# The two modules are compiled and packed as modular JARs, then jdeps lists the modules the application needs, and
# jlink says how this JDK links.
run(
"""
javac -d out/kirana.bill $(find bill/src -name "*.java")
javac -d out/kirana.app --module-path out $(find app/src -name "*.java")
jar --create --file mods/kirana.bill.jar -C out/kirana.bill .
jar --create --file mods/kirana.app.jar --main-class com.example.kirana.app.Main -C out/kirana.app .
jdeps --module-path mods --print-module-deps --module kirana.app
jdeps -summary --module-path mods --module kirana.app
jlink --help | tail -n 2
"""
) Output
# openjdk version "25.0.4.1" LTS
$ javac -d out/kirana.bill $(find bill/src -name "*.java")
$ javac -d out/kirana.app --module-path out $(find app/src -name "*.java")
$ jar --create --file mods/kirana.bill.jar -C out/kirana.bill .
$ jar --create --file mods/kirana.app.jar --main-class com.example.kirana.app.Main -C out/kirana.app .
$ jdeps --module-path mods --print-module-deps --module kirana.app
java.base,java.xml,kirana.bill
$ jdeps -summary --module-path mods --module kirana.app
kirana.app -> java.base
kirana.app -> java.xml
kirana.app -> kirana.bill
$ jlink --help | tail -n 2
Capabilities:
Linking from run-time image enabled
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 find_session.py
- jdeps reads the class files. It found
java.xmlbecauseMainusesXMLStreamWriter, andkirana.billbecause the declaration requires it;-summaryshows the same edges one per line. - The list is the input for jlink. For modular code,
--add-modules kirana.appis enough, because jlink follows therequiresdirectives itself; for a plain JAR, the list goes straight into--add-modules. - This jlink links from the run-time image. Its help ends with the capability line that JEP 493 describes. A JDK
that does have a
jmodsfolder uses those files instead, and its help says “disabled” there.
jdeps has one blind spot to keep in mind from the start: it reports what the class files name, so a module that code only finds at run time, such as a provider of locale data, is missing from its list even when the program needs it.
Link an image and run it
How the lesson's runtime image is made
Text description of the diagram
The diagram shows how the lesson builds a runtime image, from top to bottom.
- The application is two modular JARs in a folder called mods: kirana.app.jar and kirana.bill.jar.
- jdeps --print-module-deps reads them and lists the modules they need: java.base, java.xml and kirana.bill.
- jlink is given --add-modules kirana.app and a launcher called kirana. It takes the application's modules from mods, and java.base and java.xml from the JDK. This JDK has no jmods folder, so jlink copies the JDK's modules from its own run-time image.
- The result is a folder called image: bin/java and the bin/kirana launcher, lib/modules with the four modules, and a release file that lists them. It measured 61.2 MiB, against 293.3 MiB for the whole JDK.
from session import run
# A runtime image of the application and the JDK modules it needs, with a launcher; then what is in it, and how big
# it is next to the whole JDK.
run(
"""
javac -d out/kirana.bill $(find bill/src -name "*.java")
javac -d out/kirana.app --module-path out $(find app/src -name "*.java")
jar --create --file mods/kirana.bill.jar -C out/kirana.bill .
jar --create --file mods/kirana.app.jar --main-class com.example.kirana.app.Main -C out/kirana.app .
jlink --module-path mods --add-modules kirana.app --launcher kirana=kirana.app --output image
image/bin/java --list-modules
image/bin/kirana
image/bin/kirana --xml
ls image image/bin
cat image/release
java Size.java "$JAVA_HOME" image
"""
) Output
# openjdk version "25.0.4.1" LTS $ javac -d out/kirana.bill $(find bill/src -name "*.java") $ javac -d out/kirana.app --module-path out $(find app/src -name "*.java") $ jar --create --file mods/kirana.bill.jar -C out/kirana.bill . $ jar --create --file mods/kirana.app.jar --main-class com.example.kirana.app.Main -C out/kirana.app . $ jlink --module-path mods --add-modules kirana.app --launcher kirana=kirana.app --output image $ image/bin/java --list-modules java.base@25.0.4.1 java.xml@25.0.4.1 kirana.app kirana.bill $ image/bin/kirana 3 lines, Sharma Kirana Store: ₹71,400.00 $ image/bin/kirana --xml <bill><line quantity="12" paise="245000">Basmati rice, 25 kg</line><line quantity="4" paise="487500">Toor dal, 30 kg</line><line quantity="10" paise="225000">Sugar, 50 kg</line></bill> $ ls image image/bin image: bin conf include legal lib man release image/bin: java keytool kirana $ cat image/release JAVA_VERSION="25.0.4.1" MODULES="java.base java.xml kirana.bill kirana.app" $ java Size.java "$JAVA_HOME" image temurin-25.0.4+101.0.LTS 293.3 MiB image 61.2 MiB
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 link_session.py
- Four modules, no more.
--list-modulesand thereleasefile both namejava.base,java.xmland the two modules of the app: everything else of the JDK’s 69 modules stayed out. - A launcher for the app.
--launcher kirana=kirana.appwrotebin/kirana, which starts the main class that the modular JAR records, so users of the image never type a module path. - About a fifth of the JDK. On this build, the image measured 61.2 MiB and the whole JDK 293.3 MiB, by the sizes of their files.
A program that is not modular uses an image the same way: link the JDK modules from jdeps’ list, then start the
program with the image’s own java, as image/bin/java -jar app.jar. The image is not a JDK: it has no javac
and no jlink, only what the four modules contain. The jlink documentation
adds a reminder that matters in production: developers are responsible for updating their custom runtime images, so
every security update of the JDK means linking the image again.
Options you will use
| Option | What it does |
|---|---|
--module-path mods |
Where the application’s modules are; the JDK’s own modules are found without it |
--add-modules kirana.app |
The root modules; everything they require is added too |
--output image |
The folder to create; it must not exist yet |
--launcher kirana=kirana.app |
Writes a start script in bin for a module, or for a module and a main class |
--strip-debug |
Removes debug information, such as the line number tables of every class |
--no-header-files, --no-man-pages |
Leave out the C header files and the manual pages |
--compress zip-6 |
Compresses the classes and resources inside lib/modules, from zip-0 to zip-9 |
--include-locales en,hi |
Keeps only these languages’ data in jdk.localedata, when that module is added |
--bind-services |
Also adds every module that provides a service the image’s modules use |
--generate-cds-archive |
Writes a class-data sharing archive, which helps the image start faster |
--suggest-providers |
Lists the modules that provide a given service, without linking anything |
Check yourself
Check yourself
5 questions about this lesson. Every answer and why it is right is on the page, behind “Show the answer”. Your score stays in this browser.
What each jlink option saves and costs, measured Pro: opens with a Pro, Premium or Ultimate pass
Every option that makes an image smaller removes something, and two of them remove things people miss. This part measures what each option saves on the same application and reproduces what the two costly ones take away.
In this part:
- The same image five ways, measured
- What –strip-debug costs: line numbers in stack traces
- Start-up, and the class-data sharing archive an image lacks
- A starting set of options for a release
1 runnable example (Python) · about 3 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
Modules jdeps cannot see: locale data and service providers, reproduced Pro: opens with a Pro, Premium or Ultimate pass
jdeps reads class files, so it reports what code refers to by name, and nothing that is found only at run time. This part reproduces the most common such gap, locale data, shows what it costs to close it, and explains why the blunt fix fails on this JDK.
In this part:
- Month names in Hindi and Tamil, lost on a small image
- Character sets: the same gap in another module
- Why –bind-services fails here
2 runnable examples (Java, Python) · about 3 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
Linking without JMOD files: the limits, and the smallest-image exercise Pro: opens with a Pro, Premium or Ultimate pass
A JDK that links from its own run-time image is a quarter smaller, and the price is a set of things it refuses to do. This part reproduces two refusals, names the third, and ends with an exercise that brings the lesson’s measurements together.
In this part:
- Two refusals, reproduced
- Exercise: the smallest image for a notice
- Rubric
3 runnable examples (Java, Python) · about 3 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
-
Fresher to mid · Warm-up
What does jlink produce, and how does it decide which modules go into the result?
jlink produces a custom runtime image: a folder with bin/java, the modules in lib/modules, configuration files and a release file, which runs Java programs without a JDK installed. It decides the contents from the root modules given with --add-modules: it resolves them and adds every module they require, directly or indirectly, from the application's --module-path and from the JDK. For a plain JAR, jdeps --print-module-deps lists the JDK modules to name. Options trim the result, such as --strip-debug, --no-man-pages and --compress, and --launcher adds a start script. The image is not a JDK: it holds only what was linked, so it has no javac unless jdk.compiler was added.
-
Mid to senior
Your app prints Hindi month names on your laptop, but in the container built with jlink it prints "Nov" for the same call. Nothing fails. Explain what happened, and fix it without adding much to the image.
-
Mid to senior
To shrink a service's container, a teammate added --strip-debug and --compress zip-9 to jlink. The image is smaller, but stack traces in production now say Unknown Source. Weigh the options and propose what to ship.
-
Senior to staff
Your team ships a server application in containers on a full JDK base image. Someone proposes jlink runtime images instead. When is that worth it, and what must the release process gain so that it stays safe?
Key takeaways
- jlink copies a set of modules, and everything they require, into a runtime image that runs without a JDK.
jdeps --print-module-depslists the JDK modules a program refers to; jlink takes that list.- The lesson’s image measured 61.2 MiB against 293.3 MiB for the whole JDK.
- An image is not a JDK, and it needs linking again for every JDK security update.
References
- JEP 282: jlink, the Java Linker (OpenJDK)
- JEP 493: Linking Run-Time Images without JMODs (OpenJDK)
- The jlink Command (Java SE 25) (Oracle)
- The jdeps Command (Java SE 25) (Oracle)
- JEP 341: Default CDS Archives (OpenJDK)
- Java SE 25 API: ServiceLoader (Oracle)
- Java SE 25 API: java.util.spi.ToolProvider (Oracle)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress