Skip to content
JavaAgentic

Type at least two characters. Try “RAG”, “pgvector” or “tool calling”.

JVM Flags and Container Awareness in Kubernetes

How the JVM reads cgroup limits, why MaxRAMPercentage beats Xmx in a container, how CPU quota affects GC and pool sizing, and why CPU limits cause latency spikes through throttling.

Advanced6 min readUpdated
On this page

Running a JVM in a container is the default now, and the two runtimes have overlapping ideas about resources. Getting the interaction wrong produces two very recognisable failures: pods killed with no stack trace, and latency spikes with no CPU usage to explain them.

Key Takeaways

  • UseContainerSupport (default since Java 10) makes the JVM read cgroup memory and CPU limits.
  • Use MaxRAMPercentage, not -Xmx — it adapts when the pod spec changes.
  • Heap is not the whole process. Budget 25–50% above it for Metaspace, stacks, code cache and direct memory.
  • CPU limits cause throttling, which stalls every thread including GC and shows up as latency spikes with low reported CPU.
  • availableProcessors() drives GC threads, the common ForkJoinPool and JIT threads — a low CPU limit changes all three.

Memory

the configuration that gets OOMKilled
resources:
  limits:
    memory: "2Gi"
env:
  - name: JAVA_OPTS
    value: "-Xmx2g"          # the heap alone is the entire container budget

Nothing is left for Metaspace, thread stacks, the code cache, GC structures or direct buffers. The process exceeds 2GiB, the kernel sends SIGKILL, and the pod restarts with exit code 137 — no Java error, no heap dump, nothing in the application log.

the configuration that works
resources:
  requests:
    memory: "2Gi"
  limits:
    memory: "2Gi"            # requests == limits: Guaranteed QoS, evicted last
env:
  - name: JAVA_OPTS
    value: >-
      -XX:MaxRAMPercentage=70.0
      -XX:InitialRAMPercentage=70.0
      -XX:MaxMetaspaceSize=256m
      -XX:MaxDirectMemorySize=256m
      -XX:+HeapDumpOnOutOfMemoryError
      -XX:HeapDumpPath=/dumps/
      -XX:NativeMemoryTracking=summary

MaxRAMPercentage is a percentage of the container limit, read from the cgroup. Change the pod's memory and the heap follows automatically. Setting InitialRAMPercentage to the same value avoids heap resizing during warm-up, which sometimes triggers full collections at exactly the wrong time.

Bounding Metaspace and direct memory converts two silent native-memory growth paths into diagnosable OutOfMemoryErrors.

the budget for a 2GiB container
Heap (70%)                      1434 MB
Metaspace                        200 MB
Code cache                       100 MB
100 threads × 1MB (committed)     60 MB
GC structures (~5% of heap)       70 MB
Direct buffers                   100 MB
JVM native, malloc arenas        100 MB
                                --------
                                ~2064 MB  — already at the edge

That table is why 70% is a starting point rather than a rule. A service with many threads or heavy NIO needs less; a compute service with few threads can go to 80%.

CPU

what the JVM derives from the CPU limit
availableProcessors()
    -> GC thread count           (ParallelGCThreads, ConcGCThreads)
    -> ForkJoinPool.commonPool() parallelism  (parallel streams, CompletableFuture)
    -> JIT compiler threads      (CICompilerCount)
    -> your own pool sizing, if it reads availableProcessors()

Kubernetes CPU limits are expressed in millicores and enforced as a cgroup quota. The JVM computes availableProcessors() as roughly ceil(quota / period), so:

CPU limitavailableProcessors()Consequence
500m1Serial GC by default, one compiler thread
1000m1Same
2000m2Parallel collectors become viable
4000m4Reasonable for G1

The jump from 1 to 2 matters more than the raw number: at one processor the JVM chooses SerialGC and a minimal compiler configuration, which is correct for that budget but very different from what you get in a test environment on a laptop with eight cores.

Throttling

This is the failure that wastes the most debugging time.

Throttled time is not counted as CPU usage, which is why the dashboard shows a quiet service with terrible p99 latency.
the metric that proves it
rate(container_cpu_cfs_throttled_seconds_total[5m]) > 0

Any sustained non-zero value means the container is being frozen. Three responses, in order of preference:

Raise the limit, or remove it entirely and rely on requests for scheduling. Many teams run production with CPU requests set and limits unset, precisely to avoid throttling; the trade-off is weaker isolation between noisy neighbours.

Reduce concurrency inside the container so it cannot burn the quota in a burst — a smaller thread pool means fewer threads racing through the budget.

Give the JVM fewer threads. -XX:ActiveProcessorCount=N overrides the detected value, which is useful when the automatic detection produces a number that does not match how you want the pools sized.

Startup

Containers make JVM startup cost visible, because pods restart far more often than servers did.

startup-oriented flags
-XX:TieredStopAtLevel=1          # C1 only: faster warm-up, ~30% lower peak throughput
-Xshare:auto                     # class data sharing, on by default
-XX:+UseSerialGC                 # for a small, single-CPU sidecar
 
# Application class data sharing — a real win for large Spring applications
java -XX:ArchiveClassesAtExit=app.jsa -jar app.jar
java -XX:SharedArchiveFile=app.jsa -jar app.jar

AppCDS memory-maps a pre-parsed class archive and typically cuts Spring Boot startup by 20–30%. For a service that scales up and down frequently, that is more valuable than peak throughput.

TieredStopAtLevel=1 is the right trade only for short-lived processes — a batch job or a scale-to-zero function. For a long-running service it leaves 30% of throughput on the table.

Probes

probes that do not kill a healthy pod
startupProbe:                    # gives slow JVM startup room without weakening the others
  httpGet: { path: /actuator/health/readiness, port: 8080 }
  failureThreshold: 30
  periodSeconds: 5
 
livenessProbe:                   # restart only when genuinely stuck
  httpGet: { path: /actuator/health/liveness, port: 8080 }
  periodSeconds: 10
  failureThreshold: 3
 
readinessProbe:                  # remove from load balancing when temporarily busy
  httpGet: { path: /actuator/health/readiness, port: 8080 }
  periodSeconds: 5

Two mistakes worth naming. Without a startupProbe, a liveness probe with a short threshold kills the pod during the 40 seconds a large Spring context takes to start — producing a crash loop that looks like an application bug. And a liveness probe that checks the database restarts every pod in the fleet when the database has a hiccup, turning a partial outage into a total one. Liveness should ask "is this process stuck?", readiness should ask "can it serve right now?".

Graceful shutdown

finish in-flight requests before dying
terminationGracePeriodSeconds: 45
lifecycle:
  preStop:
    exec: { command: ["sh", "-c", "sleep 5"] }   # let endpoints propagate first
application.properties
server.shutdown=graceful
spring.lifecycle.timeout-per-shutdown-phase=30s

Kubernetes sends SIGTERM and removes the pod from endpoints concurrently, so without the preStop sleep some in-flight traffic still arrives after shutdown has begun. Five seconds of delay before the JVM starts shutting down covers the propagation window.

What gets asked

The scenario question is the common one: "your Java pod keeps getting OOMKilled — what do you do?" Answer with the accounting: the container limit must cover heap plus Metaspace plus stacks plus code cache plus direct memory, so use MaxRAMPercentage around 70 and bound the native regions.

The follow-up worth being ready for is throttling, because it is less well known: latency spikes with low reported CPU usage almost always mean the cgroup quota is being exhausted in bursts, and container_cpu_cfs_throttled_seconds_total is the metric that proves it.

Frequently Asked Questions

Should I set Xmx or MaxRAMPercentage in a container?
MaxRAMPercentage, because it adapts when the container limit changes. Setting Xmx hard-codes a number that becomes wrong the moment someone edits the pod spec, and the two then disagree silently. A value of 70 to 75 percent leaves room for Metaspace, thread stacks, the code cache, direct buffers and GC structures, which together typically need 25 to 50 percent above the heap.
Does the JVM see the container CPU limit?
Yes, since Java 10 with UseContainerSupport, which is on by default. availableProcessors returns a value derived from the cgroup CPU quota, and that value sizes the GC thread count, the common ForkJoinPool and the JIT compiler threads. A limit below 1000 millicores rounds up to one processor, which switches the JVM to SerialGC by default and disables tiered compilation on some versions.
Why does my pod have latency spikes even though CPU usage looks low?
Almost certainly cgroup CPU throttling. A limit is enforced as a quota per 100ms period, so a container that uses its whole quota in 30ms is frozen for the remaining 70ms — every thread, including GC. Average CPU utilisation looks modest because the throttled time is not counted as usage. Check container_cpu_cfs_throttled_seconds_total; any sustained non-zero value is the answer.

Related tutorials