jlink builds a runtime containing only the modules you name, and jmod packages your own modules in the format jlink consumes. This post measures what that actually saves, on a real Spring Boot 4.1.1 web app, and shows the one failure that catches people.
Setup and limits. Eclipse Temurin 25.0.4.1+1 on Linux x64, sizes from du -sm (megabytes, rounded). The app is the small Spring Boot service from my FIPS and TLS post, with HTTPS enabled and 34 library jars. Docker had no daemon in my sandbox, so I did not build a container image and make no claim about image sizes. Start-up times are single runs and not benchmarks.
Starting point
$ du -sm $JAVA_HOME | awk '{print $1" MB"}'
303 MB
$ java --list-modules | wc -l
69
The JDK I installed is 303 MB with 69 modules. Interestingly, this Temurin 25 has no jmods directory at all, yet jlink works (more on that below).
Step 1: ask jdeps which modules the app needs
Unpack the Spring Boot jar and letjdeps scan the application classes plus every library in BOOT-INF/lib:
$ jdeps --ignore-missing-deps --recursive --multi-release 25 --print-module-deps -cp "BOOT-INF/lib/*" BOOT-INF/classes
java.base,java.compiler,java.desktop,java.instrument,java.management,java.naming,java.net.http,java.prefs,java.scripting,java.security.jgss,java.sql,jdk.jfr,jdk.unsupported
Thirteen modules instead of 69. jdeps reads bytecode, so it misses anything loaded by name at run time (JDBC drivers, some security providers, ServiceLoader lookups). Always run the real app on the result before you trust it, as I do below.
Step 2: what each jlink option saves
The same 13 modules, three ways:$ jlink --add-modules <13 modules> --output rt-plain
size: 108 MB
$ jlink --add-modules <13 modules> --strip-debug --no-header-files --no-man-pages --output rt-strip
size: 97 MB
$ jlink --add-modules <13 modules> --strip-debug --no-header-files --no-man-pages --compress zip-6 --output rt-zip
size: 65 MB
$ rt-zip/bin/java --list-modules | wc -l
18
$ rt-zip/bin/java -version
openjdk version "25.0.4.1" 2026-08-18 LTS
OpenJDK Runtime Environment Temurin-25.0.4.1+1 (build 25.0.4.1+1-LTS)
OpenJDK 64-Bit Server VM Temurin-25.0.4.1+1 (build 25.0.4.1+1-LTS, mixed mode)
Choosing 13 modules gives 108 MB. Stripping debug info and dropping headers and man pages gives 97. Compression is the big step, to 65 MB. The runtime ends up with 18 modules because the 13 pull in their own dependencies. Is it still a working Java? Run the app on it, with HTTPS:
$ rt-zip/bin/java -jar fips-demo-1.0.0.jar
Started FipsDemoApplication in 2.518 seconds (process running for 3.26)
GET /providers -> Eclipse Adoptium 25.0.4.1
It starts and answers over TLS. (The second line is my script’s summary of an HTTPS call to the app, not raw output.)
Where the remaining 65 MB goes
Largest files in that runtime:28.5 MB rt-zip/lib/server/libjvm.so
27.1 MB rt-zip/lib/modules
1.9 MB rt-zip/lib/libfontmanager.so
0.9 MB rt-zip/lib/libawt.so
0.7 MB rt-zip/lib/libfreetype.so
0.6 MB rt-zip/lib/libmlib_image.so
0.5 MB rt-zip/lib/liblcms.so
0.5 MB rt-zip/lib/libawt_xawt.so
all .so: 35.6 MB
Two files are 55 MB of the 65: the JVM itself and the compressed module image. Nothing you pass to jlink shrinks libjvm.so. The font, AWT and image libraries come with java.desktop. So how low can it go? Here are four variants I built:
variant size
basezip6 43 MB
basezip9 43 MB
appzip9 65 MB
appzip6-nodesk 50 MB
java.base alone is 43 MB, and zip-9 saves nothing over zip-6 here (43 MB and 65 MB both times). That 43 MB is the floor for this JDK build, and it is why a “40 MB runtime” is a real number only for a program that needs almost nothing. For a Spring Boot web app, the practical result is 65 MB.
The failure: removing java.desktop
The last variant, withoutjava.desktop, is 50 MB, a 15 MB saving. Spring does not use graphics, so why does jdeps list it? Because java.beans lives in that module. Try it:
$ rt-appzip6-nodesk/bin/java -jar fips-demo-1.0.0.jar
FAILED TO START:
java.lang.NoClassDefFoundError: java/beans/PropertyEditorSupport
Caused by: java.lang.ClassNotFoundException: java.beans.PropertyEditorSupport
Exception in thread "main" java.lang.reflect.InvocationTargetException
(The two indented lines under the first are my script’s labels around the app’s own error lines.) Spring Framework uses PropertyEditorSupport, so java.desktop is required and the 15 MB is not available. This is the general lesson: the list from jdeps is a minimum you should not trim by hand, and a start-up test in CI is cheap insurance.
JMOD: building the runtime from your own module
A.jmod is a packaging format for modules that, unlike a JAR, can contain native libraries and config, and is meant only for build time (jlink reads it; java cannot run it). To see the full flow I wrote a small modular program, RuntimeInfo, which prints the runtime it runs on:
Download the code. RuntimeInfo.java goes insrc/com.ankurm.runtimeinfo/com/ankurm/runtimeinfo/and module-info.java insrc/com.ankurm.runtimeinfo/. It needs JDK 17 or later.
$ javac -d mods/com.ankurm.runtimeinfo $(find src -name "*.java")
$ jmod create --class-path mods/com.ankurm.runtimeinfo --main-class com.ankurm.runtimeinfo.RuntimeInfo --module-version 1.0 jmods/com.ankurm.runtimeinfo.jmod
$ jmod describe jmods/com.ankurm.runtimeinfo.jmod
[email protected]
requires java.base mandated
contains com.ankurm.runtimeinfo
main-class com.ankurm.runtimeinfo.RuntimeInfo
$ jmod list jmods/com.ankurm.runtimeinfo.jmod
classes/module-info.class
classes/com/ankurm/runtimeinfo/RuntimeInfo$1.class
classes/com/ankurm/runtimeinfo/RuntimeInfo.class
$ jlink --module-path jmods --add-modules com.ankurm.runtimeinfo --launcher runtimeinfo=com.ankurm.runtimeinfo --strip-debug --no-header-files --no-man-pages --compress zip-6 --output tiny
$ tiny/bin/runtimeinfo
java.version = 25.0.4.1
java.vendor = Eclipse Adoptium
modules = com.ankurm.runtimeinfo, java.base
runtime size = 41.9 MB (tiny)
$ tiny/bin/java --list-modules
[email protected]
[email protected]
The result is a single self-contained directory of 41.9 MB with a runtimeinfo launcher, containing only java.base and the one module. That is the 40 MB figure, achieved honestly: it only applies when java.base is all you need.
No jmods directory, and jlink still works
Normallyjlink reads the JDK’s own modules from $JAVA_HOME/jmods. This Temurin 25 has no such directory, yet every command above worked, and my own .jmod was found through --module-path jmods. That matches the behaviour JEP 493 describes (linking from the run-time image), but I only observed it; I did not check how Temurin builds its JDK. If you rely on a different vendor’s JDK, run ls $JAVA_HOME/jmods first. I ran this on JDK 25 only.
What I would do for a real service
- Generate the module list with
jdepsin the build, not by hand. - Use
--strip-debug --no-header-files --no-man-pages --compress zip-6. - Run the application on the resulting runtime in CI, with a request through every external integration (TLS, database, serialization), because
jdepscannot see reflective use. - Expect a Spring Boot web app to land around 65 MB on this JDK, and treat anything much lower as suspicious.
- Rebuild the runtime whenever you update the JDK. It is a snapshot of the JDK you linked against, so security patches do not reach it on their own.
A Dockerfile sketch (not built or run by me)
I had no Docker daemon, so this is an untested outline of the usual multi-stage pattern, not something I verified:
FROM eclipse-temurin:25-jdk AS build
WORKDIR /app
COPY target/app.jar app.jar
RUN jar xf app.jar \
&& MODS=$(jdeps --ignore-missing-deps --recursive --multi-release 25 --print-module-deps -cp "BOOT-INF/lib/*" BOOT-INF/classes) \
&& jlink --add-modules "$MODS" --strip-debug --no-header-files --no-man-pages --compress zip-6 --output /runtime
# Any glibc-based minimal image works; I did not test any of them.
FROM debian:stable-slim
COPY --from=build /runtime /opt/runtime
COPY target/app.jar /app.jar
ENTRYPOINT ["/opt/runtime/bin/java", "-jar", "/app.jar"]
Measure the resulting image yourself before quoting a size, and remember that the base image adds its own weight on top of the runtime. For JVM flags to use inside containers, see JVM flags for Spring Boot in containers and Spring Boot 4 on Kubernetes.
No Comments yet!