Sizing a JVM Container — Increasing the Limit is not Enough
Out of the box the modern JVM sizes its heap at 25 % of the container, for containers above roughly 500 MiB.1 Almost nobody leaves it there, and nobody agrees on what to use instead. Red Hat’s OpenJDK images shipped 50 % for years, then moved to 80 %.2 Plenty of guides say 75 %.3 Same problem, same JVM, four different recommendations and none of which account for the actual memory behavior of your application.
Properly tuning JVM containers in Kubernetes lets you do more than simply avoid OOMKilled events. It will allow you to stop blindly increasing the limit, whenever a service runs out of memory, improve application performance by allocating memory more effectively, and increase overall cluster stability by giving the Kubernetes scheduler containers with accurately sized resource requirements.
In this article, we introduce an algorithm that uses Prometheus metrics to automatically determine an appropriate heap size for each JVM container. Rather than relying on static rules of thumb, it continuously adapts to your application’s observed memory usage, allowing JVM tuning to remain effective even as workloads evolve over time.
Prerequisites
The only prerequisite is a metrics collection system. In Kubernetes, that typically means Prometheus. For JVM metrics, we use Micrometer.4 Container-level metrics come from cAdvisor, which is already exposed by the kubelet.5
Heap and Off-Heap
A JVM process uses memory in two broad categories: heap and native (everything outside the heap), shown as the two columns above.
Heap is where Java objects live. It is the only region managed by the garbage collector and exposes three values, always ordered
used<=committed<=max:6used — the memory currently occupied by objects, both live objects and garbage that has not yet been collected. This value typically follows a sawtooth pattern.
committed — the amount of heap memory the JVM has reserved from the operating system. It tends to follow the collector’s high-water mark rather than the application’s current live set.
Xmx— the maximum heap size. Unlike the other two values, this is a configuration parameter rather than an observation.
Native is all resident memory outside the heap. For our purposes, it consists of two parts:
off-heap — memory the JVM allocates and accounts for itself, including metaspace, the JIT code cache, thread stacks, direct byte buffers, and the garbage collector’s internal data structures.
untracked — everything else that contributes to the process’s resident memory but is invisible to Native Memory Tracking (NMT).7 This includes native libraries allocating memory through
malloc(for example, JNI code, compression libraries, or database drivers), as well as allocator overhead. For example, glibc creates up to eight allocation arenas per CPU core, each of which may retain freed memory instead of immediately returning it to the operating system.8
Unlike the heap, native memory is not reclaimed on a schedule you control. Metaspace is released only when classes are unloaded,9 and a direct ByteBuffer is freed only after the garbage collector reaches the Java object that references it.10
This asymmetry determines which sizing mistakes are expensive. The heap can never exceed Xmx: once it reaches the limit, the JVM throws an OutOfMemoryError before the process grows further.11 Native memory has no equivalent safeguard. After the container limit has enough room for Xmx plus the application’s typical native footprint, native memory is the only component that can continue to grow. OOMKilled events are therefore typically caused by native memory growth exhausting the remaining headroom.12
That makes an oversized heap the more costly mistake. A heap that is too small increases garbage collection frequency, making the problem visible through higher GC overhead. A heap that is too large consumes memory the application may never need, shrinking the headroom available for native allocations. That additional heap capacity is not always returned promptly to the operating system,6 so the container is left with less room to absorb native growth before the kernel terminates the process.
The Model
The model combines runtime signals with a small set of operator-controlled parameters to determine the memory limit, the request, and Xmx. The headroom values and the GC gate control how aggressively the model responds to the observed state. Those decisions affect both the running system and the signals collected on the next iteration, forming a feedback loop.
The feedback loop is what makes finding a stable solution difficult. If the model increases Xmx because the committed heap is close to its limit, the JVM typically expands to use the additional space. On the next iteration, the committed heap has grown as well, making another increase appear justified. With some garbage collectors, this positive feedback can converge on a large heap that is rarely reclaimed because there is no pressure to do so.
Choosing signals that distinguish genuine memory pressure from self-inflicted growth is therefore essential to keeping the controller stable.
The Algorithm
heap_headroom = 1.15
native_headroom = 1.10
request_headroom = 1.10
gc_gate = 0.10
window = 7d
committed_peak = max(max_over_time(
(sum by (pod) (jvm_memory_committed_bytes{area="heap"})
)[7d:10m]))
native_estimate = max(max_over_time(
(sum by (pod) (container_memory_rss)
- sum by (pod) (jvm_memory_used_bytes{area="heap"})
)[7d:10m]))
current_xmx = max(sum by (pod) (jvm_memory_max_bytes{area="heap"}))
gc_overhead = max(quantile_over_time(0.99,
(jvm_gc_overhead)[7d:10m]))
median_working_set = quantile(0.99, quantile_over_time(0.50,
(sum by (pod) (container_memory_working_set_bytes)
)[7d:10m]))
new_xmx = committed_peak × heap_headroom
if new_xmx > current_xmx and gc_overhead ≤ gc_gate:
new_xmx = current_xmx
new_limit = new_xmx + native_estimate × native_headroom
new_request = min(median_working_set × request_headroom, new_limit)
return new_xmx, new_limit, new_request
A complete, runnable implementation, including the floors, rounding and missing-signal handling this pseudocode leaves out, is available on GitHub.
Configuration and Signals
The JVM series come from Micrometer and the container series from cAdvisor.13 Signals are aggregated conservatively. Across pods, the model uses the busiest replica (max). Over time, limits are based on peaks or high percentiles, while requests are based on typical behavior using the median.
committed_peak. The committed heap. This forms the basis forXmx, because it is what the JVM actually reserved, including the space the collector holds for itself.native_estimate. Native memory cannot be measured directly, so it is estimated as container anonymous memory less heap memory. The ideal subtraction would use the resident heap, which lies betweenusedandcommitted:rss − committed ≤ native ≤ rss − usedSubtracting
committedunderestimates native memory; subtractingusedoverestimates it by resident heap pages that no longer contain live objects. This controller uses the upper bound because native memory has no enforced ceiling: underestimation risksOOMKilled, while overestimation only reserves additional memory. WithAlwaysPreTouch, committed pages are resident from the moment they are committed, making the lower bound accurate.current_xmx. The heap limit the JVM is currently running with, whether set by an operator or produced by a previous run of the controller.gc_overhead. The fraction of wall-clock time the JVM spent inside stop-the-world collection pauses, over a rolling five-minute window. Concurrent collector work is not counted.14median_working_set. The working set, which is what the kernel compares against the container limit. It forms the basis for the request.
Steps
- Propose a heap from the demand the signals report. The multiplier is a variance margin over a value the JVM itself produced, which is why it stays small. Multiply committed by a GC-headroom factor instead and you pay for the same headroom twice.
- Keep the current heap unless GC overhead is high. A heap at its ceiling is not evidence it needs more, because a collector with spare room does not reclaim it. GC overhead is the only signal that distinguishes hoarding from starvation, so it alone can justify growth. The gate belongs with the collector: G1 and ZGC do much of the work concurrently and only their pauses reach this metric, so the gate should be lower for them.
- Set the new limit.
new_xmxis an enforced limit;native_estimate × native_headroomis an estimate with a safety margin. Only one is guaranteed, which is why the second multiplier is necessary. Raise it for anything that loads classes at runtime, spawns threads, leans on direct buffers, or holds many connections. - Set the request, capped at the limit.15 The two are computed independently, and a native-heavy workload with an aggressively reclaimed heap can otherwise produce a request larger than its own limit.
A Worked Example
A service in a 1 GiB container, running with MaxRAMPercentage=75 and so an Xmx of 768 MiB. Over the window:
| Signal | Value |
|---|---|
committed_peak | 300 MiB |
native_estimate | 270 MiB |
current_xmx | 768 MiB |
gc_overhead | 0.02 |
median_working_set | 430 MiB |
Feed those through:
new_xmx = 300 × 1.15 = 345 MiB 345 ≤ 768, no change
new_limit = 345 + 270 × 1.10 = 642 MiB
new_request = min(430 × 1.10, 642) = 473 MiB
GC overhead never enters the arithmetic. The heap is reclaiming, and the gate only applies when the signals indicate demand above the current ceiling.
Contrast the blind default. Xmx = 768 MiB against a committed peak of 300 reserves 468 MiB of heap the application never touches, and budgets nothing for native at all. The derived pair lands at a 642 MiB limit with both halves accounted for, on a container that was 1 GiB.
JVM Flags
The algorithm produces an Xmx value in MiB. That value can be applied through JVM startup flags or injected through the JAVA_TOOL_OPTIONS environment variable, which the JVM reads automatically at startup.
| Flag | What it sets |
|---|---|
-Xmx | Maximum heap size, in bytes. This is the direct form of the value produced by the algorithm. |
-XX:MaxRAMPercentage | Maximum heap size as a percentage of the cgroup memory limit. 100 × Xmx / limit produces the equivalent percentage, which the JVM recomputes at startup from the limit it observes. This setting applies only above roughly 500 MiB. |
-XX:MinRAMPercentage | Maximum heap size for small containers below roughly 250 MiB, where MaxRAMPercentage is ignored. Between the two thresholds, neither percentage applies: the heap uses a fixed floor of roughly 125 MiB, so a 256 MiB and 400 MiB container can receive the same heap size. |
-Xms | Initial heap size, in bytes. Set close to the expected operating size so the JVM starts near its steady state instead of expanding during early traffic. |
-XX:InitialRAMPercentage | Initial heap size as a percentage of the cgroup memory limit. |
-XX:AlwaysPreTouch | Faults heap pages into memory during heap initialization or expansion instead of waiting for the first write to each page.16 |
That 125 MiB is the default MaxHeapSize, ScaleForWordSize(96M) in the JVM’s own argument parsing, which is also where the Min/Max branch is decided.17
JVM ergonomics —
MaxRAMPercentage,MinRAMPercentage,InitialRAMPercentage,javatool reference, JDK 21. ↩︎Overhauling memory tuning in OpenJDK containers, Red Hat Developer, March 2023. UBI8 images defaulted
JAVA_MAX_MEM_RATIOto 50 % for years; UBI9 shipped 80 % from the start and UBI8 followed in June 2023. The change is visible in the image sources:java-default-optionsand the commit "(re)set MaxRAMPercentage default to 80%". The Red Hat article itself rejects automated requests. ↩︎Containerize your Java applications, Microsoft Learn, which recommends
-XX:MaxRAMPercentage=75as a starting point. ↩︎Production-ready metrics, Spring Boot reference. The series come from Micrometer’s
JvmMemoryMetricsandJvmHeapPressureMetricsbinders, both auto-configured by Spring Boot. Micrometer’s JVM reference lists the binders rather than the series names, so the names below are best confirmed against a live/actuator/prometheus. The binders are registered byJvmMetricsAutoConfiguration. ↩︎Resource metrics pipeline, Kubernetes: “cAdvisor: Daemon for collecting, aggregating and exposing container metrics included in Kubelet.”
container_memory_rssis the cgroup anonymous counter and the working set is an estimate that “makes heavy use of heuristics”; seecontainer.go. ↩︎MemoryUsage, Java API documentation: “committed will always be greater than or equal to used”, and “The Java virtual machine may release memory to the system and committed could be less than init.” ↩︎ ↩︎Native Memory Tracking, Java Virtual Machine Guide, JDK 21: “NMT tracks only the memory that the JVM or HotSpot VM uses, not the user’s native memory.” ↩︎
Memory allocation tunables, glibc manual: on 64-bit systems the arena limit “is 8 times the number of cores online”, capped by
glibc.malloc.arena_maxormallopt(M_ARENA_MAX). The 30-40 % retention figure is a field observation from Java OOMKilled with a stable heap, Michal Drozd, not a measured study. ↩︎JEP 387: Elastic Metaspace: “When a class loader is reclaimed, the chunks in its metaspace arena are placed on freelists for later reuse.” ↩︎
Direct-X-Buffer.java.template, OpenJDK: the buffer registers aCleanerwhoseDeallocatorcallsUNSAFE.freeMemory. ↩︎The Java Virtual Machine Specification, SE 21, §2.5.3: “If a computation requires more heap than can be made available by the automatic storage management system, the Java Virtual Machine throws an OutOfMemoryError.” ↩︎
Resource management for pods and containers, Kubernetes: “memory limits are enforced by the kernel with out of memory (OOM) kills.” ↩︎
MicrometerJvmMemoryMeterConventionsdefinesjvm.memory.{used,committed,max}and theareatag; naming explains the Prometheus translation. ↩︎JvmHeapPressureMetrics, Micrometer: a five-minute lookback, andgcPauseSumrecords a collection only whenisConcurrentPhaseis false. ↩︎Pod API reference, Kubernetes: “Requests cannot exceed Limits.” ↩︎
g1RegionToSpaceMapper.cpp, OpenJDK: theAlwaysPreTouchcheck sits insidecommit_regions, the path taken for every region commit rather than only the initial one. ↩︎Arguments::set_heap_size, OpenJDK HotSpot, pinned at the JDK 25 tag: the branch that comparesMinRAMPercentageof the limit against the defaultMaxHeapSize. It moved toGCArguments::set_heap_sizeonmasterin April 2026, so the tagged link is the durable one. The four constants it reads are declared together ingc_globals.hpp, whereMaxHeapSizedefaults toScaleForWordSize(96*M). ↩︎