Notebook / Backend / 008
note entry no. 008 · July 14, 2026

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

The same Maven project, built as a GraalVM native image and as an OpenJDK 25 jar, runs at 84 MB vs 358 MB of working-set memory and starts in 0.33 s vs 5.98 s on the same Minikube cluster. The two builds share the source tree and differ in the Dockerfile, two environment variables, and the deployment's probe delays and resource limits. Prometheus records container-level memory for both.

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; 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.

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 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

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 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.

VM

V. M. Casale

backend / cloud / things that go bump in the night

I keep an engineering notebook of the small fixes, environment tricks, and infrastructure patterns that quietly make my work-week better.

Read next.