Skip to content
ExponaBLOG
JAVA, SPRING FRAMEWORK & SPRING BOOT · DAY 083 / 100

GraalVM native images for Spring Boot: a practical guide

Millisecond startup and tiny memory — with trade-offs.

Expona Admin6 min read

The startup bottleneck you didn't know you had

A typical Spring Boot application takes 2–5 seconds to start on a modern JVM. For containerized workloads running serverless functions, scaling Kubernetes pods, or handling traffic spikes in cost-sensitive environments, that delay stacks up fast. GraalVM native images for Spring Boot address this head-on: we're talking millisecond startup times and memory footprints under 100 MB. But the path to production requires understanding where the wins are real and where trade-offs bite. This practical guide walks through building and deploying GraalVM native images for Spring Boot, showing you how to evaluate whether AOT compilation belongs in your stack.

Building your first GraalVM native image

Spring Boot 3.0+ ships with first-class AOT (Ahead of Time) compilation support. The mechanics are straightforward: instead of the JVM interpreting and JIT-compiling bytecode at runtime, GraalVM's native-image tool analyzes your entire application at build time and produces a single executable binary.

Start with a standard Spring Boot 3.x project. The simplest path uses Maven:

java
./mvnw -Pnative native:compile

That one command does the heavy lifting. Behind the scenes, the native Maven profile (defined in Spring Boot's parent POM) kicks off the GraalVM native-image compiler. Your build machine needs GraalVM JDK installed (not the standard OpenJDK). Most CI/CD platforms now ship container images with it pre-installed.

Let's say you have a simple REST controller:

java
@RestController
@RequestMapping("/api")
public class GreetingController {
  @GetMapping("/hello")
  public String hello() {
    return "Hello from native image";
  }
}

After ./mvnw -Pnative native:compile completes (this can take 2–3 minutes on typical hardware), you'll find an executable in target/. Run it directly—no JVM needed:

java
./target/myapp

You'll see your server ready in under 100 milliseconds. The binary is self-contained, roughly 80–120 MB depending on your dependencies.

Real wins: where native images shine

Serverless and function-as-a-service platforms

AWS Lambda, Google Cloud Functions, and similar services charge by execution time and memory. A Spring Boot function that starts in 100 ms instead of 3000 ms cuts cold-start costs by 30×. For event-driven workloads where you scale from zero frequently, native images are often worth the build complexity alone.

Example: a webhook handler that runs maybe 10 times a day. Traditional Spring Boot might cold-start 5 times a day at 3 seconds each. That's 15 seconds of wasted billing. A native image does it in 500 ms total. Over a month, you're saving execution cost and, crucially, delivering faster responses to your users.

Dense containerized deployments

Kubernetes clusters often run hundreds of pods. A heap-heavy Spring Boot instance consumes 256–512 MB per pod. Cut that to 64 MB with a native image, and you fit 4–8× more pods on the same node. That translates to lower infrastructure spend and better bin-packing efficiency.

Large SaaS platforms running multi-tenant deployments benefit especially here. Each tenant gets a lightweight, isolated pod. Fewer nodes mean lower cluster overhead, simpler networking, and faster horizontal scaling.

CLI tools and batch jobs

A Spring Boot CLI tool (maybe a data migration utility or a scheduled batch job) spins up, does work, and exits. That 3-second startup overhead happens every invocation. With native images, it's over before the user blinks. Plus, the single-file executable is easier to distribute than a fat JAR and JVM installation.

The costs: what you trade away

Build time

Compilation to native code takes time. A simple application might take 30–60 seconds. Complex, dependency-heavy applications can hit 5–10 minutes or more. If your CI/CD builds frequently, this adds up. A standard mvn clean package for JVM artifacts often completes in 30 seconds; native builds are slower.

For rapidly iterating teams, this friction matters. You may want to keep JVM-based images for local development and reserve native images for release builds.

Reflection hints and library support

Native image compilation requires static analysis. It needs to know at build time which classes will be instantiated, which methods will be called via reflection. Many popular libraries—older JSON mappers, some ORM frameworks, and dynamic proxy libraries—use reflection heavily.

Spring has spring-boot-starter-aot and provides reflection hints for common libraries out of the box. But if you pull in a lesser-known library, you may need to add hints manually:

java
@Configuration
public class ReflectionConfiguration {
  @Bean
  static RuntimeHintsRegistrar myLibraryHints() {
    return hints -> {
      hints.reflection()
        .registerType(MyCustomClass.class,
          MemberCategory.INVOKE_DECLARED_CONSTRUCTORS,
          MemberCategory.INVOKE_PUBLIC_METHODS);
    };
  }
}

This is extra work. For teams with complex, proprietary libraries, native images become a debugging exercise.

Peak throughput and GC tuning

The JVM's JIT compiler learns your code's hotspots and optimizes them. Over several minutes of sustained load, a JVM-based service reaches 10–20% higher peak throughput than its native image equivalent. If your application is latency-critical and runs continuously under high load, the native image may be slightly slower at sustained peak.

GC tuning is also different. The GraalVM GC is excellent for predictability but sometimes trades throughput for low latency. For services running 24/7 at scale, this can matter.

The pragmatic middle ground: Class Data Sharing (CDS)

Before committing to a full native image build, consider Class Data Sharing. It's a JVM feature available since Java 10 that caches loaded classes to disk. On restart, the JVM reads the pre-loaded, pre-verified classes instead of parsing and verifying bytecode again.

Generate a CDS archive alongside your Spring Boot fat JAR:

java
java -XX:+UseAppCDS -XX:DumpLoadedClassList=classes.jsa \
  -jar myapp.jar &

# Let it run for 10 seconds, then stop

java -XX:+UseAppCDS -XX:SharedArchiveFile=app.jsa \
  -Xshare:dump -jar myapp.jar

Then launch with the archive:

java
java -XX:+UseAppCDS -XX:SharedArchiveFile=app.jsa \
  -jar myapp.jar

Startup drops from 3 seconds to around 1–1.5 seconds. Memory footprint stays similar. You keep all the JVM's JIT optimization and reflection flexibility. The build time is unchanged.

CDS is often the sweet spot: meaningful startup improvements without native image's complexity. For many teams, CDS + Spring AOT (which pre-computes bean definitions) gets you 80% of the way there with far fewer trade-offs.

Common mistakes

  • Building native images on underpowered machines. GraalVM uses lots of RAM during compilation. Allocate 8+ GB to the build. On CI agents with 4 GB, native builds fail mysteriously or timeout.
  • Forgetting to test the native binary thoroughly. A JAR works fine but the native binary crashes on startup? That's a missing reflection hint. Set up automated tests that run the native executable, not just the JVM version.
  • Assuming native images are always faster. They're faster to start. For sustained, CPU-bound workloads, the JVM's JIT may eventually outpace the static-compiled binary. Measure peak throughput in your scenario.
  • Ignoring library updates that break native compilation. A new dependency version might use reflection in ways that break the build. Pin your dependencies carefully and test native builds during upgrades.
  • Bundling native images in Docker with a base OS layer. The binary is self-contained, but Docker images still need a minimal runtime (glibc). Use gcr.io/distroless/base or similar, not full Ubuntu, to keep final image size under 150 MB.

Key takeaways

  • Spring Boot 3.0+ gives you first-class native image support via AOT. A single Maven command (./mvnw -Pnative native:compile) produces a production-ready executable.
  • Native images excel for serverless cold-start scenarios, dense Kubernetes deployments, and CLI tools. Startup drops from seconds to milliseconds; memory footprint shrinks to 50–100 MB.
  • The trade-offs are real: builds take longer, reflection hints are sometimes needed, and peak JIT throughput is slightly lower. Evaluate whether these costs fit your workload.
  • Class Data Sharing is a simpler alternative that cuts startup to 1–1.5 seconds without losing JVM flexibility. Consider CDS first if native image complexity feels excessive.
  • Test native binaries in your CI/CD pipeline, allocate adequate build resources, and use minimal base images. Native images are powerful but need discipline to maintain.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles