The AOT compilation pipeline
GraalVM Native Image performs ahead-of-time compilation, converting your Java bytecode into a standalone native executable. Unlike the JVM, which interprets bytecode and JIT-compiles hot methods at runtime, native image does all compilation at build time through a process called static analysis.
The pipeline works like this:
- Points-to analysis, Determines which classes, methods, and fields are reachable from the entry point
- Initialization, Runs class static initializers (at build time or deferred to runtime)
- Compilation, Compiles reachable methods to native code using the Graal compiler
- Linking, Produces a standalone executable with the SubstrateVM runtime
# Basic native image build for a Spring Boot app
./mvnw -Pnative native:compile
# Or with Gradle
./gradlew nativeCompile
Spring Boot 3.x includes native image support via Spring AOT (Ahead-of-Time) processing. During build, Spring AOT analyzes your application context and generates:
- Reflection hints for beans and configurations
- Proxy class registrations
- Serialization metadata
- Resource registration patterns
// build.gradle.kts
plugins {
id("org.graalvm.buildtools.native") version "0.10.4"
}
graalvmNative {
binaries {
named("main") {
imageName.set("auth-service")
mainClass.set("com.example.AuthServiceApplication")
buildArgs.add("-O2")
buildArgs.add("-march=native")
buildArgs.add("--gc=G1")
buildArgs.add("-H:+ReportExceptionStackTraces")
javaLauncher.set(javaToolchains.launcherFor {
languageVersion.set(JavaLanguageVersion.of(21))
vendor.set(JvmVendorSpec.GRAAL_VM)
})
}
}
}
The reflection configuration nightmare
Java's reflection API, Class.forName(), Method.invoke(), Field.get(), is everywhere. Spring uses it for dependency injection, Jackson for JSON serialization, Hibernate for entity mapping. Native image's static analysis can't see through reflection, so every reflective access must be declared upfront.
The native-image-agent is your best friend here. It's a JVM agent that records all reflective access, JNI calls, proxy creation, and resource loading during runtime:
# Run your app with the tracing agent
java -agentlib:native-image-agent=config-output-dir=src/main/resources/META-INF/native-image \
-jar target/auth-service.jar
# Now exercise every code path, run your full test suite
./run-integration-tests.sh
# The agent generates these files:
# reflect-config.json
# jni-config.json
# proxy-config.json
# resource-config.json
# serialization-config.json
The generated reflect-config.json looks like this:
[
{
"name": "com.example.model.User",
"allDeclaredConstructors": true,
"allPublicMethods": true,
"allDeclaredFields": true
},
{
"name": "com.example.config.SecurityConfig$$SpringCGLIB$$0",
"allDeclaredConstructors": true,
"allPublicMethods": true
},
{
"name": "com.fasterxml.jackson.databind.ser.std.ToStringSerializer",
"methods": [
{ "name": "<init>", "parameterTypes": [] }
]
}
]
The problem: the agent only records code paths that actually execute during tracing. Miss a code path, and a NoSuchMethodException or ClassNotFoundException appears in production. A common example: a token refresh endpoint, which only triggers when tokens are near expiry, throwing a reflection error two weeks after deployment.
A robust approach: run the agent against three different scenarios:
# 1. Full integration test suite
java -agentlib:native-image-agent=config-merge-dir=native-config \
-jar app.jar &
./run-integration-tests.sh
kill %1
# 2. Manual smoke test of less-tested paths
java -agentlib:native-image-agent=config-merge-dir=native-config \
-jar app.jar &
curl -X POST localhost:8080/auth/refresh -H "Authorization: Bearer expired_token"
curl -X POST localhost:8080/auth/logout
curl -X GET localhost:8080/actuator/health
curl -X GET localhost:8080/actuator/prometheus
kill %1
# 3. Error paths, trigger validation errors, 404s, auth failures
java -agentlib:native-image-agent=config-merge-dir=native-config \
-jar app.jar &
curl -X POST localhost:8080/auth/login -d '{"bad":"json"}'
curl -X GET localhost:8080/nonexistent
curl -X POST localhost:8080/auth/login -d '{"email":"x","password":"wrong"}'
kill %1
The config-merge-dir flag is critical, it merges new entries with existing config instead of overwriting.
Startup time: the before and after
Here are the real numbers from our auth-service:
JVM (OpenJDK 21, default settings):
Startup: 8,247ms
Time to first request: 9,100ms
RSS at idle: 287MB
RSS under load: 412MB
Native Image (GraalVM 21, -O2, G1 GC):
Startup: 47ms
Time to first request: 52ms
RSS at idle: 43MB
RSS under load: 198MB
That's a 175x improvement in startup time. But notice something: under load, the memory gap narrows from 6.7x to 2.1x. The native image's advantage is primarily in idle/startup scenarios. For long-running services under sustained load, the JVM's C2 JIT compiler produces more optimized code for hot paths.
Throughput benchmarks for the same service handling JWT validation:
JVM (after warmup): 47,200 req/s at p99=2.1ms
Native Image: 31,400 req/s at p99=3.8ms
The JVM is 50% faster at peak throughput. This is the fundamental trade-off: native image gives you fast startup and low idle memory at the cost of peak throughput. For our auth service (50 req/min), the throughput difference is irrelevant. For a high-traffic API gateway, it might matter.
Build time and CI/CD impact
Native image compilation is brutally resource-intensive. Our auth-service (a small Spring Boot app with ~30 beans) takes:
JVM build (mvn package): 14 seconds, 512MB RAM
Native image build (-O2): 11 minutes 23 seconds, 14.2GB RAM
Native image build (-Ob): 4 minutes 8 seconds, 8.1GB RAM
CI runners need beefy machines. c6a.2xlarge instances (8 vCPU, 16GB RAM) are a reasonable baseline for native builds. The -Ob (quick build) flag is essential for development, it skips expensive optimizations and produces a larger, slightly slower binary, but builds 3x faster.
Here's our multi-stage Dockerfile:
# Stage 1: Build native image
FROM ghcr.io/graalvm/native-image-community:21 AS builder
WORKDIR /app
COPY . .
RUN ./mvnw -Pnative native:compile -DskipTests \
-Dspring.native.build.args="-O2 --gc=G1 -march=x86-64-v3"
# Stage 2: Minimal runtime image
FROM debian:bookworm-slim
RUN apt-get update && apt-get install -y --no-install-recommends \
libz-dev \
&& rm -rf /var/lib/apt/lists/*
COPY --from=builder /app/target/auth-service /app/auth-service
EXPOSE 8080
ENTRYPOINT ["/app/auth-service"]
The final image is 82MB, compared to 340MB for the JVM-based image with a JDK. In a Kubernetes cluster running 40 instances, that's 10GB less to pull on deployments.
Memory footprint: the hidden complexity
RSS (Resident Set Size) is what matters, not just heap. A native image's memory consists of:
- Image heap, Objects initialized at build time, mapped into memory
- Runtime heap, Objects allocated at runtime (managed by GC)
- Code cache, Compiled native code
- Thread stacks, Default 1MB per thread
- Native memory, JNI allocations, NIO buffers
# Monitor native image memory breakdown
auth-service -XX:+PrintGC -XX:+VerboseGC \
-Xmx128m \
-XX:MaxDirectMemorySize=64m \
-Dserver.tomcat.threads.max=50
The gotcha: image heap is memory-mapped from the executable file, so it shows up in RSS but is shared across processes and doesn't consume physical RAM proportionally. Tools like pmap give you better visibility:
# Detailed memory map
pmap -x $(pgrep auth-service) | tail -5
# Or use NativeMemoryTracking equivalent
auth-service -XX:+DashboardAll -XX:DashboardDump=heap-info.json
Production lessons
After six months of production experience, the key takeaways for teams considering GraalVM Native Image:
Use it for: CLI tools, serverless functions, short-lived batch jobs, low-traffic microservices, sidecar containers, and any workload where startup time or idle memory matters more than peak throughput.
Avoid it for: high-throughput services where JIT optimization matters, apps with heavy use of dynamic class loading (plugins, OSGI), services that depend on libraries without native image support.
The reflection problem doesn't go away: Even with Spring AOT, third-party libraries surprise you. Budget time for tracing agent runs after every dependency upgrade.
Build reproducibility: Pin your GraalVM version. Different versions produce different binaries with different performance characteristics. We pin to exact patch versions in our build toolchain.
Monitoring: The JMX MBeans you rely on for JVM monitoring don't exist in native image. VisualVM doesn't connect. You need to instrument your application differently, Micrometer with Prometheus works, but Java Flight Recorder doesn't.
An auth service running on native images for six months demonstrates the benefits clearly. Deployments that previously took 45 seconds (readiness probe wait) now complete in under 3 seconds. Container sizes drop from 512MB to 256MB with headroom. The throughput difference is invisible at typical traffic levels. For this specific workload, native image is an unqualified win. For others, the answer is more nuanced, and that nuance is the whole point of this article.