Skip to main content

jlink and JMOD: Cut a Spring Boot Java Runtime from 303 MB to 65 MB (and a Minimal One to 42 MB)

Measured jlink results on Temurin 25 for a real Spring Boot 4.1 app: which modules it needs (jdeps), what each jlink option saves, why java.desktop costs 15 MB, a failure when you remove it, and a JMOD-based custom runtime of 42 MB for a minimal modular program. Docker was not available, so no image sizes are claimed.

A full JDK is about 300 MB on disk. Your Spring Boot service uses a fraction of it. 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 let jdeps 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, without java.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 in src/com.ankurm.runtimeinfo/com/ankurm/runtimeinfo/ and module-info.java in src/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

Normally jlink 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

  1. Generate the module list with jdeps in the build, not by hand.
  2. Use --strip-debug --no-header-files --no-man-pages --compress zip-6.
  3. Run the application on the resulting runtime in CI, with a request through every external integration (TLS, database, serialization), because jdeps cannot see reflective use.
  4. Expect a Spring Boot web app to land around 65 MB on this JDK, and treat anything much lower as suspicious.
  5. 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.

Further reading

No Comments yet!

Leave a Reply

This site uses Akismet to reduce spam. Learn how your comment data is processed.