Building Native Images with Spring Boot and GraalVM: A Complete Guide

Building Native Images with Spring Boot and GraalVM: A Complete Guide

Introduction

In the world of cloud-native applications, startup time and memory footprint are critical metrics that directly impact deployment costs, scaling efficiency, and user experience. Traditional JVM-based applications, while powerful, often struggle with cold starts in containerized environments and consume significant memory resources.

Enter GraalVM Native Image—a revolutionary technology that transforms Java bytecode into standalone native executables before runtime. When combined with Spring Boot 3.x, this approach unlocks dramatic improvements in startup performance and reduced memory usage, making it ideal for serverless functions, microservices, and Kubernetes deployments.

In this comprehensive guide, we will explore everything you need to know about building native images with Spring Boot and GraalVM, from initial project setup to production deployment.

Why Use Native Images with Spring Boot?

The Performance Advantage

Traditional Spring Boot applications require a full JVM to run, which means:

Native images eliminate the JVM overhead by ahead-of-time (AOT) compilation:

When Should You Consider Native Images?

Native images are particularly beneficial for:

However, they may not be suitable for:

Prerequisites

Before diving into the implementation, ensure you have the following:

  1. Java 17 or higher (preferably Java 21 for production)
  2. Maven 3.8+ or Gradle 7+
  3. Docker for containerized builds (recommended)
  4. GraalVM with Native Image tool installed
  5. Spring Boot 3.2+ project

Setting Up Your Project

Option 1: Using Spring Initializr

The easiest way to start is through Spring Initializr. Visit start.spring.io and configure the following:

Option 2: Manual Configuration

If you prefer to configure manually, add the following to your pom.xml:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.2.4</version>
</parent>

<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
</dependencies>

<build>
<plugins>
<plugin>
<groupId>org.graalvm.buildtools</groupId>
<artifactId>native-maven-plugin</artifactId>
<version>0.9.28</version>
<extensions>true</extensions>
<configuration>
<mainClass>com.example.nativedemo.NativeDemoApplication</mainClass>
</configuration>
</plugin>
</plugins>
</build>

For Gradle projects, add to your build.gradle:

1
2
3
4
5
6
7
8
9
10
plugins {
id 'org.springframework.boot' version '3.2.4'
id 'io.spring.dependency-management' version '1.1.4'
id 'org.graalvm.buildtools.native' version '0.9.28'
}

dependencies {
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-actuator'
}

Building Your First Native Image

Local Build with Maven

To build a native image locally, ensure GraalVM is properly configured:

1
2
3
4
5
6
7
8
9
# Set GraalVM home
export JAVA_HOME=/path/to/graalvm-java17
export PATH=$JAVA_HOME/bin:$PATH

# Verify GraalVM installation
java -version

# Build native image
./mvnw package -Pnative

Local Build with Docker

For consistent builds across environments, use Docker:

1
./mvnw package -Pnative -Dnative.image.docker.build=true

This creates a Docker container that builds the native image, ensuring reproducibility.

Understanding the Build Output

After a successful build, you will find the native executable in:

1
target/native-demo

On Linux, the file will have no extension. On macOS/Windows, it will be native-demo.exe.

Configuration Best Practices

1. Reflection Configuration

GraalVM Native Image requires explicit configuration for reflective code. Add reflection hints to your project:

1
2
3
4
5
6
7
8
9
10
@RegisterForReflection(
targets = {
@ReflectionParameterizedType(
type = MyDto.class,
genericTypes = {String.class, Integer.class}
)
}
)
public class MyConfiguration {
}

Or use the reflect-config.json approach:

1
2
3
4
5
6
7
8
9
10
11
[
{
"name": "com.example.MyClass",
"allDeclaredConstructors": true,
"allPublicConstructors": true,
"allDeclaredMethods": true,
"allPublicMethods": true,
"allDeclaredFields": true,
"allPublicFields": true
}
]

2. Resource Configuration

Include non-classpath resources in your native image:

1
2
3
4
5
6
@BuildTimeResourceBundleHint(
resourceBundleName = "messages",
targetLocale = "en"
)
public class ResourceConfiguration {
}

Or configure in resource-config.json:

1
2
3
4
5
6
7
8
{
"resources": {
"includes": [
{"pattern": "messages_.*\\.properties"},
{"pattern": "META-INF/services/.*"}
]
}
}

3. Dynamic Proxy Configuration

For applications using dynamic proxies (common with Spring AOP):

1
2
3
4
5
6
@BuildTimeDynamicProxyHint(
proxyInterfaces = {MyInterface.class},
targetClass = MyImplementation.class
)
public class ProxyConfiguration {
}

Optimizing Build Performance

Incremental Builds

Enable incremental compilation to speed up rebuilds:

1
2
3
4
5
6
7
8
9
<plugin>
<groupId>org.graalvm.buildtools</groupId>
<artifactId>native-maven-plugin</artifactId>
<configuration>
<agent>
<enabled>true</enabled>
</agent>
</configuration>
</plugin>

Parallel Compilation

Leverage multi-core processors during build:

1
./mvnw package -Pnative -Dnative-image.parallel.threads=8

Memory Configuration

Control native image memory usage during build:

1
2
3
./mvnw package -Pnative \
-Dnative-image.xmx=8g \
-Dnative-image.max.heap.size=4g

Docker Deployment

Multi-Stage Dockerfile

Create an optimized Dockerfile for production:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# Build stage
FROM ghcr.io/graalvm/graalvm-ce:java17 AS build
WORKDIR /app
COPY . .
RUN ./mvnw package -Pnative -Dnative.image.docker.build=true

# Runtime stage
FROM alpine:3.19
RUN apk --no-cache add ca-certificates tzdata
ENV TZ=UTC

COPY --from=build /app/target/native-demo /native-demo
COPY --from=build /app/target/native-demo-run /native-demo-run

EXPOSE 8080
ENTRYPOINT ["/native-demo"]

Optimizing Image Size

Use distroless or scratch images for minimal footprint:

1
2
3
FROM gcr.io/distroless/java17-debian11
COPY --from=build /app/target/native-demo /native-demo
ENTRYPOINT ["/native-demo"]

This produces images as small as 60-80MB compared to 200-300MB for traditional JVM containers.

Monitoring and Debugging

Health Checks

Enable actuator endpoints for monitoring:

1
2
3
4
5
6
7
8
management:
endpoints:
web:
exposure:
include: health,info,metrics
endpoint:
health:
show-details: always

Native Image Diagnostic Options

Use diagnostic flags during development:

1
2
3
./mvnw package -Pnative \
-Dnative-image.agent=true \
-Dnative-image.dump-configuration=true

Common Issues and Solutions

Issue: ClassNotFoundException at runtime Solution: Add reflection configuration for missing classes

Issue: High memory usage during build Solution: Increase build memory or enable incremental compilation

Issue: Long build times Solution: Use Docker with cache mounts or enable parallel compilation

Production Considerations

CI/CD Integration

Integrate native image builds into your pipeline:

1
2
3
4
5
6
7
8
9
# GitHub Actions example
- name: Build Native Image
run: ./mvnw package -Pnative

- name: Build Docker Image
run: docker build -t myapp:latest .

- name: Push to Registry
run: docker push myapp:latest

Resource Limits

Set appropriate JVM and native image options for production:

1
2
3
4
5
6
7
spring:
profiles:
active: native
native:
image:
heap-size: 256m
max-heap-size: 512m

Cold Start Optimization

For serverless deployments, consider:

Key Takeaways

  1. Native images significantly improve startup performance – Applications can start in under 100ms compared to seconds with traditional JVMs.

  2. Memory footprint is dramatically reduced – Expect 50-80% memory savings, leading to lower infrastructure costs.

  3. Configuration is crucial – Proper reflection, resource, and proxy configuration prevents runtime errors.

  4. Docker builds ensure consistency – Use containerized builds for reproducible native images across environments.

  5. Not a silver bullet – Evaluate use cases carefully; native images excel in cloud-native and serverless scenarios but may not suit all applications.

  6. Tooling continues to improve – GraalVM and Spring Boot native support evolve rapidly, with regular performance improvements and new features.

  7. Build times can be optimized – Use incremental builds, parallel compilation, and proper caching strategies to reduce build times.

  8. Monitoring is essential – Leverage actuator endpoints and native image diagnostics for production visibility.

Building native images with Spring Boot and GraalVM represents a significant advancement in Java application performance. By following the guidelines in this post, you can unlock the full potential of native compilation and deliver faster, more efficient applications to production.