Smaller Runtimes with jlink

Find the modules a service needs with jdeps and build a custom Java runtime with jlink, without breaking reflection-heavy libraries.

Syntaxjdeps --ignore-missing-deps --print-module-deps --class-path 'lib/*' app.jar jlink --add-modules java.base,java.sql --strip-debug --no-man-pages \ --no-header-files --compress=zip-6 --output runtime

Since Java 9 the JDK is split into modules. java.base holds the core classes, and the rest (java.sql, java.net.http, java.logging and so on) are separate. jlink assembles a runtime image that contains only the modules you list. A typical service runtime built this way is a fraction of the size of a full JDK, which means smaller images, faster pulls and fewer files to patch.

The two steps

  1. Find the modules. jdeps --print-module-deps reads your JAR and its dependencies and prints a comma-separated module list. Add --ignore-missing-deps, because libraries often reference optional classes you do not ship.
  2. Link. jlink --add-modules <list> --output runtime writes a directory with bin/java and the chosen modules. --strip-debug, --no-man-pages, --no-header-files and --compress=zip-6 shrink it further.

Your application does not need to be modular. jlink builds the runtime; you still run a plain JAR on it with -jar or -cp.

Where it goes wrong

jdeps reads bytecode. It cannot see a class loaded by name through reflection or ServiceLoader, which is how frameworks, JDBC drivers and logging back ends often work. The symptom is a ClassNotFoundException or NoClassDefFoundError at run time, sometimes only on a code path tests did not touch. Modules that are often missed:

  • java.desktop: holds java.beans, which Spring and other libraries use.
  • jdk.unsupported: sun.misc.Unsafe, used by some networking and serialisation libraries.
  • java.naming, java.management, java.instrument: JNDI lookups, JMX and agents.
  • jdk.localedata: locale data beyond the basic set, for formatting dates and numbers in other languages.
  • jdk.jfr and jdk.jcmd: Flight Recorder and the jcmd tool you will want during an incident.

Treat the jdeps output as a starting point, add what your stack needs, and run the full integration test suite against the linked runtime, not against your development JDK.

Practical constraints

The runtime is built for one operating system and CPU architecture, and for one C library: a runtime linked from a glibc JDK will not start on Alpine, which uses musl. Build it in a stage of the same Dockerfile from the same vendor JDK, and copy it onto a matching slim base image. The image also no longer gets Java updates from its base image; you rebuild to pick up a security release.

If the saving is small for your service, a vendor JRE image is simpler. jlink pays off with many services or tight image budgets.

Example

Example · dockerfile

FROM eclipse-temurin:25-jdk AS build
WORKDIR /src
COPY . .
RUN ./mvnw -B -q package -DskipTests

# Work out the modules, then add what reflection-based libraries need
RUN jdeps --ignore-missing-deps -q --recursive --multi-release 25 \
      --print-module-deps --class-path 'target/lib/*' \
      target/orders-service.jar > modules.txt \
 && jlink --add-modules "$(cat modules.txt),jdk.jfr,jdk.jcmd" \
      --strip-debug --no-man-pages --no-header-files \
      --compress=zip-6 --output /opt/java-runtime

# Same C library (glibc) as the JDK the runtime was linked from
FROM debian:bookworm-slim
ENV JAVA_HOME=/opt/java-runtime
ENV PATH="${JAVA_HOME}/bin:${PATH}"
COPY --from=build /opt/java-runtime /opt/java-runtime
COPY --from=build /src/target/orders-service.jar /app/
COPY --from=build /src/target/lib/ /app/lib/
RUN groupadd --system app && useradd --system --gid app --no-create-home app
USER app
ENTRYPOINT ["java", "-jar", "/app/orders-service.jar"]

When to use it

  • Shrink the runtime image of fifty small internal services so node image caches hold more of them and deploys pull less data.
  • Ship a command-line export tool with its own minimal Java runtime so customers do not need a JDK installed.
  • Remove unused modules from a payments service image to reduce what security scanners report and what has to be patched.

More examples

Which module is a class in?

Class.getModule() names the module that has to be in the --add-modules list. On a full JDK, java --describe-module java.sql and java --list-modules give the same information from the command line.

Example · java
import java.util.List;

// When a linked runtime throws NoClassDefFoundError, this tells you the module to add.
void main() {
    List<Class<?>> classes = List.of(
            String.class,
            java.net.http.HttpClient.class,
            java.sql.Connection.class,
            java.util.logging.Logger.class,
            javax.xml.parsers.DocumentBuilder.class);
    for (Class<?> type : classes) {
        IO.println(type.getSimpleName() + " -> " + type.getModule().getName());
    }
}

// Output:
// String -> java.base
// HttpClient -> java.net.http
// Connection -> java.sql
// Logger -> java.logging
// DocumentBuilder -> java.xml

Inspecting a linked runtime

--list-modules shows exactly what is in the image, which is the first thing to check when a class cannot be found. The --smoke-test flag stands for whatever start-up check your service offers.

Example · bash
# What did we actually link?
/opt/java-runtime/bin/java --list-modules

# In the build stage: full JDK against the linked runtime
du -sh "$JAVA_HOME" /opt/java-runtime

# Run a smoke test with the linked runtime, not the build JDK
/opt/java-runtime/bin/java -jar /app/orders-service.jar --smoke-test

Modules a Spring Boot service usually needs

Keeping the additions in one named variable documents why each is there and makes the list easy to review. Confirm the final set by running the integration tests on the image.

Example · bash
# jdeps rarely finds all of these for a Spring application
EXTRA_MODULES=java.desktop,java.naming,java.management,java.instrument,jdk.unsupported

jlink --add-modules "$(cat modules.txt),${EXTRA_MODULES}" \
      --strip-debug --no-man-pages --no-header-files \
      --compress=zip-6 --output /opt/java-runtime

Discussion

  • Be the first to comment on this lesson.