How We Cut Spring Boot Startup from 12 s to 3 s with GraalVM Native Image
This article walks through converting a Spring Boot order‑query microservice to a GraalVM Native Image, detailing environment setup, common build pitfalls with concrete code fixes, Docker multi‑stage packaging, K8s scaling comparison, performance benchmarks, CI/CD integration, and guidance on when Native Image is appropriate.
Background: a 12‑second startup microservice
We had an order‑query service built with Spring Boot 3.2.4, running on Kubernetes. The service receives a request, hits Redis, queries MySQL and returns data. Its startup time of ~12 s caused readinessProbe delays, request queuing, and HPA scaling latency.
Part 1: What GraalVM Native Image changes
Traditional JVM vs Native Image
In JVM mode the startup involves class loading, bytecode verification, interpretation, JIT compilation, and Spring bean scanning, taking 8‑15 s. Native Image performs AOT compilation, eliminating class‑loader overhead, JIT warm‑up, and generates a native executable that starts in 50‑200 ms.
JVM requires five serial phases before ready; Native Image moves all analysis to build time.
Spring Boot 3 AOT processing
At build time Spring analyzes annotations, reflection and dynamic proxies, generates hints (reflect‑config.json, proxy‑config.json, resource‑config.json) and feeds them to the GraalVM compiler, which produces a single native binary.
Part 2: Environment setup
Installing GraalVM
# Recommended via SDKMAN
curl -s "https://get.sdkman.io" | bash
source "$HOME/.sdkman/bin/sdkman-init.sh"
sdk install java 21.0.2-graal
sdk use java 21.0.2-graal
# Verify
java -version
native-image --version⚠️ Windows users must compile inside “x64 Native Tools Command Prompt” or use WSL2.
Part 3: Common pitfalls and fixes
Pitfall 1 – MySQL driver reflection failure
Error:
Caused by: java.lang.ClassNotFoundException: com.mysql.cj.jdbc.Driver
at com.zaxxer.hikari.util.DriverDataSource.<init>(DriverDataSource.java:110)Solution: add a reflect‑config.json entry:
[
{
"name": "com.mysql.cj.jdbc.Driver",
"allDeclaredConstructors": true,
"allPublicMethods": true,
"allDeclaredFields": true
}
]Pitfall 2 – Jackson serialization failure
Error:
InvalidDefinitionException: No serializer found for class OrderDTOSolution: register the DTOs for reflection:
@RegisterReflectionForBinding({OrderDTO.class, OrderListDTO.class})
@RestController
@RequestMapping("/api/orders")
public class OrderController { ... }Pitfall 3 – Lombok @Builder vs JPA @Entity conflict
Error:
Error: Field com.example.order.entity.Order.id is not accessibleAdd the field to reflect‑config.json with allowUnsafeAccess:
{
"name": "com.example.order.entity.Order",
"allDeclaredFields": true,
"fields": [
{"name":"id","allowUnsafeAccess":true},
{"name":"orderNo","allowUnsafeAccess":true}
]
}Pitfall 4 – Build OOM (exit status 137)
Increase build memory:
<plugin>
<groupId>org.graalvm.buildtools</groupId>
<artifactId>native-maven-plugin</artifactId>
<configuration>
<buildArgs>
<buildArg>-J-Xmx8g</buildArg>
<buildArg>-H:+GenerateBuildReport</buildArg>
</buildArgs>
</configuration>
</plugin>At least 8 GB RAM is required; CI machines should provide 16 GB.
Pitfall 5 – Lettuce connection pool init failure
Error:
NoSuchMethodException: io.lettuce.core.resource.DefaultClientResources.<init>Fix: upgrade Lettuce to 6.3.2+ in pom.xml.
Pitfall 6 – Missing resource files at runtime
Add required patterns to resource‑config.json:
{
"resources": {
"includes": [
{"pattern":"db/migration/.*\\.sql"},
{"pattern":"i18n/.*\\.properties"},
{"pattern":"application.*\\.yml"},
{"pattern":"META-INF/services/.*"},
{"pattern":"META-INF/spring/.*"}
]
}
}Pitfall 7 – Hibernate bytecode enhancement failure
Disable enhancement and use explicit JOIN FETCH:
spring:
jpa:
properties:
hibernate:
bytecode:
provider: none
open-in-view: falsePart 4: Docker multi‑stage build
Stage 1 (builder) uses graalvm/native-image:21 to compile the native binary (≈8 min, ~2 GB image). Stage 2 (runtime) uses debian:bookworm-slim, copies only the binary, creates a non‑root user, and runs ./order-query-service. Final image size is 87 MB versus 298 MB before.
Multi‑stage build reduces runtime image size by ~70 %.
Part 5: K8s scaling comparison
When HPA triggers, a JVM pod becomes ready after ~15 s, while a Native Image pod is ready after ~3 s, cutting the wait time by 80 %.
Part 6: Full performance data
Startup time: JVM 12.3 s → Native 0.089 s (‑99 %)
Pod Ready: 15 s → 3 s (‑80 %)
Memory (idle): 380 MB → 58 MB (‑85 %)
Docker image: 298 MB → 87 MB (‑71 %)
Stable QPS (200 concurrency): 4,800 → 3,900 (‑19 %)
P99 latency: 18 ms → 21 ms (+17 %)
Note: Native throughput is slightly lower because JVM JIT continues to optimise hot code at runtime, whereas Native Image uses generic compile‑time optimisations.
Part 7: CI/CD integration
CI pipeline runs JVM unit tests (~2 min) on every PR for fast feedback. Native Image build (8‑12 min) is triggered only on merge to main, after which the Docker image is pushed and deployed to K8s.
PR stage runs only JVM tests; full Native build runs on the release channel.
Part 8: When to use Native Image
Recommended for serverless/FaaS cold‑start, K8s microservices that need rapid scaling, CLI tools, and batch jobs. Caution for medium‑concurrency web services, memory‑constrained environments, legacy systems with heavy reflection, or long‑running high‑throughput services.
Matrix: top‑left (high start‑up sensitivity + short‑lived) = best for Native; bottom‑right (low start‑up sensitivity + long‑running) = prefer JVM.
Conclusion
By converting the order‑query service to a GraalVM Native Image we reduced pod start‑up from 15 s to 3 s, memory from 380 MB to 58 MB, and Docker image size from 298 MB to 87 MB, eliminating K8s scaling timeouts. Key lessons: address reflection‑related issues first, use GraalVM Reachability Metadata Repository, keep local development on JVM and compile Native only in CI.
Java Web Project
Focused on Java backend technologies, trending internet tech, and the latest industry developments. The platform serves over 200,000 Java developers, inviting you to learn and exchange ideas together. Check the menu for Java learning resources.
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.
