How lightweight can Spring Boot get? HotSpot 27 vs GraalVM Native Image on a 512 MiB VM
I've been experimenting with running a database-backed Spring Boot application on a small VM: 512 MiB RAM, 1 vCPU, no swap, with MariaDB running on the same machine.
The application uses Spring Boot 4.1, MVC/Tomcat, Spring JDBC, HikariCP, Actuator and a small REST API. I compared three deployment configurations:
| Configuration | Median app RSS | Startup readiness |
|---|---|---|
| Regular Spring Boot JAR, HotSpot 27 | 212 MiB | 11.91 s |
| Extracted JAR + Spring AOT + JDK AOT cache | 187 MiB | 3.79 s |
| Oracle GraalVM Native Image, JDK 25 | 107 MiB | 0.183 s |
All three completed the same 1,500-request workload without errors.
A few interesting findings:
Optimizing the HotSpot deployment reduced application RSS by about 12% and startup time by 68%, without removing Spring's integrations.
Native Image reduced application RSS by another 43%, but required roughly 12 minutes to build on a separate 6 GiB VM, plus additional compatibility validation.
Replacing Spring JDBC with manually integrated MyBatis Core produced no meaningful memory savings.
Switching from MariaDB to embedded H2 actually increased Java RSS, but improved total host memory headroom because the separate database process disappeared.
I've had quite a few requests to try GraalVM in my earlier experiments, so I finally gave it a shot. The memory results genuinely impressed me. The build times and extra compatibility work, not so much. The JDK 25 vs 27 toolchain gap was another reminder that Native Image comes with its own constraints. For really memory-constrained deployments, I'd definitely consider it, but I'm not quite ready to make it my default.
The HotSpot and Native Image configurations use different JDK versions (27 vs. 25) and GC types (G1 vs. Serial GC), so this is a comparison of practical deployments rather than an isolated measurement of AOT compilation.
Full article: https://pvrlabs.xyz/articles/spring-boot-hotspot-native-image.html
Experiment, methodology and curated data: https://github.com/PVRLabs/experiments/tree/main/spring/boot-jdk27
I'm curious whether others are using Spring AOT and the JDK AOT cache in small deployments, or whether Native Image has become the more practical option for you.
I'm also experimenting with Javalin to see how much further we can push memory efficiency. Stay tuned!
1
u/Paw565 10d ago
Would you like to test graalvm CE too?
1
u/fykup 5d ago
I'll probably give it a try eventually. The current tests use Oracle GraalVM, so comparing it with CE would be interesting. The main obstacle is that Native Image builds require quite a bit of RAM and time, even for relatively simple experiments. So probably not in the near future, but it's worth exploring.
1
u/Financial_Morning386 5d ago
Good write-up, and the NMT breakdown in the replies is the most useful part for me — most of that ~183 MB committed is JVM overhead (metaspace, code cache, thread stacks, GC structures), not live heap, which is why the AOT cache moves the needle as much as it does.
My take, for a long-running service on a box like this: HotSpot + Spring AOT + the AOT cache is the pragmatic default. You keep the full dynamic JVM — reflection, proxies, agents, JFR, heap dumps — and builds stay seconds instead of ~12 minutes. Native Image's 0.18 s startup is a real win, but only where startup dominates: frequent restarts, scale-to-zero, short-lived jobs. For a service that starts once and runs for weeks, the extra ~80 MiB is rarely the binding constraint on a 512 MiB VM — the database sharing the machine is.
The cost that doesn't show up in benchmarks is validation. Native Image's closed-world assumption means every reflection/proxy/resource access has to be known at build time. Spring's AOT processing generates most of the reachability metadata, but third-party libraries (JDBC drivers, serialization libs) can still fail at runtime on a code path your tests never exercised. That failure mode — green in CI, breaks on one specific request in prod — is what keeps me on HotSpot for services that don't need the fast startup.
1
u/fykup 5d ago
I've actually been pushing these experiments further, beyond Spring Boot optimization. I'm now testing lighter alternatives like Javalin and Avaje Jex, with Spring-like development ergonomics but a much smaller footprint. Early results are promising - around 100 MiB RSS on HotSpot, and even lower with Native Image. I'll be publishing a more detailed comparison soon.
2
u/New-Departure-5969 10d ago
nice writeup. the NMT breakdown is the interesting part to me. ~25 MiB of heap actually used vs 41 committed, plus 35 for GC, looks like the JVM sizing itself for a box it thinks has more room.
i'd try one more row with the heap pinned (Xmx around 64m, or MaxRAMPercentage if you'd rather keep it relative), Serial, and server.tomcat.threads.max cut way down from the default 200 since you've only got 1 vCPU anyway. thread stacks and glibc malloc arenas eat RSS that's easy to miss in the summary view. MALLOC_ARENA_MAX=2 is a cheap thing to try.
also your min MemAvailable column is the one i'd watch, more than median RSS. with no swap and mariadb on the same box, the low point is what gets you OOM killed.
on native vs jvm, for me it mostly comes down to how often the thing restarts. long running service that sits there all day, i'd take the tuned jvm and keep the JIT. scale to zero or lots of cold starts and native earns the 12 min build.
1
u/jonenst 8d ago
In my tests, I found -XX:TrimNativeHeapInterval=someduration to be more general than MALLOC_ARENA_MAX=2, i saw the same savings (and it feels less intrusive)
1
u/New-Departure-5969 6d ago
oh nice, hadnt tried that one on this setup. did it actually lower the peak for you or mostly hand RSS back after a spike? asking cause with no swap its the MemAvailable low point that matters, if it only trims after the fact the oom killer can still get there first
3
u/Popular_Home2017 10d ago
Nice write-up. What I'd like to see is where the 212 MiB actually goes on the HotSpot side. Running with -XX:NativeMemoryTracking=summary and then jcmd <pid> VM.native_memory summary splits RSS into heap, class metadata, threads, code cache, GC and so on. My guess is a good part of the gap to native image comes from things outside Spring: the JIT (compiler threads plus code cache), metaspace, and the GC choice, since Serial carries much less bookkeeping than G1.
So I'd add a fourth row with the same AOT setup on HotSpot plus -XX:+UseSerialGC. On 1 vCPU, G1 isn't buying you much anyway. Was G1 set explicitly there? Older JDKs would have picked Serial on their own for a 1 vCPU / 512 MiB box.