008

Native image vs JVM on Spring Boot 4.1: 4.3x less memory, 18x faster startup

The GraalVM native image of a Spring Boot 4.1 service uses 84 MB of memory and starts in 0.33 s. The OpenJDK 25 jar of the same code uses 358 MB and starts in 5.98 s. Both builds come from one Maven project and run on one Minikube cluster, and Prometheus measures both at container level.

problem

Most native image vs JVM numbers come from benchmarks of other applications. Here one service is built both ways from the same source code and measured on the same dashboard, so the numbers describe this code on this cluster.

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

app-native GraalVM native image
app-jvm OpenJDK 25 jar
local port
8091
8092
base image
debian:bookworm-slim
eclipse-temurin:25-jre
build stage
ghcr.io/graalvm/native-image-community:25
eclipse-temurin:25-jdk
schema
order_bench_native
order_bench_jvm
memory request / limit
64 MiB / 512 MiB
256 MiB / 1 GiB
readiness probe delay
2 s
20 s
ORM / migrations
Spring Data JPA + Hibernate, Liquibase
Spring Data JPA + Hibernate, Liquibase
metrics
Micrometer + /actuator/prometheus
Micrometer + /actuator/prometheus

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.

01 Liquibase never runs
symptom The build compiles, Liquibase logs nothing, and Hibernate's 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.
02 Native build rejects the reachability metadata
symptom The native-image build fails on the reachability-metadata schema with GraalVM versions that many native-image guides still reference, such as 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.
03 Native binary crashes on the first database request
symptom 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

container working-set memory · after 500 seeded orders
app-native
84 MBbaseline
app-jvm
358 MB×4.3
container_memory_working_set_bytes · lower is better

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

time to "Started OrderBenchApplication" · from Spring Boot's own log line
app-native
0.33 sbaseline
app-jvm
5.98 s×18
clean pod restart, both against the same Postgres instance · lower is better

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

app-native better when
low-traffic services At 84 MB the service uses about as much memory as a Node.js or Python service. Java becomes an option where 358 MB per service is too expensive.
serverless functions A start time of 0.33 s is similar to the cold start of Node.js and Python functions. A JVM start of 5.98 s is too slow for code that starts on demand.
bursty traffic A new pod passes its readiness check 2 s after start, so autoscaling adds capacity while the burst is still in progress.
pod density With a 64 MiB memory request, a node schedules four times as many native pods as JVM pods at 256 MiB.
app-jvm better when
constant high traffic HotSpot recompiles the most used code paths for the real traffic and can reach a higher peak throughput than the native binary. The native image is compiled once and is not optimized again at runtime.
fast build cycles The jar builds in seconds. The native image of this service takes 2m39s per build.
runtime tooling Profilers, heap dumps and Java agents attach to a running JVM. A native binary does not support them in the same way.
dynamic class loading The native build fixes the set of classes at build time. Code that loads or generates classes at runtime needs the JVM.

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.