JVM-in-Container Basics
Understand how container cgroup memory limits interact with JVM heap defaults, why -Xmx still matters, and use jcmd/jstat to inspect a running JVM in Kubernetes.
What this lesson teaches
Every lesson so far has treated the JVM as a black box that either starts or doesn’t. This lesson opens that box just enough to explain the interaction that causes a huge share of Java-specific OOM incidents on Kubernetes: the relationship between the container’s cgroup memory limit and the JVM’s own heap-sizing defaults. You already diagnosed OOMKilled at the symptom level in the CrashLoopBackOff lesson, this lesson explains the mechanism underneath it, and introduces jcmd/jstat as basic inspection tools. Full thread dumps, heap dumps, and GC tuning are deliberately out of scope here, that’s Advanced-level material; this lesson is strictly the conceptual foundation and baseline identity checks.
Core concepts
cgroup limits vs JVM heap defaults
A container’s resources.limits.memory is enforced by the Linux kernel via a cgroup, the kernel simply kills the process (or the whole container, depending on cgroup configuration) if total memory usage crosses that ceiling, regardless of what the JVM thinks its own limits are. Historically (JDK 8 before update 191, and all versions before JDK 10), the JVM had no idea it was running in a container: it read /proc/meminfo for total system memory (the node’s memory, not the container’s limit) and sized its default heap (-Xmx, when not explicitly set) as a fraction of that. On a node with 64Gi of RAM, a container limited to 512Mi could still have a JVM default-sizing its heap toward several gigabytes, guaranteeing an OOM kill almost immediately under any real load.
Modern JVMs (JDK 10+, and backported to JDK 8u191+) are container-aware: they read the cgroup limits directly and size default heap as a fraction of the container limit, not the node’s total memory. This fixed the worst version of the problem, but does not eliminate the need to think about memory sizing, for two reasons covered next.
Why -Xmx still matters even with a container-aware JVM
The default heap fraction is conservative, not tailored to your app. A container-aware JVM defaults
-Xmxto roughly 25% of the container memory limit (-XX:MaxRAMPercentage, default 25.0). That’s a reasonable generic default, but it leaves the other ~75% of the container’s memory budget for everything else, and whether that split is correct for your specific app is something the JVM cannot know. An app doing heavy off-heap work (Netty buffers, large thread counts) needs a smaller heap fraction to leave room; an app that’s almost entirely heap-allocated objects with few threads could safely use a much larger fraction.Off-heap memory is not counted against
-Xmxat all, but IS counted against the container limit. This is the critical point:-Xmxonly bounds the heap. It does nothing to bound thread stacks (-Xss× thread count), metaspace (class metadata, unbounded by default unless-XX:MaxMetaspaceSizeis set), direct/native buffers (NIO, Netty, Kafka/gRPC clients), the JIT compiler’s code cache, or native library memory (glibc arena overhead, JNI). The container’s cgroup limit, by contrast, covers the process’s entire resident memory footprint, heap plus all of the above. A JVM can be well within its heap budget and still get the whole container OOM-killed because off-heap consumption pushed total RSS over the cgroup ceiling.
The practical implication: explicitly setting -Xmx (and ideally -XX:MaxMetaspaceSize, -Xss) to leave deliberate headroom below the container limit is still correct practice, even on a fully container-aware modern JVM, because the question isn’t “does the JVM know its limit,” it’s “does the heap-plus-everything-else total stay under that limit,” and only you know how much off-heap memory your specific app needs.
flowchart TB
subgraph "Container memory limit (e.g. 1Gi)"
H["JVM Heap (-Xmx)<br/>bounded, GC-managed"]
M["Metaspace<br/>unbounded unless -XX:MaxMetaspaceSize set"]
T["Thread stacks<br/>-Xss × thread count"]
N["Native/direct buffers<br/>Netty, NIO, JNI, JIT code cache"]
end
H -.->|"cgroup OOM killer doesn't care<br/>WHICH region grew : <br/>only that the TOTAL crossed the limit"| K[Kernel OOM kill<br/>= container Exit Code 137]
M -.-> K
T -.-> K
N -.-> K
Inspecting a running JVM’s effective configuration
kubectl exec -it <pod> -n <ns> -- sh
# or bash if available:
kubectl exec -it <pod> -n <ns> -- bash
# Inside the pod:
java -version
jps -l # list JVM processes and main class (PID is usually 1 in containers)
jcmd 1 VM.version
jcmd 1 VM.flags # effective JVM flags, incl. container-derived heap sizing
jcmd 1 VM.system_properties | grep -i spring
jcmd 1 VM.flags is the single most useful command here, it shows the actual, effective flags the JVM is running with, including any values the JVM computed automatically from the container’s cgroup limits (e.g., a computed -XX:MaxHeapSize you never explicitly set). Comparing this output against the container’s declared resources.limits.memory tells you immediately whether the JVM’s own view of its heap ceiling makes sense relative to the container’s total budget, before you even look at actual memory usage.
# Check cgroup memory limit as seen INSIDE the container: confirm the JVM is seeing what you expect
kubectl exec -it <pod> -n <ns> -- cat /sys/fs/cgroup/memory.max # cgroup v2
kubectl exec -it <pod> -n <ns> -- cat /sys/fs/cgroup/memory/memory.limit_in_bytes # cgroup v1
Basic jstat usage
jstat gives a lightweight, continuously-sampled view of GC/heap behavior without the overhead of a full profiling session, useful as a first look before deciding whether deeper GC tuning (Advanced level) is warranted:
kubectl exec -it <pod> -n <ns> -- jstat -gcutil 1 1000 10 # every 1s, 10 samples
This samples GC utilization stats for PID 1 every 1000ms, 10 times, showing heap generation occupancy percentages and GC counts/time. A heap that’s consistently near 100% occupancy even right after a garbage collection cycle (rather than dropping back down) is an early, cheap signal of either a genuine leak or simply an undersized heap for the actual working set, worth knowing before reaching for a full heap dump.
Setting a memory limit and observing the effect end-to-end
The full loop that ties this lesson together: set a container memory limit, observe how the JVM sizes its heap in response, then deliberately misconfigure -Xmxabove that limit and watch it OOM-kill, proving that an explicit, wrong -Xmx overrides the JVM’s own container-awareness entirely (container-awareness only applies to computing a default; an explicit flag always wins).
Lab
Run the full memory-sizing experiment on a local kind cluster.
- Deploy a Spring Boot pod with a 512Mi memory limit and no explicit
-Xmx:# jvm-default.yaml apiVersion: v1 kind: Pod metadata: name: jvm-default spec: containers: - name: app image: <your-spring-boot-image> resources: limits: memory: "512Mi" requests: memory: "512Mi"kubectl apply -f jvm-default.yaml kubectl wait --for=condition=Ready pod/jvm-default --timeout=60s - Observe the JVM’s computed default heap sizing relative to the container limit:
kubectl exec -it jvm-default -- jcmd 1 VM.flags | grep -iE "MaxHeapSize|MaxRAMPercentage" kubectl exec -it jvm-default -- cat /sys/fs/cgroup/memory.maxConfirm the computed
MaxHeapSizeis roughly 25% of 512Mi (Java expresses it in bytes, expect a value in the neighborhood of 128Mi, modulo rounding and JVM version differences). - Sample live GC stats:
kubectl exec -it jvm-default -- jstat -gcutil 1 1000 5 - Now deliberately misconfigure
-Xmxabove the container limit and triggerOOMKilled:# jvm-misconfigured.yaml apiVersion: v1 kind: Pod metadata: name: jvm-misconfigured spec: containers: - name: app image: <your-spring-boot-image> env: - name: JAVA_TOOL_OPTIONS value: "-Xmx1024m" resources: limits: memory: "512Mi" requests: memory: "512Mi"kubectl apply -f jvm-misconfigured.yaml kubectl get pod jvm-misconfigured -wConfirm the pod eventually shows
OOMKilledinLast Stateonce the JVM’s heap usage grows past what the 512Mi container can actually hold, even though the JVM itself believes it has up to 1024Mi of heap available. - Confirm the root cause explicitly:
kubectl describe pod jvm-misconfigured | grep -A5 "Last State" - Clean up:
kubectl delete pod jvm-default jvm-misconfigured
Checkpoint
- I can explain what changed between pre-JDK-10 JVMs and modern container-aware JVMs regarding memory sizing.
- I can explain why
-Xmxstill matters even on a fully container-aware JVM. - I can name at least three categories of off-heap memory that count against the container limit but not against
-Xmx. - I can read
jcmd 1 VM.flagsoutput and identify the effective computed heap size. - I reproduced an OOM kill in the lab caused specifically by an explicit
-Xmxset above the container’s memory limit.