GraalVM Native Images with Spring Boot: Fast Startup, Low Memory, Real Trade-offs
A typical Spring Boot application takes 5-15 seconds to start and consumes 200-500MB of memory. GraalVM native images compile your application ahead-of-time into a standalone binary that starts in 50-200 milliseconds and uses 50-100MB of memory. Therefore, this guide covers the practical aspects of native image compilation with Spring Boot — what works, what breaks, and how to handle the trade-offs in production.
Why Native Images? The Container World Changed the Rules
In the era of always-on application servers, JVM startup time didn’t matter — your app started once and ran for months. In the container era, startup time matters constantly: Kubernetes scales pods up and down based on load, serverless functions cold-start on every invocation, and CI/CD pipelines run integration tests against fresh instances. Moreover, cloud costs are directly tied to memory consumption — a 5x memory reduction means 5x cost reduction for memory-bound workloads.
JVM vs Native Image Comparison (Spring Boot REST API):
JVM (OpenJDK 21) Native Image (GraalVM)
Startup time: 4.2 seconds 0.08 seconds (52x faster)
Memory (RSS): 380 MB 72 MB (5.3x less)
First response: 4.5 seconds 0.12 seconds
Binary size: 18 MB (JAR) 85 MB (native binary)
Build time: 30 seconds 4-8 minutes
Peak throughput: 45,000 req/s 38,000 req/s (15% less)
Warmup to peak: 30-60 seconds ImmediateThe trade-off is clear: native images win at startup, memory, and initial response time. JVM wins at peak throughput (thanks to JIT optimization) and build time. For microservices that need fast scaling and low memory, native images are compelling. For long-running services that need maximum throughput, the JVM is still better.
Building Native Images with Spring Boot 3
Spring Boot 3 has first-class support for GraalVM native images through Spring AOT (Ahead-of-Time) processing. The AOT engine analyzes your application at build time, generates optimized code, and produces the metadata GraalVM needs for native compilation.
# Prerequisites: GraalVM JDK 21+ installed
# Install native-image component
gu install native-image
# Build native image with Maven
./mvnw -Pnative native:compile
# Or with Gradle
./gradlew nativeCompile
# Or build as a container image (no local GraalVM needed)
./mvnw -Pnative spring-boot:build-image
# Uses Paketo buildpacks — works in CI/CD without GraalVM installation<!-- pom.xml — Spring Boot native image configuration -->
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.3.0</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-data-jpa</artifactId>
</dependency>
</dependencies>
<profiles>
<profile>
<id>native</id>
<build>
<plugins>
<plugin>
<groupId>org.graalvm.buildtools</groupId>
<artifactId>native-maven-plugin</artifactId>
<configuration>
<buildArgs>
<buildArg>--gc=G1</buildArg>
<buildArg>-march=compatibility</buildArg>
</buildArgs>
</configuration>
</plugin>
</plugins>
</build>
</profile>
</profiles>Reflection Configuration: The Biggest Pain Point
GraalVM native images analyze all reachable code at build time and include only what’s provably used. Reflection, dynamic proxies, and runtime class loading break this analysis because the compiler can’t determine at build time which classes will be accessed reflectively. Consequently, you need to provide explicit configuration telling GraalVM about reflective access.
// Spring Boot 3 handles most reflection configuration automatically
// through AOT processing. But custom code may need hints:
// Option 1: @RegisterReflectionForBinding — simplest approach
@RegisterReflectionForBinding({OrderDTO.class, CustomerDTO.class})
@RestController
public class OrderController {
// OrderDTO and CustomerDTO are registered for JSON serialization
}
// Option 2: RuntimeHints for complex scenarios
@ImportRuntimeHints(MyRuntimeHints.class)
@SpringBootApplication
public class Application {
static class MyRuntimeHints implements RuntimeHintsRegistrar {
@Override
public void registerHints(RuntimeHints hints, ClassLoader classLoader) {
// Register classes for reflection
hints.reflection().registerType(
MyDynamicClass.class,
MemberCategory.INVOKE_DECLARED_CONSTRUCTORS,
MemberCategory.INVOKE_DECLARED_METHODS
);
// Register resources
hints.resources().registerPattern("templates/*.html");
// Register proxies
hints.proxies().registerJdkProxy(
MyInterface.class, SpringProxy.class
);
}
}
}
// Option 3: Use the GraalVM tracing agent to auto-discover reflection
// Run tests with tracing agent to generate config:
// java -agentlib:native-image-agent=config-output-dir=src/main/resources/META-INF/native-image \
// -jar myapp.jarLibraries that heavily use reflection (some JSON processors, ORM features, serialization frameworks) may require manual configuration. Spring Boot 3 handles most Spring-specific reflection automatically, but third-party libraries may need hints. Additionally, the GraalVM tracing agent helps by recording reflective access during test execution and generating the necessary configuration files.
AOT Processing: What Spring Does at Build Time
Spring AOT replaces runtime bean creation with build-time code generation. Instead of scanning classpath and creating bean definitions at startup, AOT generates Java source code that directly instantiates beans, resolves dependencies, and applies configurations. This is why native images start so fast — there’s no component scanning, no annotation processing, and no proxy generation at runtime.
// What Spring does at runtime (JVM mode):
// 1. Scan classpath for @Component/@Service/@Repository annotations
// 2. Parse @Configuration classes
// 3. Create bean definitions
// 4. Resolve dependencies
// 5. Create CGLIB proxies for @Transactional, @Cacheable
// This takes 3-5 seconds
// What Spring AOT generates at build time (native mode):
// Generated code that does all of the above directly
public class Application__BeanDefinitions {
public static BeanDefinition getOrderServiceBeanDefinition() {
return BeanDefinitionBuilder
.rootBeanDefinition(OrderService.class)
.setInstanceSupplier(() -> new OrderService(
new OrderRepository(dataSource),
new PaymentClient(webClient)
))
.getBeanDefinition();
}
}
// Startup: just execute the generated code — millisecondsPerformance Trade-offs and Limitations
Native images have real limitations you need to understand before committing. Peak throughput is 10-20% lower than JVM because there’s no JIT compiler optimizing hot paths at runtime. Build times are 10-30x longer (minutes vs seconds), which slows development cycles. Some libraries don’t support native images at all. Furthermore, profiling and debugging tools for native images are less mature than JVM tools.
Use native images when: Startup time matters (Kubernetes scaling, serverless, CLI tools), memory cost is significant (running many small services), or you need instant peak performance without warmup.
Stay on JVM when: Maximum throughput is critical, build time matters for developer productivity, you depend on libraries without native image support, or you need advanced JVM profiling and debugging.
Related Reading:
Resources:
In conclusion, GraalVM native images with Spring Boot deliver transformative improvements in startup time and memory usage at the cost of build time and peak throughput. They’re ideal for containerized microservices, serverless functions, and CLI tools. For long-running, throughput-critical services, the JVM with JIT compilation remains the better choice.