Every GC Failed at 256 MB (Except the Boring One)
Contents
Monday: “The JVM at 256 MB should work. Just need the right GC.”
Tuesday: G1GC. OOM at 150 concurrent users.
Tuesday, later: Shenandoah. OOM at 50 concurrent users.
Tuesday, even later: ZGC. OOM at zero concurrent users. Couldn’t serve a single request.
Wednesday: Serial GC with 7 JVM flags and 3 Micronaut properties. Survives 60 seconds before the OOM killer shows up anyway.
The Contenders
Same app: Micronaut 5.0.0 Space Observatory, 16 REST endpoints, reactive R2DBC, 7-table PostgreSQL schema. Container: 256 MB hard limit, swap disabled. Heap capped at 64 MB (-Xms64m -Xmx64m). Load: k6, ramping from 10 to 200 concurrent users over 8.5 minutes.
Four GCs. Same heap budget. Same container.
G1GC: The Default That Doesn’t Fit
G1GC is the JVM default for a reason — concurrent marking, incremental collection, pause-time goals. At 256 MB with -Xms16m -Xmx64m, it starts. Docker reports 222 MiB idle (measured manually before the benchmark harness, same heap cap and thread tuning).
The cost of all that sophistication: G1GC divides the heap into 1-2 MB regions, each with remembered sets tracking cross-region references. Card tables for dirty-card tracking. Concurrent marking threads running alongside application threads. On a 64 MB heap, these structures add ~25 MB of internal overhead.
222 MiB idle. 256 MiB limit. 34 MB of headroom. Under load at 150 concurrent users, G1’s concurrent marking threads compete with the application for that margin. The remembered sets grow as cross-region references multiply under allocation pressure. OOM.
A 64 MB heap with G1 is a logistics coordinator for a one-truck delivery service. The coordinator costs more than the truck.
Shenandoah: Concurrent Until It Can’t Be
Shenandoah’s promise: concurrent compaction, no stop-the-world pauses proportional to heap size. The implementation: forwarding pointers in every object header, concurrent GC threads running full-time, a brooks pointer on each object.
Docker reports 252 MiB idle. 4 MB of headroom. The concurrent GC threads themselves consume the memory they need to do the concurrent collection.
OOM at 50 concurrent users. Shenandoah never demonstrated its concurrency advantage because it consumed all available memory being concurrent.
ZGC: The Terabyte GC at 64 Megabytes
ZGC targets heaps from 8 MB to 16 TB. The “8 MB” is technically true. In practice: colored pointers, a multi-mapped heap (multiple virtual-to-physical mappings), load barriers. This per-byte overhead is negligible at 4 GB and catastrophic at 64 MB.
Docker reports 251 MiB idle. The heap-metadata ratio at this scale means ZGC has claimed the entire container for GC infrastructure. The app started. No memory remained to allocate a request context. Zero requests served.
Serial GC: Last One Standing
Serial GC: one thread, stop-the-world, copy collector for young gen, mark-sweep-compact for old gen. No concurrent overhead. No remembered sets. No forwarding pointers. The simplest possible garbage collector.
export JAVA_TOOL_OPTIONS="\
-Xms64m -Xmx64m \
-Xss256k \
-XX:+UseSerialGC \
-XX:ReservedCodeCacheSize=32m \
-Dmicronaut.server.netty.worker.threads=4 \
-Dmicronaut.netty.event-loops.default.num-threads=4 \
-Dmicronaut.executors.scheduled.core-pool-size=2"
Seven JVM flags. Three Micronaut properties. Every one necessary.
-Xss256k cuts thread stacks from 1 MB to 256 KB. On 74 threads: 55 MB saved.
-XX:ReservedCodeCacheSize=32m caps the JIT code cache. Default is 240 MB reserved (though not committed). The reservation alone can confuse the OOM killer.
The three Micronaut properties cut threads from ~96 to ~10 Netty threads plus 2 scheduled threads. Details in Part 3 — the short version is that Micronaut ignores -Dio.netty.eventLoopThreads and you need framework-specific properties.
Docker idle: 191.7 MiB. 64 MB of headroom. Enough to start. Enough to serve requests.
For a while.
60 Seconds of Life
Serial GC at 256 MB serves requests. Fast ones — 0.41 ms average, 2.42 ms p99. When responses arrive, they’re indistinguishable from the unlimited scenario.
Then the non-heap grows. The JIT compiler kicks in, profiling hot paths, compiling bytecode to native code. Each compilation loads new classes into metaspace and generates machine code into the code cache:
Startup: non-heap 93 MB → heap 29 MB → total 122 MB
+30s: non-heap 101 MB → heap 40 MB → total 141 MB
+50s: non-heap 115 MB → heap 44 MB → total 159 MB
+58s: non-heap 117 MB → heap 39 MB → total 156 MB
+60s: [prometheus scrape fails — container killed]
73 GC pauses in those 60 seconds. Total pause time: 175 ms. Average: 2.4 ms. Serial GC was doing its job. The heap never exceeded 45 MB — well within the 64 MB -Xmx.
Last successful prometheus scrape at 17:57:36. The next one at 17:57:38 returns nothing. Prometheus recorded 4,431 requests served up to that scrape; k6 counted 4,550 total successful responses (the delta hit between the last scrape and the kill). Then 19,763 requests hit a dead container. 81.3% error rate overall.
The app didn’t crash. The app was killed. The Linux OOM killer looked at RSS, compared it to the cgroup limit, and chose violence. -Xmx64m only governs the heap. Non-heap, thread stacks, JIT artifacts, mapped files — all outside that budget, all counted by the cgroup.
The heap was fine. Everything outside the heap killed it.
The Irony
At 256 MB, the JVM must use Serial GC. It’s the only collector that fits.
GraalVM Community Edition native images also use Serial GC. It’s the only collector available.
The constrained comparison is Serial GC vs. Serial GC. Native wins because it starts smaller:
| Native (128 MB limit) | JVM (256 MB limit) | |
|---|---|---|
| Idle RSS | 50 MiB | 192 MiB |
| Heap room | ~78 MB (49 MB peak) | ~64 MB |
| Non-heap | 0 MB | 93 MB (growing) |
| Threads | 78 | 74 |
| Total k6 requests | 1,634,338 | 24,316 |
| Successful responses | 1,634,338 (100%) | 4,550 (18.7%) |
| Tuning required | none | 10 flags/properties |
Native image at half the container memory serves 67x more requests with zero errors. Its non-heap never grows because there’s no JIT compiler loading classes and generating code. The baseline is 50 MiB and it stays at 50 MiB for the entire 10-minute load test.
The JVM’s runtime sophistication — the JIT, the dynamic class loading, the adaptive GC — is the exact machinery that makes it faster than native with abundant memory. At 256 MB, that same machinery is what kills it.
What JVM Unlimited Proves
Remove the memory limit, and the JVM is the better runtime:
| Metric | JVM unlimited | Native unlimited |
|---|---|---|
| Total requests | 1,663,341 | 1,641,863 |
| Avg latency | 0.43 ms | 0.81 ms |
| p99 latency | 1.70 ms | 8.27 ms |
| Peak CPU | 13.5% | 21.6% |
| Peak heap | 101 MB | 107 MB |
| Docker RSS | 539 MiB | 74 MiB |
2x lower latency. 40% less CPU. G1GC handles allocation pressure gracefully. The JIT’s profiling-guided optimizations produce tighter code than AOT. The p99 gap — 1.70 ms vs 8.27 ms — is the cost of Serial GC’s stop-the-world pauses at the tail.
The JVM is better at everything except memory and startup. Demonstrating “everything except memory” requires 539 MiB — over 7x what native needs.
The Decision Matrix
| Container budget | JVM | Native |
|---|---|---|
| 128 MB | Won’t start | Works (50 MiB idle, 0% errors) |
| 256 MB | Starts, dies under load | Works (same as unlimited) |
| 512 MB+ | Likely faster (extrapolated) | Works, higher CPU, higher p99 |
| Unlimited | 2x faster, 7x more RAM | 7x less RAM, higher latency |
The crossover is somewhere around 512 MB — not benchmarked, but the JVM at unlimited already shows what it can do when memory isn’t the constraint.
Below 512 MB, native wins by default. Not because it’s faster, but because it’s the only one alive.
Above that, you’re trading memory for latency and CPU efficiency. Whether that’s worth it depends on whether you’re paying for memory or paying for milliseconds. At scale with many replicas, 50 MiB vs 539 MiB per instance is 10x the pod count for the same cluster memory. At a single high-traffic instance, the JVM’s 2x latency advantage matters more than 500 MB.
There is no universal answer. There is a container size where the question stops being “which is better” and becomes “which one boots.”
Sources
- G1GC Tuning Guide — region size, remembered sets, concurrent marking
- ZGC Documentation — colored pointers, multi-mapped heap
- Shenandoah Wiki — forwarding pointers, concurrent compaction
- GraalVM CE Native Image Memory — Serial GC as only option in CE
- Part 3: The Observability Tax — why 128 MB doesn’t work, thread tuning
- Part 2: Native Image vs JVM at 128 MB — initial ab benchmarks
- Part 1: From mn create-app to Native Binary — build walkthrough