-Xmx Won't Save Your Native Image
Contents
Part 19 of the Micronaut native image series. Follows Part 18, where a reset Prometheus counter made the benchmark lie. Assumes familiarity with GraalVM native image and Docker memory limits.
The Confident Wrong Fix
# docker-compose.yml — the JVM reflex
environment:
JAVA_TOOL_OPTIONS: "-Xmx128m"
Standard. Reliable. Every JVM deployment in every Kubernetes cluster in the world does this. It has never not worked.
It is a no-op on a native binary.
GraalVM native images are not JVMs. They don’t read JAVA_TOOL_OPTIONS. They don’t read -Xmx from environment variables. GraalVM issue #4650 requested this capability. Issue #9504 confirmed it’s still not supported. Your heap cap does nothing. No warning. No error. The binary starts, ignores the variable, sizes its heap however it wants.
The Crash Nobody Could See
Spring Boot 4.1, WebFlux + R2DBC, GraalVM CE 25 native image. Six endpoints, each fanning out to ~28 mock HTTP APIs. k6 ramping to 300 VUs. Container memory limit: 256MB, swap disabled.
At peak load, the container died. And restarted. And died. And restarted. Five times.
The error logs were a wall of the same line:
reactor.netty.http.client.PrematureCloseException: Connection prematurely closed BEFORE response
Here’s the part that costs you an hour: docker stats, polling every 2 seconds, reported a peak RSS of 233 MB. Under the 256 MB limit. By every metric the monitoring could see, the container was fine.
It wasn’t fine. The OOM spike happened between two 2-second samples. The kernel killed the process, Docker restarted it (restart: on-failure), and by the next sample the freshly-restarted container was back at 170 MB. The real evidence was the restart count — and the HTTP-served counter resetting to zero mid-benchmark, turning 65,000 served requests into a reported 820.
Why 80% Is Too Much
GraalVM’s Serial GC — the default for native images — sets the maximum heap to 80% of available memory. In a 256 MB container, that’s ~205 MB reserved for heap.
But RSS is not just heap:
| Component | Approximate |
|---|---|
| Heap (Serial GC default) | up to 205 MB |
| Netty direct buffers (28 concurrent channels) | 20-40 MB |
| Native image code + rodata | ~15 MB |
| Thread stacks (61 threads) | ~8 MB |
| Total at peak | 248-268 MB |
At 200 VUs with 28-way fan-out, each request opens connections to 28 mock APIs through reactor-netty. Each connection allocates channel pipelines and pooled direct ByteBufs. The heap fits. Everything else doesn’t.
The Fix That Actually Works
Native images accept -XX:MaxHeapSize as a runtime argument to the binary itself — not through environment variables, not through JAVA_TOOL_OPTIONS, but as an argument on the command line:
# docker-compose.yml
services:
app:
build: .
mem_limit: ${MEM_LIMIT:-0}
memswap_limit: ${MEM_LIMIT:-0}
# JAVA_TOOL_OPTIONS/-Xmx are ignored by native image.
# Cap heap as a runtime arg to the binary.
command: ["-XX:MaxHeapSize=${NATIVE_MAX_HEAP:-2g}"]
The scenario scripts export the cap per memory tier:
# native-synth-256mb.sh
export NATIVE_MAX_HEAP=128m # 50% of container, not 80%
# native-synth-128mb.sh
export NATIVE_MAX_HEAP=64m
# unlimited scenarios default to 2g — effectively uncapped
128 MB of heap inside a 256 MB container leaves ~128 MB for Netty buffers, image overhead, and thread stacks. More than enough.
Before and after
| Metric | No cap (80% default) | Capped at 128m |
|---|---|---|
| Restarts | 5 | 0 |
| Heap max | ~205 MB (theoretical) | 116 MB |
| RSS max | 233 MB (sampled) | 234 MB |
| HTTP served | 820 (bogus — counter reset) | 65,857 |
| Errors | PrematureCloseException flood | 0.000% |
| Throughput | crash-loop | 101 req/s |
The RSS max is nearly identical. The difference is that the capped version never spikes past the limit between samples. The uncapped version’s 233 MB was the container after restart, not at the moment of death.
The Micronaut Contrast
The sibling Micronaut 5 native image, same workload, same 256 MB container, no heap cap at all:
| Metric | Micronaut (no cap) | Spring Boot (capped 128m) |
|---|---|---|
| RSS max | 192 MB | 234 MB |
| Heap max | 151 MB | 116 MB |
| Restarts | 0 | 0 |
| Requests served | 74,657 | 65,857 |
Micronaut’s native image is lighter — smaller framework footprint, less image overhead. The Serial GC’s 80% default gives it ~205 MB of heap, but it only uses 151 MB. Combined with off-heap overhead, peak RSS stays at 192 MB. Plenty of room.
Spring Boot’s image is larger. Same 80% default, but the off-heap overhead pushes total RSS past the container limit. The fix isn’t “use Micronaut” — it’s “know your framework’s footprint and size the heap accordingly.”
What Native Image Memory Is Not
It is not JVM memory. The mental model from twenty years of JVM deployment — JAVA_TOOL_OPTIONS, -Xmx, environment variables — does not apply. A native binary has its own argument parsing that runs before main(). You must pass flags to the binary, not to a JVM that doesn’t exist.
It is not one number. Heap is the part you can cap. Netty direct buffers, image code, and thread stacks are not subject to -XX:MaxHeapSize. A heap that “fits” inside the container says nothing about whether the container has room for everything else.
It is not portable across frameworks. Micronaut native and Spring Boot native have different image sizes, different off-heap profiles, different amounts of headroom at the same container limit. “It works for Micronaut at 256 MB” told me nothing about Spring Boot at 256 MB.
It is not visible at 2-second resolution. If your monitoring polls RSS every N seconds and the OOM spike is narrower than N seconds, you will see a container that “looks fine” and restarts five times. Trust the restart count. Better yet, watch for counter resets in your metrics — a Prometheus counter that goes backwards is a process that died.