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.
jdeps --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 runtimeSince 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
- Find the modules.
jdeps --print-module-depsreads 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. - Link.
jlink --add-modules <list> --output runtimewrites a directory withbin/javaand the chosen modules.--strip-debug,--no-man-pages,--no-header-filesand--compress=zip-6shrink 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: holdsjava.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.jfrandjdk.jcmd: Flight Recorder and thejcmdtool 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
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.
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.xmlInspecting 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.
# 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-testModules 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.
# 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