Every Java application ships with a runtime, whether you think about it or not. For years that runtime was a full JDK or JRE: hundreds of megabytes of modules, tools, headers and man pages, most of which your service never loads. Since Java 9, jlink lets you build a runtime that contains only the platform modules you need, plus optionally your own, in a single directory you can copy into a container or bundle with a desktop application.
The basic command is easy. The hard parts are knowing which modules to include, deciding which plugins are worth their cost, keeping the image patched, and diagnosing the failures that appear only at run time when a module you did not link turns out to be needed. This article treats the runtime image as a build artifact you own. Module descriptors, readability and the basics of linking a modular application are covered in Java modules in depth; here the focus is producing, measuring and operating the image.
What jlink actually produces
A runtime image is a directory with the same shape as a JDK: bin/ with the java launcher and any tools whose modules you included, conf/ with security and logging configuration, lib/ with the native libraries and the modules file (a jimage container holding every linked class and resource), legal/ with licence notices, and a release file describing the version and the modules inside.
It is not a native executable. The image still contains HotSpot, the JIT compilers and the garbage collectors, so peak performance and tuning behave exactly as on a full JDK of the same version. That is the main difference from GraalVM native image, which compiles ahead of time into a binary with different start-up, memory and reflection trade-offs. jlink removes what you do not use; it does not change how what remains runs.
jlink reads modules from a module path. By default that is the jmods directory of the JDK you run it from, which holds a packaged copy of every platform module. The resolver starts from the root modules you name with --add-modules, follows requires edges to build the closure, and hands the result to a pipeline of plugins that can strip, compress or generate files before the image is written.
Choosing modules: jdeps first, then the blind spots
You do not need to modularise your application to benefit. The most common production pattern is a JDK-only image: link just the platform modules your code and libraries use, then run the application from the class path exactly as before. jdeps computes the list by analysing bytecode references.
# Ask jdeps which platform modules the application and its libraries reference
jdeps --print-module-deps --ignore-missing-deps \
--multi-release 25 --recursive \
--class-path 'lib/*' app.jar
# prints e.g.: java.base,java.logging,java.net.http,java.sql,jdk.httpserverjdeps sees static references only. Anything loaded by name at run time is invisible to it, and these are the modules people discover in production:
| Need | Module | How the omission shows up |
|---|---|---|
| Locale data beyond English | jdk.localedata | Wrong date or number formats, silently |
| Extra character sets | jdk.charsets | UnsupportedCharsetException on legacy encodings |
| DNS lookups through JNDI | jdk.naming.dns | NamingException in LDAP or service discovery code |
| ZIP file systems | jdk.zipfs | ProviderNotFoundException opening a jar as a FileSystem |
| Flight Recorder | jdk.jfr | Recordings cannot start; profiling disabled |
| jcmd and attach | jdk.jcmd, jdk.attach | No thread dumps or heap dumps in the container |
| JMX remote | java.management.rmi, jdk.management.agent | Monitoring agent fails to start |
| Crypto providers | jdk.crypto.* modules, by JDK version | TLS handshakes fail for some cipher suites |
Treat the table as a review list, not an answer: which crypto modules exist and what lives in java.base has changed across JDK releases, so check java --list-modules on your exact version. Diagnostics deserve a deliberate decision. Leaving out jdk.jfr and jdk.jcmd saves a little space and costs you the ability to profile or dump threads during an incident, which is usually a bad trade for a server. Service providers are another blind spot: jlink does not follow uses and provides unless asked, and --suggest-providers lists candidates without adding everything.
Plugins and what they cost
jlink's own options and plugins, as documented for JDK 25, cover most of what you need. Run jlink --list-plugins on your JDK to see the exact set, because vendors and versions differ.
| Option | Effect | Cost |
|---|---|---|
--strip-debug | Removes debug attributes from classes | JDK frames in stack traces lose line numbers |
--no-header-files | Drops C headers | None for running Java |
--no-man-pages | Drops man pages | None in containers |
--compress=zip-N | Compresses resources in the modules file, N from 0 to 9 | Decompression work when classes load |
--include-locales=en,de | Keeps only the named locales from jdk.localedata | Other locales fall back silently |
--generate-cds-archive | Writes a default class data sharing archive into the image | Larger image, faster start |
--bind-services | Links every service provider in the module path | Often pulls in many modules; prefer explicit roots |
The compression syntax changed in JDK 21. The old numeric levels 0, 1 and 2 are deprecated in favour of zip-0 to zip-9; write the long --compress=zip-6 form in build scripts so they keep working. Compression makes the image smaller on disk but container layers are compressed for transfer anyway, so measure pull size and start-up before assuming it helps. CDS usually does help start-up; class data sharing explains how archives work and how to add an application-level archive on top of the default one.
A multi-stage container build
In containers, jlink runs in a build stage that has a full JDK and copies the result into a small base image. The base image must use the same C library as the JDK you linked from: a glibc-based JDK needs a glibc-based runtime image, and musl distributions such as Alpine need a JDK built for musl.
# syntax=docker/dockerfile:1
FROM eclipse-temurin:25-jdk AS build
WORKDIR /src
COPY build/libs/ ./libs/
RUN MODS=$(jdeps --print-module-deps --ignore-missing-deps \
--multi-release 25 --recursive --class-path 'libs/*' libs/app.jar) \
&& jlink --add-modules "$MODS,jdk.jfr,jdk.jcmd,jdk.localedata" \
--include-locales=en,de \
--strip-debug --no-header-files --no-man-pages \
--compress=zip-6 --generate-cds-archive \
--output /opt/runtime
FROM debian:bookworm-slim
COPY --from=build /opt/runtime /opt/runtime
COPY --from=build /src/libs /app/libs
ENV PATH=/opt/runtime/bin:$PATH
USER 10001
ENTRYPOINT ["java", "-XX:MaxRAMPercentage=75", "-cp", "/app/libs/*", "com.acme.Main"]Two details matter. First, put the runtime and the application in separate layers, as above, so a code change does not re-ship the runtime. Second, if many services share one base image that already contains a standard JRE, each service's custom runtime is a new, unshared layer; on a node running thirty services, thirty slightly different runtimes can use more disk and cache than one shared JRE. Custom runtimes pay off most for services deployed independently, for serverless or edge targets, and for desktop bundles.
Linking for another platform, and JEP 493
jlink is platform-neutral Java, but the image it writes contains native code. To produce a Windows or Linux image from a macOS build machine, download the target platform's JDK of the same version and point --module-path at its jmods directory. The jlink you run must be the same JDK feature version as the jmods.
JEP 493, delivered in JDK 24, lets jlink link from the running JDK's own image when no jmods directory exists. JDK builds that enable it, with the --enable-linkable-runtime configure option, can be about 25 percent smaller because they omit jmods; whether a distribution enables it is the vendor's choice. You can tell by running jlink --help, which reports either Linking from run-time image enabled or Linking from run-time image disabled. The JEP lists restrictions: you cannot link an image that includes jdk.jlink itself, you cannot cross-link for another platform, jlink fails if files in the runtime's conf directory were modified, and --patch-module and pulling modules from another runtime image are unsupported. In practice: for cross-platform builds, keep using the target JDK's jmods; for same-platform container builds, a linkable runtime works if you leave its configuration untouched.
Patching: the image is a frozen JDK
An image linked from JDK 25.0.1 is JDK 25.0.1 forever. When the quarterly security update ships, the image does not change until you relink. This is the operational cost that teams underestimate. Make the JDK version an explicit build input, rebuild every image when it moves, and let your scanners see what is inside: the release file records the Java version and the linked modules, which most image scanners read to identify the runtime.
# Fail CI if the linked runtime is older than the JDK we promised to ship
expected="25.0.1"
actual=$(sed -n 's/^JAVA_VERSION="\(.*\)"/\1/p' /opt/runtime/release)
[ "$actual" = "$expected" ] || { echo "runtime is $actual, want $expected"; exit 1; }
grep '^MODULES=' /opt/runtime/release
Worked example: measuring an image
A team runs a small HTTP service built on jdk.httpserver, java.net.http and JDBC. jdeps reports java.base,java.logging,java.net.http,java.sql,jdk.httpserver. They link three variants and measure each one the same way: image size, the time for java -version as a floor, and the time to the first successful health check, which is the number users feel.
#!/usr/bin/env bash
set -euo pipefail
BASE=java.base,java.logging,java.net.http,java.sql,jdk.httpserver
link() { rm -rf "$1"; jlink --add-modules "$2" --output "$1" "${@:3}"; }
link img-min "$BASE" --strip-debug --no-header-files --no-man-pages
link img-ops "$BASE,jdk.jfr,jdk.jcmd" --strip-debug --no-header-files --no-man-pages
link img-cds "$BASE,jdk.jfr,jdk.jcmd" --strip-debug --no-header-files --no-man-pages \
--generate-cds-archive
for img in img-min img-ops img-cds; do
size=$(du -sh "$img" | cut -f1)
start=$(date +%s%N)
"$img/bin/java" -cp 'libs/*' com.acme.Main & pid=$!
until curl -sf localhost:8080/health >/dev/null; do sleep 0.05; done
echo "$img size=$size ready_ms=$(( ($(date +%s%N) - start) / 1000000 ))"
kill "$pid"; wait "$pid" 2>/dev/null || true
doneThe pattern of results is what matters, and it is typical. The minimal image is a small fraction of a full JDK. Adding Flight Recorder and jcmd costs little and buys incident tooling, so that variant replaces the minimal one. The CDS archive makes the image somewhat larger and shortens time to first health check, which is worth it for a service that scales out on demand. The team then load-tested for an hour with the full integration suite and found one gap: a reporting endpoint used a legacy character set, so jdk.charsets was added. They kept the integration suite as a required gate for any change to the module list.
Failure modes
| Symptom | Cause | Fix |
|---|---|---|
| ClassNotFoundException for a JDK class | Module loaded reflectively, not seen by jdeps | Add it explicitly; keep a reviewed extras list |
| Error: automatic module cannot be used with jlink | A non-modular jar on the module path | Link the JDK only and run the app from the class path |
| Image will not run on the target | Linked from another OS, architecture or C library | Link from the target's jmods; match glibc or musl |
| jlink fails with a linkable runtime | Modified conf files, cross-linking, or jdk.jlink requested | Use a JDK with jmods for those cases |
| Scanner flags an old JDK | Image not relinked after an update | Pin the JDK version and rebuild on every update |
| Slower start than expected | Heavy compression or no CDS | Measure zip levels; add --generate-cds-archive |
Trade-offs and where it fits
jlink gives you a smaller, more deliberate runtime with the same performance model as the full JDK. You pay with a module list to maintain, a rebuild for every JDK update, and occasional run-time surprises. Combine it with start-up work where that matters: Project Leyden moves more work ahead of time, and CRaC restores a warmed-up process from a checkpoint. If your start-up or memory targets are beyond what any JIT runtime can meet, consider native image instead; if you deploy dozens of services onto shared nodes from a common base image, a shared standard runtime may be simpler and no larger in total.
What to do next
- Run jdeps with --print-module-deps on your service and its libraries, and save the output in the repository.
- Write an extras list for modules loaded at run time (locales, charsets, naming, JFR, jcmd) with one line explaining each.
- Build a JDK-only image in a multi-stage container build, keeping the runtime and application in separate layers.
- Measure size, pull time and time to first health check for two or three plugin combinations and keep the best.
- Make the JDK version a pinned build input, check the release file in CI, and rebuild on every JDK update.
- Run your full integration suite against the linked image before it becomes the default.