<!-- Canonical: https://lucioduran.com/blog/graalvm-native-image-spring-boot-startup
Language: en
Content-SHA256: da4ebf9cd17d6ff7420d67d884dee60f1579c29d5c6778d6b61e1e817848f538
-->

JVM

# GraalVM Native Image: From 8-Second Spring Boot Startup to 50ms

2025-10-167 min read

Lucio Durán
Engineering Manager & AI Solutions Architect

Also available in: [Español](<https://lucioduran.com/es/blog/graalvm-native-image-spring-boot-startup>)

## The AOT compilation pipeline

[GraalVM](<https://www.graalvm.org/>) 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:

1. **Points-to analysis**, Determines which classes, methods, and fields are reachable from the entry point
2. **Initialization**, Runs class static initializers (at build time or deferred to runtime)
3. **Compilation**, Compiles reachable methods to native code using the Graal compiler
4. **Linking**, Produces a standalone executable with the SubstrateVM runtime

```bash
# 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

```java
// 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:

```bash
# 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:

```json
[
 {
 "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:

```bash
# 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:

```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:

1. **Image heap**, Objects initialized at build time, mapped into memory
2. **Runtime heap**, Objects allocated at runtime (managed by GC)
3. **Code cache**, Compiled native code
4. **Thread stacks**, Default 1MB per thread
5. **Native memory**, JNI allocations, NIO buffers

```bash
# 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:

```bash
# 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.

## Frequently asked questions

### Is GraalVM Native Image production-ready for Spring Boot?

Yes, Spring Boot 3.x has first-class native image support via Spring AOT. Most Spring modules work out of the box, though some third-party libraries require manual reflection configuration.

### What are the main trade-offs of native compilation?

Faster startup and lower memory at the cost of lower peak throughput (no JIT optimization), longer build times, and restricted use of reflection/dynamic class loading. The C2 JIT compiler can optimize hot paths better than AOT for long-running services.

### How much memory does a native Spring Boot app use?

A typical REST API starts at 30-60MB RSS compared to 150-300MB for the same app on the JVM. However, under load, the gap narrows because the native image allocates heap similarly to the JVM.

## Tips

1. **Tip:** Run ` native-image --initialize-at-build-time ` reporting with ` -H:+PrintClassInitialization ` before your first build. It will show you exactly which classes are delaying startup, and you can decide which to initialize at build time vs runtime.
2. **Tip:** Never ship a native image without running the ` native-image-agent ` against your full integration test suite. Missing reflection configs only show up at runtime as ` NoSuchMethodException ` in production, the worst kind of surprise.
3. **Tip:** Use ` -Ob ` (quick build mode) during development and ` -O2 ` only for release builds. Full optimization takes 10-15 minutes and 14GB+ RAM, your dev loop can't afford that.
4. **Tip:** Monitor RSS memory, not heap. Native images use off-heap memory for image-mapped data, code cache, and thread stacks. A 128MB heap app might consume 350MB RSS in production.

- graalvm
- native-image
- spring-boot
- aot
- jvm
- startup-time
- performance
- java

[Back to Blog](<https://lucioduran.com/blog>)
