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.

Advertisement

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.

jlink is a linker for the platform: it resolves a module graph, runs plugins over it, and writes a runtime directoryYour app jarsclass path or modulesjdepswhich JDK modules?JDK jmodsor linkable runtimeResolverroots + requiresmodule listPlugin pipelinestrip, compress, CDSRuntime imagebin, conf, libContainer layerimage + app jarsrelease fileversion, modulesTarget platformOS and arch of jmodsdecidesThe image is a real JDK runtime with a JIT and a GC, limited to the modules you named and their dependenciesIt is frozen at the JDK version it was linked from, so every JDK security update means a rebuild
jdeps tells you which platform modules the application needs; jlink resolves them against the target platform's modules, runs plugins and writes an image that becomes a container layer.

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.httpserver

jdeps sees static references only. Anything loaded by name at run time is invisible to it, and these are the modules people discover in production:

NeedModuleHow the omission shows up
Locale data beyond Englishjdk.localedataWrong date or number formats, silently
Extra character setsjdk.charsetsUnsupportedCharsetException on legacy encodings
DNS lookups through JNDIjdk.naming.dnsNamingException in LDAP or service discovery code
ZIP file systemsjdk.zipfsProviderNotFoundException opening a jar as a FileSystem
Flight Recorderjdk.jfrRecordings cannot start; profiling disabled
jcmd and attachjdk.jcmd, jdk.attachNo thread dumps or heap dumps in the container
JMX remotejava.management.rmi, jdk.management.agentMonitoring agent fails to start
Crypto providersjdk.crypto.* modules, by JDK versionTLS 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.

Advertisement

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.

OptionEffectCost
--strip-debugRemoves debug attributes from classesJDK frames in stack traces lose line numbers
--no-header-filesDrops C headersNone for running Java
--no-man-pagesDrops man pagesNone in containers
--compress=zip-NCompresses resources in the modules file, N from 0 to 9Decompression work when classes load
--include-locales=en,deKeeps only the named locales from jdk.localedataOther locales fall back silently
--generate-cds-archiveWrites a default class data sharing archive into the imageLarger image, faster start
--bind-servicesLinks every service provider in the module pathOften 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
done

The 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

SymptomCauseFix
ClassNotFoundException for a JDK classModule loaded reflectively, not seen by jdepsAdd it explicitly; keep a reviewed extras list
Error: automatic module cannot be used with jlinkA non-modular jar on the module pathLink the JDK only and run the app from the class path
Image will not run on the targetLinked from another OS, architecture or C libraryLink from the target's jmods; match glibc or musl
jlink fails with a linkable runtimeModified conf files, cross-linking, or jdk.jlink requestedUse a JDK with jmods for those cases
Scanner flags an old JDKImage not relinked after an updatePin the JDK version and rebuild on every update
Slower start than expectedHeavy compression or no CDSMeasure 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

  1. Run jdeps with --print-module-deps on your service and its libraries, and save the output in the repository.
  2. Write an extras list for modules loaded at run time (locales, charsets, naming, JFR, jcmd) with one line explaining each.
  3. Build a JDK-only image in a multi-stage container build, keeping the runtime and application in separate layers.
  4. Measure size, pull time and time to first health check for two or three plugin combinations and keep the best.
  5. Make the JDK version a pinned build input, check the release file in CI, and rebuild on every JDK update.
  6. Run your full integration suite against the linked image before it becomes the default.
Key takeaway: jlink builds a real Java runtime that contains only the modules you name, so you can ship a smaller, more deliberate platform without changing how your code runs. Use jdeps for the static list, add the modules it cannot see, keep diagnostics in the image, measure plugin choices by start-up and pull time rather than by habit, link from the target platform's modules, and treat the image as a frozen JDK that must be relinked on every security update.