From JIT to Native: Path to Efficient Java Containers
Can a Micronaut application start in milliseconds and run in a container smaller than most Go apps? It can, with GraalVM Native Image. Here’s how we transformed a traditional Java application into a fast to start, small to ship, and ready for the cloud native container.
The experiment started with a Micronaut web server, and ended with a 22MB fully static deployable image. How do we get there? With GraalVM Native Image, smart linking strategies, and a bit of magic at the end.
Along the way, we tested various build strategies, explored trade-offs, and measured real impact.
Yet, once compiled into a JAR and placed in a Docker container with a full JDK, this web server expanded to ~470MB. This result was observed with multiple OpenJDK distributions. That’s a non-starter if you’re trying to keep things lightweight for microservices or scale-to-zero platforms like Knative or AWS Lambda.
How small can we make it? The answer is, as it turns out, very small — if you’re willing to “go native” and even “static native”.
Step by Step: Slimming Down the Container
We started by benchmarking different ways to package and run the application — gradually replacing the JVM with custom runtimes, native executables, and, finally, fully static binaries. The results throughout this post were collected on an Oracle Linux 8 machine with 48 GB of memory and 4 CPUs to ensure consistency.
The demo is open source and reproducible: 👉 github.com/graalvm/workshops/tree/main/native-image/micronaut-webserver. Each step is automated via scripts and Dockerfiles, and the app serves real GraalVM documentation pages so you can benchmark accurately.
Here’s a brief overview of the most notable stages.
Step 1: The Starting Point — Running a JAR in a Container
Compiled as a JAR and run with java -jar
, this web server worked perfectly—but the container size was still significant. Using a distroless java21-debian12
image instead of one built by installing an OpenJDK distribution into a slim OS container dropped the container size down to 216MB. Note that the static website pages contributed to the overall image size.
🧱 Image: gcr.io/distroless/java21-debian12
🚀 Startup: ~378ms
📦 Container size: 216MB
Not bad for a full Java web server, but could be further optimized for cold start and footprint.
Step 2: Creating a Custom JDK with jlink
Next, we tested jlink
, a tool that creates a stripped-down Java runtime containing only the modules your application needs. Using jdeps
to find dependencies and jlink
to build the runtime, we saved quite some space:
🧱 Image: gcr.io/distroless/java-base-debian12
🚀 Startup: ~340ms
📦 Container size: 167MB
Here’s what the command flow looked like:
RUN ./mvnw clean package
RUN ./mvnw dependency:build-classpath -Dmdep.outputFile=cp.txt
RUN CP=$(cat cp.txt) && \
MODULES=$(jdeps --ignore-missing-deps -q --recursive --multi-release 24 --print-module-deps --class-path "$CP" target/webserver-0.1.jar) && \
echo "Modules: $MODULES" && \
jlink \
--module-path "${JAVA_HOME}/jmods" \
--add-modules "$MODULES",jdk.zipfs \
--verbose \
--strip-debug \
--compress zip-9 \
--no-header-files \
--no-man-pages \
--strip-java-debug-attributes \
--output jlink-jre
This gave us an instant 49MB win, just by trimming unused modules. Not a dramatic performance change, but a solid step toward efficiency.
Step 3: Compiling Ahead-of-Time — Going Native
Then came the game changer: GraalVM Native Image. We built the application ahead of time using Native Image Maven plugin, inside a multi-stage Docker build, and then packaged it in a distroless base image for the runner — no JVM required. The resulting image was dynamically linked.
🧱 Image: gcr.io/distroless/java-base-debian12
🚀 Startup: ~20ms
📦 Container size: 132MB
We just reduced startup time by almost 17x times, and the container size by 35MB! That’s how powerful native compilation can be. Still, we wanted more space savings.
Step 4: Optimizing a Native Image for File Size
What if we could shrink the native executable file itself? GraalVM offers the-Os
flag, which optimizes for file size by skipping performance-costly optimizations. We added it to the build:
🧱 Image: gcr.io/distroless/java-base-debian12
🚀 Startup: ~20ms
📦 Container size: 102MB
📦 Binary size: 62MB (down from default 86MB!)
The binary size decreased by 24MB — with no change in behavior or startup time. Optimization for the win!
Additionally, we tested the new SkipFlow feature introduced in GraalVM for JDK 24. That’s a static analysis optimization that tracks unreachable branches to reduce code paths that may never run. It’s experimental, but was easy to enable: -H:+TrackPrimitiveValues -H:+UsePredicates.
It shaved just 1MB off in our case, but that could grow with larger codebase.
Step 5: Running a Mostly Static Application — Going Static
So far we built dynamically linked native images. What if we link almost everything statically? With the --static-nolibc
flag, we created a mostly static executable, linked against everything except glibc
. This made it possible to switch to a smaller container image (base-debian12
).
🧱 Image: gcr.io/distroless/base-debian12
🚀 Startup: ~20ms
📦 Container size: 89.7MB
That’s a further 12MB saved just by changing the base container.
Here is a breakdown of the native-maven-plugin
configuration for this build:
<plugin>
<groupId>org.graalvm.buildtools</groupId>
<artifactId>native-maven-plugin</artifactId>
<version>${native.maven.plugin.version}</version>
<configuration>
<imageName>webserver.mostly-static</imageName>
<buildArgs>
<buildArg>--static-nolibc</buildArg>
<buildArg>-Os</buildArg>
</buildArgs>
</configuration>
</plugin>
Step 6: Running a Fully Static Application — In an Empty Container
Now time for the real fun. With the--static --libc=musl
flag, we could build a fully static native image—no OS-level dependencies, just a single binary. That meant we could use the scratch
container—basically, just an empty filesystem. (scratch
is an official Docker image.)
🧱 Image: scratch
🚀 Startup: ~20ms
📦 Container size: 69.2MB
A production-ready Micronaut web application was deployed in under 69MB, starting in milliseconds! That’s better than many compiled C++ apps.
Step 7. Going Extreme — UPX Compression
Could we go even smaller? We applied UPX, a binary compression tool, to our fully static executable and packaged it into the same scratch
container. UPX decompresses the image at first, adding a CPU hit, but drastically reduces the image size.
🧱 Image: scratch
🚀 Startup: ~20ms
📦 Container size: 22.3MB
📦 Binary size: 20MB (down from 62MB!)
That’s nearly 20× smaller than the original container size. The app still started instantly and served requests flawlessly! The trade-off is that you lose visibility—you can’t easily inspect what was compressed out.
Before and After: The Numbers
Let’s compare where we started and where we landed:
Engineering Lessons Learned
Here’s what we took away from this process:
- Not all optimizations are equal.
jlink
is useful, but Native Image delivers orders-of-magnitude improvements in both size and startup time. - Static linking unlocks ultra-tiny containers. If you don’t need a full OS, use
scratch
—you’ll get not only smaller images but also a reduced attack surface. Alternatives toscratch
can begcr.io/distroless/static
oralpine:3
with a few more utilities and libraries inside. - Base image size matters. Switching from
java21-debian12
tobase-debian12
orscratch
made a huge difference. Don’t overlook this part. - Compression can be the final trick. UPX is surprisingly effective, and worth experimenting with — though you’ll want to test CPU overhead in performance-sensitive apps.
On the final thought, if you’re building Java applications for the cloud and haven’t tried GraalVM Native Image, now is the time. The ecosystem is ready, the tooling is mature, and the results are hard to ignore.
Try It Yourself 💻
The demo sources, Dockerfiles, and step-by-step instructions are available on GitHub: github.com/graalvm/workshops/tree/main/native-image/micronaut-webserver.
If you prefer Spring to Micronaut, there is an identical Spring Boot version of this demo: github.com/graalvm/workshops/tree/main/native-image/spring-boot-webserver.
For more experiments and examples, look at Tiny Java Containers.
We welcome your feedback via Slack or GitHub.
— the GraalVM team