The full picture
- Allocator anatomy: size-class bins, per-thread caches (tcache/arenas) to avoid lock contention, small allocations from sliced arenas, huge ones straight to mmap. Fragmentation — external (holes) and internal (rounding) — is why long-lived mixed-size workloads bloat; jemalloc/tcmalloc exist largely as better fragmentation/concurrency trades.
- 'Free' doesn't shrink RSS: allocators keep pages for reuse (MADV_FREE lets the kernel reclaim lazily). Judge leaks by trend under stable load, not by 'it never goes down'.
- Container OOM math (the JVM classic): limit must cover heap + metaspace + thread stacks + code cache + direct buffers + allocator overhead. Exit 137 with a healthy heap = native side or limit math, not
OutOfMemoryError— different diagnosis, different fix (this is the ops mirror of the JVM container-memory question). - Defensive knobs:
oom_score_adjto shield critical daemons, cgroupmemory.high(throttle-before-kill), PSI alerts before the killer wakes, and-XX:+ExitOnOutOfMemoryErrorso the JVM dies loudly on its OOM rather than limping.
# Pod restarted, exit code 137, no heap dump, no OutOfMemoryError in logs
kubectl describe pod api-7f... # -> Last State: OOMKilled
# This was the CGROUP killer, not the JVM:
# limit 1Gi vs RSS = 780Mi heap-touched + 120Mi metaspace/code
# + 200 threads × 1MB stacks + direct buffers -> >1Gi on a burst
# Fixes: raise limit / trim stacks (-Xss512k) / cap direct buffers /
# -XX:MaxRAMPercentage=70 so heap leaves room for everything else.