16,700 Classes Before Your First Request
Contents
Part 24 of the memory benchmark series. Assumes familiarity with JVM memory regions and container memory limits.
96% at Idle
Quarkus JVM, jdbc-synth scenario, 256 MB container (--memory=256m, swap disabled). The app starts. Prometheus reports heap usage: 37 MB used out of a 62 MB cap. Healthy. Normal. Unremarkable.
Container RSS at idle: 246 MB.
The first k6 ramp hits, allocates a few buffers, and the OOM killer fires. Twenty-six restarts. 42.8 req/s. 53% error rate. FAILED.
Micronaut JVM on the same scenario, same 256 MB limit: runs clean. Spring Boot JVM: runs clean. Same heap cap, same workload, same container. Quarkus is the one that doesn’t fit.
Not Heap. Not a Leak.
Every instinct says “tune the heap.” But the heap IS tuned — -Xmx62m, and only 37 MB is used. There’s nothing to reclaim.
The next instinct says “it’s a memory leak.” But metaspace doesn’t grow under load. It plateaus after startup and stays flat. This is a working set, not a leak. Quarkus loads more classes at startup than the other two frameworks.
How many more:
| Framework | Classes loaded | Metaspace |
|---|---|---|
| Micronaut | ~12,200 | ~64 MB |
| Quarkus | ~16,700 | ~86 MB |
Twenty-two megabytes of metaspace separates “runs clean” from “OOMs on the first request.”
Why Quarkus Specifically
Quarkus’s build-time augmentation (ArC, Jandex) is supposed to move work OUT of the runtime. And it does — augmentation metadata isn’t loaded at runtime in JVM mode. But the runtime stack that is loaded is heavier than Micronaut’s:
- Hibernate ORM — metamodel classes, proxy generators, dialect resolution, type descriptors. This is the big one. Micronaut uses JDBC directly; Spring Boot uses JdbcClient. Neither loads an ORM.
- Agroal — connection pool (vs. HikariCP in the others)
- Narayana — JTA transaction manager (Quarkus pulls this in with Hibernate)
- RESTEasy Reactive — JAX-RS runtime on Vert.x
- Vert.x + Netty — the full reactive IO stack (Micronaut also uses Netty, but with a thinner abstraction layer)
The tell: Quarkus reactive-r2dbc (no Hibernate) at 256 MB is only “tight” — a few early restarts, then stable. Not FAILED. Remove Hibernate and there’s enough headroom to survive.
Native Erases It
GraalVM native image compiles everything to machine code ahead of time. No JVM bytecode loading, no metaspace. The entire class/method metadata question disappears.
Quarkus native at 128 MB: runs clean. Half the memory limit, zero metaspace overhead. This is why Quarkus invested so heavily in native image support — their runtime stack is heavier, so the savings from eliminating metaspace are proportionally larger.
The AOT Cache Rescue (Leyden, JDK 24/25)
Project Leyden’s AOT cache (the aot-jar packaging in Quarkus) memory-maps the class metadata from a pre-recorded archive instead of loading it into committed metaspace. In theory, this gives you native-like metaspace behavior with JVM semantics.
In practice, loading that cache requires the training JVM and runtime JVM to be byte-for-byte identical, and you will hit four distinct “Unable to map shared spaces” errors before it works.
Wall 1: Heap geometry. The training run records the JVM’s heap region layout. Training on a 27 GB developer machine bakes in giant heap regions. The 256 MB container rejects them. Fix: train with the target’s memory constraints.
# application.properties
quarkus.package.jar.aot.additional-recording-args=-XX:MaxRAM=256m
Wall 2: JDK distribution. Host SDKMAN Temurin is not the same binary as Docker’s eclipse-temurin. Different lib/modules, different shared archive layout. “lib/modules size has changed.” Fix: build the cache INSIDE the runtime Docker image, not on the host.
Wall 3: JDK image variant. eclipse-temurin:25-jdk for building vs eclipse-temurin:25-jre for runtime. The JRE’s lib/modules is a trimmed subset of the JDK’s. Same distribution, different size, same error. Fix: runtime must use the JDK image — the same one the training ran in.
Wall 4: Actually loading it. After surviving walls 1-3, the JVM still needs to be told to use the cache.
java -XX:AOTCache=app.aot -jar app.jar
Each wall produces the same error message. Each has a different cause. The fix for each is “make the training and runtime environments more identical,” but the ways they can differ are non-obvious.
The Numbers
Before AOT cache:
42.8 req/s | 53% errors | 26 restarts | FAILED
After AOT cache:
2525 req/s | 0.14% errors | 3 restarts | tight
Metaspace metric drops to zero — the JVM’s Metaspace committed counter goes away because class metadata is served from the mmap’d archive, not from anonymous committed pages. The three early restarts happen during warmup (the app still touches 256 MB briefly), so it’s “tight” rather than rock-solid. But it runs.
Idle RSS is only ~10 MB lower than without the cache. The 62 MB app.aot mmap shows up in RSS as file-backed pages. The savings aren’t in total RSS — they’re in the headroom under load, where metaspace no longer competes with heap for the remaining headroom.
The Clean Answer and the Other One
Native image is the clean low-memory path for Quarkus. No metaspace, no class loading, no “identical JVM” build constraints. It fits 128 MB. It starts in under a second.
AOT cache is the answer you get when you ask “but what if I need JVM?” It works. The throughput proves it. But the build requires four things to be identical that you wouldn’t think to check, and the error message is the same for all four. Debugging it is an exercise in binary archaeology.
The benchmark keeps untuned java -jar for all three frameworks — fair comparison, since Micronaut and Spring Boot also run plain. A tuned headline would apply AOT/SerialGC to all three. But that’s a different experiment, and the metaspace floor is the story: Quarkus’s runtime stack costs 22 MB more than Micronaut’s, and at 256 MB, 22 MB is the difference between running and dying.
Sources
- Project Leyden — AOT cache, ahead-of-time class data sharing
- Quarkus AOT Packaging —
quarkus.package.jar.aot.*configuration keys - Quarkus Class Loading Reference — augmentation vs runtime class loading
- JDK 25 java command —
-XX:AOTCacheflag reference