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. The Makefile forwards the Kubernetes service ports 8091 and 8092 to localhost. The two variants share one Postgres instance, with one schema each, so make seed loads both without mixing their data. A Prometheus job in the cluster scrapes container_memory_working_set_bytes from the cAdvisor endpoint of the kubelet. JVM heap metrics do not exist for a native binary, so the comparison uses container metrics for both variants.
Build issues and fixes
Both builds needed three fixes.
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 come 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 grew to 411 MB. A JVM heap usually grows by 10 to 20% under load. app-native went down to 49 MB after the load ended. A native image uses Serial GC by default, and Serial GC gives back the memory of short-lived request objects instead of keeping a large heap. Average CPU during the stress run was 20.8% for the native binary and 29.6% for the jar. The JVM value includes the JIT compilation of the code paths used by the new load.
Startup time
The time comes from the “Started” log line of Spring Boot after a clean pod restart. Startup time decides how fast a new pod can serve traffic when the deployment scales out. The native pod is ready before its first readiness check, 2 seconds after the container starts. With the probe settings of this project, the JVM pod has its first check after 20 seconds, so a new JVM replica is not ready for at least 20 seconds.
What the numbers mean
The 274 MB difference is memory that the JVM itself uses before the first request: the heap, the JIT compiler with its profiling data, and the metaspace for loaded classes. A native image compiles the reachable code ahead of time and does not need them. The JVM number also depends on the deployment. By default HotSpot sets the maximum heap to 25% of the container memory limit. With the 1 GiB limit used here, that is 256 MB. A lower limit gives a smaller heap and a smaller working set.
The native build has two costs. It is slow: the native compile of this service took 2m39s, and the jar builds in seconds. It is also less flexible: the compiler decides at build time which code is reachable, and the binary cannot load other code later. The community reachability metadata covered all the framework classes of this project. The only missing hint was the UUID[] type above.
When to use each build
The comparison with Python also depends on the number of processes. The native service handles concurrent requests with threads in one process. A Python service runs several worker processes to use more than one CPU core, and each worker has a full copy of the application. The memory of the Python service grows with the number of workers, and the native service stays one process.
Running it
The project is at github.com/valeriomc/spring-native-vs-jvm.
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 the Docker daemon of Minikube, so you do not need GraalVM on your machine. Grafana runs on http://localhost:3000 with user admin and password admin. Its dashboard compares working-set memory, CPU and request rate of the two pods. All the numbers in this article come from that dashboard during a seed and stress run. make down deletes the cluster.