A GraalVM native image of this Spring Boot 4.1 service uses 84 MB of working-set memory and starts in 0.33 seconds. The same source packaged as an OpenJDK 25 jar uses 358 MB and starts in 5.98 seconds. Both variants come from one Maven project and run side by side on one Minikube cluster, where Prometheus records the metrics and Grafana shows them on a shared dashboard.
The two variants share the pom.xml, the controllers, the Liquibase changelog, and the Postgres schema shape. They differ in the Dockerfile, in the APP_MODE and DB_SCHEMA environment variables, and in the deployment manifests, which give each pod its own readiness delay and resource limits. The limits affect the memory numbers and are listed below.
The setup
Both containers listen on port 8080; 8091 and 8092 are the Kubernetes service ports that the Makefile forwards to localhost. Both variants use one Postgres instance, each in its own schema, so make seed can load both without mixing their data. An in-cluster Prometheus job scrapes container_memory_working_set_bytes from the kubelet’s cAdvisor endpoint. JVM heap metrics don’t describe the memory of a native binary, so the comparison uses container-level data for both variants.
Build issues and fixes
Three fixes were needed before both builds passed.
ddl-auto: validate fails at startup with a missing-table error that looks like a schema problem.
cause
Spring Boot 4 split autoconfiguration into one module per feature. A direct dependency on org.liquibase:liquibase-core, sufficient on Spring Boot 3, no longer pulls in the module that runs migrations.
fix
Depend on org.springframework.boot:spring-boot-starter-liquibase instead of the core artifact, and review every other raw core-jar dependency carried over from Spring Boot 3.
21.0.2.
cause
The native-build-tools version bundled with Spring Boot 4.1 expects a metadata schema that only GraalVM 23 and later understand.
fix
Build with GraalVM 25: ghcr.io/graalvm/native-image-community:25.
app-native compiles cleanly, then throws MissingReflectionRegistrationError: Cannot reflectively instantiate the array class 'java.util.UUID[]' on the first request that touches the database.
cause
Hibernate builds a multi-id loader for every entity, and the loader reflectively allocates an array of the entity's id type. The add-reachability-metadata goal of native-maven-plugin pulls in community-maintained hints for Liquibase and Hibernate, but UUID[] follows from this project's choice of UUID as the entity id, and no published metadata registers it.
fix
Register the array type with a RuntimeHintsRegistrar, shown below.
class NativeHints implements RuntimeHintsRegistrar {
@Override
public void registerHints(RuntimeHints hints, ClassLoader classLoader) {
hints.reflection().registerType(UUID[].class);
}
}
@ImportRuntimeHints(NativeHints.class) on the application class registers the hint. The error message names the missing type and the section of reachability-metadata.json where it belongs.
Memory
The values above were read from container_memory_working_set_bytes after make seed sent 500 orders to each variant. During a 120-second stress run with 8 concurrent workers per variant, app-jvm rose to 411 MB. A 10-20% rise under load is normal for a JVM heap responding to allocation pressure. app-native dropped to 49 MB once the burst ended. Serial GC, the default collector in a native image, releases transient request allocations instead of holding a pre-sized heap. Average process CPU during the stress window was 20.8% for the native binary and 29.6% for the jar; the JVM figure includes JIT compilation triggered by the new load pattern.
Startup time
The measurement is Spring Boot’s “Started” log line after a clean pod restart. The gap sets how fast new capacity comes online when the deployment scales out. The native pod is up before its first readiness check, which runs 2 seconds after container start. The JVM pod waits 20 seconds for its first check with the probe settings used here, so a new replica stays unready for at least that long.
What this does and doesn’t tell you
The 274 MB difference is the resident cost of the JVM itself: heap, JIT compiler with its profiling data, and metaspace for loaded classes, all allocated before the first request. A native image compiles the reachable code ahead of time and starts without them. The JVM figure also depends on deployment configuration. HotSpot sizes its default max heap at 25% of the container memory limit, 256 MB under the 1 GiB limit used here; a lower limit shrinks the heap and the measured working set with it.
The native build costs compile time and flexibility. The native compile of this service took 2m39s against seconds for the jar, and the closed-world assumption fixes the set of reachable code at build time. Community reachability metadata covered every framework class in this project; the only gap was the project-specific UUID[] hint above.
Whether the migration pays off depends on the traffic profile. The native deployment requests 64 MiB of memory and the JVM deployment 256 MiB, so at these requests a node schedules four times as many native pods. Under sustained constant load HotSpot recompiles hot paths against the observed traffic pattern and can exceed the native binary’s peak throughput; a native image is compiled once and never re-optimized.
Running it
cd spring-native-vs-jvm
make up # minikube + postgres + prometheus + grafana + both images + deploy
make seed # 500 orders to each variant
make stress # 120 s of sustained load, DURATION=300 make stress to extend
Both images build inside Minikube’s Docker daemon, so no local GraalVM installation is needed. Grafana runs on http://localhost:3000 (admin/admin) with a dashboard that compares working-set memory, CPU, and request rate between the two pods. Every number in this article comes from that dashboard during a seed-and-stress run. make down deletes the cluster.