Java JVM CPU and Memory Requests and Limits in Kubernetes

August 2026


A 4GiB heap requires a container larger than 4GiB.

The Java heap shares the container memory limit with metaspace, code cache, thread stacks, direct buffers, garbage-collector structures, and native libraries.

If the heap uses all available memory, the kernel might kill the container, even if Java still shows the heap as healthy.

CPU settings can also be mismatched in a similar way.

Kubernetes controls scheduling and CPU time, while HotSpot converts the available CPU into a processor count that changes thread pools and garbage-collector defaults.

This article explains how Kubernetes requests and limits affect JVM settings and shows how to test them under load.

Table of contents

Why JVM services are harder to size than most

Before we begin, the examples use a simple Java app without external dependencies, packaged as a Docker image.

We'll use these endpoints to compare Kubernetes settings with the CPU and memory that the JVM sees.

When a Java app starts, the JVM looks at the available memory and CPU in the host or container and uses those to set its runtime defaults.

Consider a container with these limits:

resources.yaml

resources:
  limits:
    cpu: 500m
    memory: 512Mi

The JVM reads the available memory and CPU from that environment and uses those values to choose defaults such as:

Kubernetes resource settings change how the JVM works.

Memory is not just heap

When you set a Kubernetes memory limit, it applies to the whole container.

The limit must cover:

The Java heap stores objects created by your code.

That includes a String, a HashMap, a request DTO, or a byte[].

The demo app makes this visible in the /gc endpoint, which deliberately creates short-lived objects on the heap:

Main.java

static void allocateGarbage(int megabytes) {
    List<byte[]> sink = new ArrayList<>();
    for (int i = 0; i < megabytes; i++) {
        sink.add(new byte[1024 * 1024]);
    }
}

Each loop iteration allocates a 1MiB byte array and stores its reference in sink.

The garbage collector cannot reclaim the arrays until the method returns and the list becomes unreachable.

The heap size sets the maximum amount of space available for Java objects.

The JVM may run garbage collection well before the heap reaches that maximum.

Two JVM startup options control the heap sizes:

When sizing containers, -Xmx is usually the first JVM setting to check.

It limits the heap, which is often the biggest part of a JVM service's memory usage.

Hitting the -Xmx limit doesn't make the JVM fail right away.

When there is not enough heap space for a new object, the JVM first runs garbage collection.

If it can reclaim enough space, the application continues.

If it cannot, the JVM throws OutOfMemoryError.

How is this different from hitting the Kubernetes memory limit?

-Xmx limits only the heap.

Kubernetes limits.memory applies to the container's total memory usage, including the JVM and any other processes in the container.

These two types of failures happen at different boundaries:

  • The Java heap has its own boundary. `-Xmx` or `MaxRAMPercentage` sets the largest heap HotSpot will try to use.The Java heap has its own boundary. -Xmx or MaxRAMPercentage sets the largest heap HotSpot will try to use.
    1/5

    The Java heap has its own boundary. -Xmx or MaxRAMPercentage sets the largest heap HotSpot will try to use.

  • When the heap cannot allocate a new object, the JVM asks the garbage collector to reclaim space.When the heap cannot allocate a new object, the JVM asks the garbage collector to reclaim space.
    2/5

    When the heap cannot allocate a new object, the JVM asks the garbage collector to reclaim space.

  • If GC cannot free enough heap, HotSpot throws `OutOfMemoryError`. The container may keep running.If GC cannot free enough heap, HotSpot throws OutOfMemoryError. The container may keep running.
    3/5

    If GC cannot free enough heap, HotSpot throws OutOfMemoryError. The container may keep running.

  • The container has a second boundary. If total charged memory reaches `limits.memory`, the kernel may kill the JVM process.The container has a second boundary. If total charged memory reaches limits.memory, the kernel may kill the JVM process.
    4/5

    The container has a second boundary. If total charged memory reaches limits.memory, the kernel may kill the JVM process.

  • If that kill stops the container, Kubernetes reports `OOMKilled`. `ExitOnOutOfMemoryError` is a separate path where HotSpot exits after a heap OOM.If that kill stops the container, Kubernetes reports OOMKilled. ExitOnOutOfMemoryError is a separate path where HotSpot exits after a heap OOM.
    5/5

    If that kill stops the container, Kubernetes reports OOMKilled. ExitOnOutOfMemoryError is a separate path where HotSpot exits after a heap OOM.

Container memory and RSS are related, but they're not the same thing.

RSS is the physical memory used by one process.

On cgroup v2, memory.current covers the whole cgroup, including its child cgroups.

memory.stat breaks that total into areas such as anonymous memory, file cache, kernel memory, and socket buffers.

Those figures cover the major sources of memory use, although kernel accounting is not perfect.

Some memory is easy to overlook because it doesn't store Java objects.

Thread stacks are one example.

A Java thread is one path of execution inside the same JVM process.

For example, a web server may use one thread to handle one request while another thread handles a different request.

Each thread also needs its own stack memory.

Stack memory is where the JVM stores method call frames and local variables for a thread while it is running.

The memory used by a container running a JVM app roughly comes from these areas:

A JVM container memory limit where the Java heap is capped by -Xmx or MaxRAMPercentage, while thread stacks, direct buffers, metaspace, code cache, GC structures, native memory, and other container memory share the remaining headroom.

What if the service uses direct buffers?

A direct buffer holds data for file and network I/O outside the Java heap.

Java applications can create them using NIO, and libraries such as Netty also use them.

Direct-buffer memory matters when the service uses NIO, Netty, gRPC, or Kafka clients, or handles large amounts of file or network traffic.

Heap metrics do not include this memory, but it still counts toward the container memory limit.

BufferPoolMXBean reports JDK-managed direct buffers.

For Netty, also check allocator metrics similar to usedDirectMemory().

To limit JDK NIO direct buffer memory, set -XX:MaxDirectMemorySize.

The container limit has to cover both the heap and everything outside it.

To see that in the demo app, let's compare two numbers from the /info endpoint:

nonHeapUsedMi does not include all the memory used outside the heap.

Thread stacks, direct buffers, and native libraries are not included in that number, and you get the full amount only by measuring the process or the container.

What if -Xmx is set to the same value as the container limit?

The first example sets both values to 256Mi:

bash

docker run --rm -d --name jvm-xmx-info -p 8080:8080 \
  --memory=256m \
  -e JAVA_TOOL_OPTIONS="-Xmx256m" \
  ghcr.io/learnk8s/jvm-requests-limits:latest

curl -s localhost:8080/info | jq '{heapMaxMi, nonHeapUsedMi}'

{
  "heapMaxMi": 247,
  "nonHeapUsedMi": 3
}

docker stop jvm-xmx-info >/dev/null

The JVM reports a maximum heap of about 247Mi, which is nearly the whole 256Mi container limit.

nonHeapUsedMi shows 3Mi of JVM-managed non-heap memory while the app is idle, but this value does not include all memory outside the heap.

If the heap gets close to its maximum, there's very little space left for thread stacks, direct buffers, native libraries, and the rest of the JVM.

The /oom endpoint fills the heap with 1MiB byte arrays:

Main.java

List<byte[]> sink = new ArrayList<>();
while (true) {
    sink.add(new byte[1024 * 1024]);
}

The list keeps every array in use.

The garbage collector cannot reclaim them, and the allocation continues until the JVM reaches its heap limit or the process reaches the container memory limit.

Swap needs to be disabled for this test, since both the heap and all memory outside the heap must fit within the same 256MiB limit.

If you set only --memory=256m, Docker can also allow up to 256MiB of swap when swap is available on the host.

The kernel can move heap pages to swap, and this might allow the JVM to reach its heap limit before the whole process reaches the container limit.

Setting --memory-swap=256m together with --memory=256m disables swap for the container and keeps the total available memory at 256MiB.

Run the container in the background and keep its state available for inspection after the process exits:

bash

docker run -d --name jvm-oom -p 8080:8080 \
  --memory=256m \
  --memory-swap=256m \
  -e JAVA_TOOL_OPTIONS="-Xmx256m" \
  ghcr.io/learnk8s/jvm-requests-limits:latest

curl -s localhost:8080/oom | jq .

{
  "status": "allocating",
  "note": "Allocation continues until the JVM throws OutOfMemoryError or the container is OOM-killed."
}

After the allocation starts, inspect the container state:

bash

docker inspect -f 'OOMKilled={{.State.OOMKilled}} ExitCode={{.State.ExitCode}}' jvm-oom

OOMKilled=true ExitCode=137

In this Docker test, OOMKilled=true indicates that the container ran out of its allocated memory and the kernel killed the JVM process.

The Linux kernel enforces container memory limits through cgroups.

When the container reaches its limit, the kernel first tries to free memory.

If it cannot free up enough, it may kill a process in the container with SIGKILL.

The application logs show what the JVM managed to write before the kernel stopped it:

bash

docker logs jvm-oom

Picked up JAVA_TOOL_OPTIONS: -Xmx256m
Listening on port 8080

In this test, the heap and the memory outside it together reached the 256Mi container limit, while the heap stayed below its 247Mi maximum.

The kernel killed the JVM before it could report OutOfMemoryError.

The next example limits the heap to 75% of the container memory.

This leaves the remaining 25% for thread stacks, metaspace, code cache, direct buffers, and other memory outside the heap:

bash

docker run --rm -p 8080:8080 \
  --memory=256m \
  -e JAVA_TOOL_OPTIONS="-XX:MaxRAMPercentage=75.0" \
  ghcr.io/learnk8s/jvm-requests-limits:latest

curl -s localhost:8080/info | jq '{heapMaxMi, nonHeapUsedMi}'

{
  "heapMaxMi": 185,
  "nonHeapUsedMi": 4
}

The JVM now reports a maximum heap size of about 185Mi.

A 256Mi container limit leaves about 71Mi for memory outside the Java heap.

This does not prove that 71Mi is enough for every service.

It just shows the main rule: the heap has to fit inside the container limit, with enough memory left for everything else.

A common starting point is to set the maximum heap size to about 50% to 75% of the container memory limit, then test it with your actual workload.

The previous example used 75%.

Here is the same container with a 50% heap target:

bash

docker run --rm -p 8080:8080 \
  --memory=256m \
  -e JAVA_TOOL_OPTIONS="-XX:MaxRAMPercentage=50.0" \
  ghcr.io/learnk8s/jvm-requests-limits:latest

bash

curl -s localhost:8080/info | jq '{heapMaxMi, nonHeapUsedMi}'
{
  "heapMaxMi": 123,
  "nonHeapUsedMi": 3
}

With the 50% setting, the JVM reports a maximum heap size of around 123Mi.

In a 256Mi container, that leaves about 133Mi for everything outside the Java heap.

Two 256Mi JVM containers comparing the HotSpot-reported max heap for 75% and 50% heap targets, with dashed target lines and labels for 185Mi + 71Mi and 123Mi + 133Mi.

Run the /oom endpoint again with the same 256Mi container limit and a lower heap target of 50%:

bash

docker run --rm -d --name jvm-oom-safe -p 8080:8080 \
  --memory=256m \
  --memory-swap=256m \
  -e JAVA_TOOL_OPTIONS="-XX:MaxRAMPercentage=50.0" \
  ghcr.io/learnk8s/jvm-requests-limits:latest

curl -s localhost:8080/oom | jq .

{
  "status": "allocating",
  "note": "Allocation continues until the JVM throws OutOfMemoryError or the container is OOM-killed."
}

With the smaller heap, the container survives and remains available after the allocation fails:

bash

docker logs jvm-oom-safe

Picked up JAVA_TOOL_OPTIONS: -XX:MaxRAMPercentage=50.0
Listening on port 8080
OutOfMemoryError: Java heap space

curl -s localhost:8080/health

ok

Both tests use the same 256Mi container limit and allocate the same byte arrays.

The second test limits the heap to 123Mi.

When the heap reaches that limit, and garbage collection cannot free enough space, the JVM throws OutOfMemoryError.

A lower heap limit leaves enough memory for the rest of the JVM.

The process stays below the container limit, and the kernel does not kill it.

The /oom handler catches the error, so the container stays available to respond to health checks.

This is not a recovery pattern for production.

The demo catches the error only to show the difference between the two kinds of OOM.

In a real service, a failed allocation might leave the app in an unreliable state, even if its health endpoint still returns ok.

For a restartable service, use -XX:+ExitOnOutOfMemoryError.

It makes the JVM process exit when a heap OOM occurs.

Kubernetes then sees that the container stopped and can restart it according to the pod's restart policy.

For a service that should restart on heap OOM, include the exit flag with the heap setting:

deployment.yaml

env:
  - name: JAVA_TOOL_OPTIONS
    value: '-XX:MaxRAMPercentage=50.0 -XX:+ExitOnOutOfMemoryError'

If you also need HotSpot fatal-error diagnostics, use -XX:+CrashOnOutOfMemoryError instead.

It crashes the JVM and lets HotSpot write diagnostic files.

Will either flag make the failure appear as OOMKilled?

No, because OOMKilled means the container stopped after the kernel OOM killer killed its main process.

A JVM that exits because of either flag is a different failure, as is a pod evicted under node memory pressure.

Omitting both flags is safe only when the application has a tested recovery path for the failed allocation.

The examples above start with a container limit and use a percentage to set the maximum heap.

If you know the maximum heap your application needs, divide that size by the heap percentage to calculate the container memory limit.

The calculation uses the percentage as a decimal.

For example, divide by 0.75 when the heap is 75% of container memory:

A heap size is not the same as a Kubernetes memory limit.

If you see "4Gi heap" in a GC note, benchmark, or tuning article, it means the JVM can use up to 4Gi for Java objects.

It doesn't mean the Kubernetes container should be set to 4Gi.

If you want the heap to use 75% of the container, the calculation is 4Gi / 0.75 = ~5.33Gi.

The remaining 25% allocates memory to the rest of the JVM, but you still need to test whether it is enough for the real workload.

Measure live heap, not just container memory

The live heap after GC is the measurement for validating the selected heap target.

This value is easy to mix up with process RSS, total container memory, committed heap, or the maximum heap set by -Xmx.

Container memory shows how close the container is to the memory limit configured through Kubernetes and enforced by the kernel, making it the metric to watch for container OOM risk.

RSS (resident set size) is the part of the JVM process that is currently loaded into physical RAM.

The operating system manages process memory in pages, and RSS counts every page from the JVM process that is currently in RAM, including the heap, thread stacks, direct buffers, and native libraries.

Because RSS includes memory outside the heap, it can be much larger than the live heap.

Why are these values not enough to size the heap?

Container memory and RSS both include memory from several areas.

Neither one tells you how much of that memory belongs to live objects, to committed but unused heap, or to areas outside the heap.

For a JVM service, always look at container memory and RSS together with heap metrics.

Without heap metrics, high RSS by itself doesn't tell you if you should change the heap size, the container limit, or memory use outside the heap.

These heap measurements describe different things:

  • The JVM heap view starts inside the process. Post-GC live heap is the reachable part of the used heap, used heap is inside the committed heap, and committed heap stays within the max heap.The JVM heap view starts inside the process. Post-GC live heap is the reachable part of the used heap, used heap is inside the committed heap, and committed heap stays within the max heap.
    1/3

    The JVM heap view starts inside the process. Post-GC live heap is the reachable part of the used heap, used heap is inside the committed heap, and committed heap stays within the max heap.

  • The process view is different. RSS counts resident physical pages from the heap, thread stacks, direct buffers, native libraries, and other process memory.The process view is different. RSS counts resident physical pages from the heap, thread stacks, direct buffers, native libraries, and other process memory.
    2/3

    The process view is different. RSS counts resident physical pages from the heap, thread stacks, direct buffers, native libraries, and other process memory.

  • The container view is different again. The cgroup memory total can include process memory, file cache, socket buffers, and kernel-accounted memory.The container view is different again. The cgroup memory total can include process memory, file cache, socket buffers, and kernel-accounted memory.
    3/3

    The container view is different again. The cgroup memory total can include process memory, file cache, socket buffers, and kernel-accounted memory.

Which post-GC value should you use?

G1 divides the heap into a young generation and an old generation, which are heap areas that group objects by how long they have survived.

Most objects start in the young generation and can move to the old generation if they survive garbage collection.

Because a G1 young-only collection focuses on the younger generation, it can leave unreachable objects in the old generation.

Those objects still count toward the used heap, and the used heap can be higher than the live heap.

For sizing, use the heap usage measured after G1 has reclaimed memory from the old generation.

In a test environment, run realistic traffic and request a full garbage collection (full GC).

A G1 full GC compacts the heap, and the GC logs tell you when it has completed.

Heap usage immediately after that point is the relevant value.

If you cannot trigger a full GC, use the GC logs instead.

Once the logs show that G1 has reclaimed memory from the old generation, record the heap use.

The JVM tracks its memory in separate pools, and for a supported pool, MemoryPoolMXBean.getCollectionUsage() reports how much memory that pool used after its latest garbage collection.

That result covers only one pool, not the live heap for the entire JVM.

After GC, the live heap might drop, but the committed heap and RSS can stay high. That's because G1 may keep a committed heap ready for future allocations, and RSS includes memory outside the heap too.

Can a committed heap be released?

Yes, G1 can uncommit unused heap regions and return the memory to the operating system after a full or concurrent GC cycle.

However, an idle application may not trigger either cycle, and the committed heap and RSS can remain high.

-Xms is the minimum heap size the collector can use, and G1 cannot shrink the heap below this value.

If -Xms and -Xmx are the same, the heap has a fixed size and cannot shrink.

To let G1 return unused heap memory, set -Xms lower than -Xmx.

JEP 346 added periodic GC to G1 in JDK 12, but the default value of G1PeriodicGCInterval is 0, which disables periodic GC.

The following settings ask G1 to check every 30 seconds and use a concurrent periodic GC cycle:

-XX:G1PeriodicGCInterval=30000
-XX:+G1PeriodicGCInvokesConcurrent

G1 can give back unused committed heap, but doing so adds GC overhead and might increase latency when the JVM needs that memory again.

The separate -XX:-ShrinkHeapInSteps option asks HotSpot to shrink the heap immediately instead of over several GC cycles, which can reduce performance.

This setting requires testing against the real allocation pattern and latency targets.

Live heap shows what's left in the Java heap after GC, but it doesn't tell you about the container's total memory usage.

For that, you have to look at the JVM and the container together.

Collect JVM metrics as well as container metrics

Container metrics show total memory usage, OOM kills, and CPU throttling.

JVM metrics show what happened inside the process during the same period.

A container can use more memory because the live Java object graph has grown, because HotSpot has committed heap for reuse, or because thread stacks, direct buffers, native libraries, and other off-heap areas have grown.

A larger heap may help when the live set has grown, but it will not fix growth outside the heap.

A highly committed heap alone may not require a larger container if the live heap and total container memory remain within their targets.

During a load test or a small production rollout, collect JVM and container metrics together.

This lets you compare the container's memory and CPU usage with what the JVM was doing under the same workload.

At minimum, record the maximum heap, committed heap, heap used after GC, non-heap memory, GC pause time, GC collection count, thread count, and available processor count alongside container memory, OOM kills, and CPU throttling.

For services that heavily use direct buffers, record direct-buffer memory too.

For Prometheus, two common choices are Spring Boot Actuator with Micrometer's Prometheus registry and the Prometheus JMX Exporter:

Whichever path you use, keep the JVM and container time series together, as the comparison only works when the values are from the same period.

Container metrics show if a limit is being reached, while JVM metrics help explain why.

The demo's /gc endpoint calls System.gc() to request a collection, which makes the change easy to observe on this HotSpot configuration.

In production, collect metrics under real traffic rather than adding an endpoint that requests GC solely for sizing.

The percentage calculation leaves room outside the heap, and it never says what fills that room.

Thread stacks are a good place to start, since every platform thread needs its own stack.

Thread stacks are not in the heap either

When a thread calls a method, the JVM stores that call and its local variables on the thread's stack.

Every platform thread has a stack outside the heap, and that memory counts toward the container limit, even though -Xmx doesn't control it.

The -Xss option sets the stack size for each separate thread.

The Eclipse Temurin 21 Linux/x64 image used here reports 1024KiB by default, but the value reported by your runtime is authoritative.

With -Xss1m and 200 platform threads, the configured stack capacity is 200MiB.

Do 200 threads use the full 200MiB as soon as they start?

No, the 200MiB is the maximum combined size of their stacks, not their immediate physical memory usage.

A sleeping thread uses only a small part of its stack, and it only uses more memory as it makes more method calls or stores more local data.

That means the physical memory used by 200 sleeping threads can be much less than 200MiB.

The /threads endpoint shows this difference by measuring physical memory and heap usage before and after creating 200 sleeping threads:

bash

docker run --rm -d --name jvm-threads -p 8080:8080 \
  --memory=512m \
  -e JAVA_TOOL_OPTIONS="-Xmx256m -Xss1m" \
  ghcr.io/learnk8s/jvm-requests-limits:latest >/dev/null

curl --retry 10 --retry-connrefused --retry-delay 1 \
  -sf localhost:8080/health >/dev/null

curl -s "localhost:8080/threads?count=200" | jq .

{
  "threadsCreated": 200,
  "configuredStackKiPerThread": 1024,
  "configuredStackCapacityKi": 204800,
  "rssBeforeKi": 47584,
  "rssAfterKi": 66684,
  "rssDeltaKi": 19100,
  "heapUsedDeltaKi": 162,
  "estimatedNativeRssDeltaKi": 18937,
  "note": "Configured stack capacity is not the same as physical memory use.
  Sleeping threads use only part of their stacks.
  The estimate also includes other thread memory and may vary between runs."
}

docker stop jvm-threads >/dev/null

We configured 200 threads with a 1MiB stack each, which means their stacks could use up to 200MiB if every thread filled its available space.

During this test, however, the threads did nothing but sleep and touched only a small part of their stacks.

RSS rose by 18.7MiB while the heap grew by just 162KiB, which means almost all of the increase came from thread stacks and other thread data outside the heap.

RSS can't precisely distinguish those values, but it shows why the container memory limit requires additional space beyond -Xmx.

Two hundred platform threads with 200MiB of configured stack capacity, but only about 18.7MiB of extra RSS while the threads sleep.

Maximum configured stack capacity is -Xss multiplied by the maximum platform-thread count.

Threads use memory beyond just their stacks, so the best way to know is to run your app under real load and measure the container's total memory usage.

This calculation is for platform threads.

Virtual threads do not keep a dedicated operating system stack when they are waiting; the JVM stores their suspended stacks on the heap and runs many virtual threads on a smaller number of platform threads, called carriers.

Ten thousand virtual threads do not mean you have ten thousand native stacks.

If you are using virtual threads, enter the maximum number of platform and carrier threads into the calculator.

The -Xss values you set for those threads estimate native stack capacity, while virtual threads add to heap usage instead.

Framework thread pools may create extra platform threads, and a pinned virtual thread can keep its carrier busy for longer.

To see both effects, run your actual concurrency pattern under load and measure the live heap, platform thread count, and total container memory.

Heap size and thread stacks are just two parts of JVM memory, and most real services use several flags together.

The next example shows how the main memory settings fit together.

JVM flags that affect memory

When you set a Kubernetes memory limit, it applies to the entire container.

The Java 21 options let you control how large the heap and thread stacks can grow, and what the JVM should do if it runs out of memory:

Run the JVM in a 512MiB container with three memory settings.

InitialRAMPercentage=12.5 gives the heap an initial target of 64MiB, MaxRAMPercentage=50.0 gives it a maximum target of 256MiB, and -Xss512k sets a 512KiB stack for each platform thread.

The /info endpoint shows how the JVM applied them:

bash

docker run --rm -d --name jvm-memory-flags -p 8080:8080 \
  --memory=512m \
  -e JAVA_TOOL_OPTIONS="-XX:InitialRAMPercentage=12.5 -XX:MaxRAMPercentage=50.0 -Xss512k" \
  ghcr.io/learnk8s/jvm-requests-limits:latest >/dev/null

curl --retry 10 --retry-connrefused --retry-delay 1 \
  -sf localhost:8080/health >/dev/null

curl -s localhost:8080/info | \
  jq '{heapInitialMi, heapCommittedMi, heapMaxMi, heapUsedMi, configuredStackKiPerThread, jvmArgs}'

{
  "heapInitialMi": 64,
  "heapCommittedMi": 61,
  "heapMaxMi": 247,
  "heapUsedMi": 7,
  "configuredStackKiPerThread": 512,
  "jvmArgs": [
    "-XX:InitialRAMPercentage=12.5",
    "-XX:MaxRAMPercentage=50.0",
    "-Xss512k"
  ]
}

docker stop jvm-memory-flags >/dev/null

We start with a target of 64MiB, since that's 12.5% of 512MiB, and HotSpot sets the committed heap size to 61MiB.

When we ran /info, Java objects used only 7MiB.

If the application needs more memory, the JVM can increase the heap up to 247MiB, which is the maximum HotSpot sets for the 50% target.

The 512KiB stack size is set as requested and applies to all threads.

None of these JVM settings changes the 512MiB container limit.

The heap, thread stacks, metaspace, code cache, direct buffers, and every other area have to fit within that limit.

A deployment can change the container limit and JVM flags separately, so always check both.

Check what the JVM received

The image in this article is deployed using app/deployment.yaml.

The key part of the manifest keeps the image, JVM flags, request, and limit together in the same container definition:

deployment.yaml

containers:
  - name: app
    image: ghcr.io/learnk8s/jvm-requests-limits:latest
    env:
      - name: JAVA_TOOL_OPTIONS
        value: '-XX:InitialRAMPercentage=25.0 -XX:MaxRAMPercentage=50.0 -Xss512k'
    resources:
      requests:
        cpu: 100m
        memory: 256Mi
      limits:
        cpu: 500m
        memory: 512Mi

What should you check first in this manifest?

The maximum heap comes from -Xmx or MaxRAMPercentage and must be compared with the container memory limit.

This configuration does not set -Xmx, which means the percentage controls the heap.

The app container has a 512MiB memory limit, which gives the heap an initial target of 128MiB at 25% and a maximum target of 256MiB at 50%.

The remaining 256MiB must cover thread stacks, metaspace, code cache, direct buffers, and the rest of the process.

The heap calculation uses the 512MiB memory limit, not the 256MiB request, because the request affects pod placement rather than this runtime heap calculation.

Deploy that manifest and wait until the pod is ready:

bash

kubectl apply -f learnkube.com/blog/jvm-requests-limits/app/deployment.yaml

deployment.apps/jvm-memory-check created

kubectl rollout status deployment/jvm-memory-check

Waiting for deployment "jvm-memory-check" rollout to finish: 0 of 1 updated replicas are available...
deployment "jvm-memory-check" successfully rolled out

After the deployment rolls out, call /info inside the pod and check the values used by the running JVM:

bash

kubectl exec deploy/jvm-memory-check -- \
  wget -qO- http://localhost:8080/info | \
  jq '{heapInitialMi, heapMaxMi, configuredStackKiPerThread, jvmArgs}'

{
  "heapInitialMi": 128,
  "heapMaxMi": 247,
  "configuredStackKiPerThread": 512,
  "jvmArgs": [
    "-XX:InitialRAMPercentage=25.0",
    "-XX:MaxRAMPercentage=50.0",
    "-Xss512k"
  ]
}

The configuration sets the initial heap to 25% of 512MiB, and /info reports the expected 128MiB.

It sets the maximum heap to 50%, and /info reports 247MiB instead of exactly 256MiB because HotSpot adjusts the value based on how it organizes the heap.

The pod also reports the requested -Xss512k, which gives each thread a 512KiB stack.

These values confirm that the pod got the settings from the manifest.

If they were different, we would first fix the deployment because the JVM can only use the resources and flags it receives at runtime.

Once the memory settings are correct, the next runtime choice to check is the garbage collector.

GC selection depends on visible memory and CPU

A garbage collector frees up heap space when objects can no longer be reached.

Java 21 HotSpot ships several garbage collectors, and four of them come up in this article:

If you do not pick a collector, HotSpot 21 picks one for you.

It chooses G1 when it sees at least two active processors and 1792 MB of memory (about 1.75 GiB), and Serial GC otherwise.

This larger setup is a server-class environment.

Does the 4GiB heap example affect this choice?

No, HotSpot 21 chooses between G1 and Serial GC based on the CPU and memory available to the JVM, not on the 4GiB example used earlier.

A 4GiB heap still matters when calculating the container memory limit, but it does not control this automatic collector choice.

Parallel GC and ZGC are also available, and this rule never selects them.

To use one, set -XX:+UseParallelGC or -XX:+UseZGC.

Changing just the resources available to a container can make the same application image start with a different garbage collector.

  • HotSpot first reads the processor count visible to the JVM. CPU quota, CPU affinity, cpusets, and `ActiveProcessorCount` can change that number.HotSpot first reads the processor count visible to the JVM. CPU quota, CPU affinity, cpusets, and ActiveProcessorCount can change that number.
    1/4

    HotSpot first reads the processor count visible to the JVM. CPU quota, CPU affinity, cpusets, and ActiveProcessorCount can change that number.

  • HotSpot also reads the memory visible to the JVM. In a container with a memory limit, that limit is the number to check at runtime.HotSpot also reads the memory visible to the JVM. In a container with a memory limit, that limit is the number to check at runtime.
    2/4

    HotSpot also reads the memory visible to the JVM. In a container with a memory limit, that limit is the number to check at runtime.

  • A server-class environment has at least two active processors and at least 1792MB of memory.A server-class environment has at least two active processors and at least 1792MB of memory.
    3/4

    A server-class environment has at least two active processors and at least 1792MB of memory.

  • With automatic GC selection, HotSpot 21 picks G1 for server-class environments and Serial GC otherwise. An explicit GC flag bypasses this choice.With automatic GC selection, HotSpot 21 picks G1 for server-class environments and Serial GC otherwise. An explicit GC flag bypasses this choice.
    4/4

    With automatic GC selection, HotSpot 21 picks G1 for server-class environments and Serial GC otherwise. An explicit GC flag bypasses this choice.

We can isolate the CPU part of that decision by keeping the image and the 2GiB memory limit the same.

The first container gets two CPUs:

bash

docker run --rm -d --name jvm-gc-two-cpu -p 8081:8080 \
  --memory=2g --cpus=2 \
  ghcr.io/learnk8s/jvm-requests-limits:latest >/dev/null

curl --retry 10 --retry-connrefused --retry-delay 1 \
  -sf localhost:8081/health >/dev/null

curl -s localhost:8081/info | jq '{availableProcessors, heapMaxMi, gc}'

{
  "availableProcessors": 2,
  "heapMaxMi": 512,
  "gc": [
    { "name": "G1 Young Generation", "collections": 0, "timeMs": 0 },
    { "name": "G1 Concurrent GC", "collections": 0, "timeMs": 0 },
    { "name": "G1 Old Generation", "collections": 0, "timeMs": 0 }
  ]
}

docker stop jvm-gc-two-cpu >/dev/null

Two CPUs and 2GiB of memory satisfy the server-class requirements, so HotSpot 21 selects G1.

G1 reports the expected 512MiB maximum heap.

The second container uses the same image and memory limit with one CPU:

bash

docker run --rm -d --name jvm-gc-one-cpu -p 8081:8080 \
  --memory=2g --cpus=1 \
  ghcr.io/learnk8s/jvm-requests-limits:latest >/dev/null

curl --retry 10 --retry-connrefused --retry-delay 1 \
  -sf localhost:8081/health >/dev/null

curl -s localhost:8081/info | jq '{availableProcessors, heapMaxMi, gc}'

{
  "availableProcessors": 1,
  "heapMaxMi": 494,
  "gc": [
    { "name": "Copy", "collections": 0, "timeMs": 0 },
    { "name": "MarkSweepCompact", "collections": 0, "timeMs": 0 }
  ]
}

docker stop jvm-gc-one-cpu >/dev/null

A container with one CPU does not meet the processor requirement, so HotSpot 21 selects Serial GC.

For Serial GC, the young generation and old generation are called Copy and MarkSweepCompact.

Serial GC reports a 494MiB maximum instead of G1's 512MiB because the collectors organize the heap differently.

CPU and memory explain why a particular collector was chosen for you.

They say nothing about whether that collector meets your service's response-time and throughput targets, and only a load test answers that.

If your load test shows that G1 is the right collector, set it explicitly.

A later change to the container resources cannot quietly move the service back to Serial GC.

deployment.yaml

env:
  - name: JAVA_TOOL_OPTIONS
    value: '-XX:+UseG1GC -XX:MaxRAMPercentage=75.0'

Explicitly choosing G1 doesn't give the container more CPU.

G1 still does all its work with the CPU the container has, and some of that work will pause the application.

How CPU throttling may extend GC pauses

The HotSpot collectors in this section pause application threads for part of their work, which is called a stop-the-world pause.

The collector still requires CPU time even when the application is paused.

When the container runs out of CPU, the collector waits, and the application threads stay paused until the collector finishes.

To measure this effect without mixing different collectors, both containers use Serial GC, a 256MiB heap, and two busy application threads.

The first container uses two CPUs:

bash

docker run --rm -d --name jvm-gc-two-cpu -p 8081:8080 \
  --memory=512m --cpus=2 \
  -e JAVA_TOOL_OPTIONS="-Xmx256m -XX:+UseSerialGC" \
  ghcr.io/learnk8s/jvm-requests-limits:latest >/dev/null

curl --retry 10 --retry-connrefused --retry-delay 1 \
  -sf localhost:8081/health >/dev/null

curl -s "localhost:8081/gc-under-stress?threads=2&seconds=10" | \
  jq '{availableProcessors, throttledRatio, throttledUsecDelta, totalGcPauseMs, gcWallMaxMs, gcWallAvgMs}'

{
  "availableProcessors": 2,
  "throttledRatio": "0.26",
  "throttledUsecDelta": 47443,
  "totalGcPauseMs": 82,
  "gcWallMaxMs": 4,
  "gcWallAvgMs": 2
}

docker stop jvm-gc-two-cpu >/dev/null

With two CPUs, the container waited for more CPU time during 26% of the measurement periods.

The total GC collection time was 82ms, and the average System.gc() call took 2ms.

The second container changes only the CPU quota, reducing it to half a CPU:

bash

docker run --rm -d --name jvm-gc-half-cpu -p 8081:8080 \
  --memory=512m --cpus=0.5 \
  -e JAVA_TOOL_OPTIONS="-Xmx256m -XX:+UseSerialGC" \
  ghcr.io/learnk8s/jvm-requests-limits:latest >/dev/null

curl --retry 10 --retry-connrefused --retry-delay 1 \
  -sf localhost:8081/health >/dev/null

curl -s "localhost:8081/gc-under-stress?threads=2&seconds=10" | \
  jq '{availableProcessors, throttledRatio, throttledUsecDelta, totalGcPauseMs, gcWallMaxMs, gcWallAvgMs}'

{
  "availableProcessors": 1,
  "throttledRatio": "1.00",
  "throttledUsecDelta": 15811522,
  "totalGcPauseMs": 331,
  "gcWallMaxMs": 83,
  "gcWallAvgMs": 18
}

docker stop jvm-gc-half-cpu >/dev/null

With half a CPU, the container waited for more CPU in every single measurement period.

The reported GC collection time went up from 82ms to 331ms, and the average System.gc() call rose from 2ms to 18ms.

The exact times vary between runs, but GC took longer with less CPU in this comparison.

CPU throttling makes GC pauses longer because the collector can't finish until it gets more CPU time.

Requests that arrive during the pause will also have to wait longer.

  • A CPU limit gives the container a quota for each quota period. Many setups use a 100ms period, but the runtime value is the one that matters.A CPU limit gives the container a quota for each quota period. Many setups use a 100ms period, but the runtime value is the one that matters.
    1/4

    A CPU limit gives the container a quota for each quota period. Many setups use a 100ms period, but the runtime value is the one that matters.

  • When a stop-the-world GC starts, application threads stop, and the collector needs CPU to finish the pause.When a stop-the-world GC starts, application threads stop, and the collector needs CPU to finish the pause.
    2/4

    When a stop-the-world GC starts, application threads stop, and the collector needs CPU to finish the pause.

  • If the container exhausts its quota during the pause, the cgroup is throttled, and the collector waits.If the container exhausts its quota during the pause, the cgroup is throttled, and the collector waits.
    3/4

    If the container exhausts its quota during the pause, the cgroup is throttled, and the collector waits.

  • The collector resumes in a later period. The GC work may be the same, but the wall-clock pause seen by requests is longer.The collector resumes in a later period. The GC work may be the same, but the wall-clock pause seen by requests is longer.
    4/4

    The collector resumes in a later period. The GC work may be the same, but the wall-clock pause seen by requests is longer.

CPU limits and JVM processor counts are different

Kubernetes and the JVM each describe CPU in their own way:

The two are related without being the same, because HotSpot 21 divides the container's CPU quota by its period and rounds up the result:

Fractional Kubernetes CPU limits contrasted with the whole-number processor counts HotSpot 21 can report, with 500m rounding to 1, 1500m rounding to 2, and 3400m rounding to 4 before CPU affinity or cpuset caps are applied.

CPU affinity and cpusets can lower the reported processor count, and ActiveProcessorCount can set it higher or lower.

The value reported inside the running container is authoritative.

Why does this rounded number matter?

The JVM uses it to pick a garbage collector, size the GC worker pools, and set defaults such as ForkJoinPool parallelism.

Many frameworks likewise size their own thread pools using availableProcessors().

What the JVM sees inside a container

Start the demo with a limit of half a CPU:

bash

docker run --rm -d --name jvm-cpu-half -p 8082:8080 \
  --cpus=0.5 \
  ghcr.io/learnk8s/jvm-requests-limits:latest >/dev/null

curl --retry 10 --retry-connrefused --retry-delay 1 \
  -sf localhost:8082/health >/dev/null

Once the container is ready, /info shows the cgroup quota and the processor count HotSpot derived from it:

bash

curl -s localhost:8082/info | \
  jq '{availableProcessors, forkJoinPoolParallelism, cgroupCpuQuotaUs, cgroupCpuPeriodUs, effectiveCpus}'

{
  "availableProcessors": 1,
  "forkJoinPoolParallelism": 1,
  "cgroupCpuQuotaUs": 50000,
  "cgroupCpuPeriodUs": 100000,
  "effectiveCpus": "0.50"
}

docker stop jvm-cpu-half >/dev/null

Linux gives this container 50,000 microseconds of CPU time in every 100,000-microsecond period.

That is half a CPU, reported as effectiveCpus: "0.50".

HotSpot rounds the fractional quota up and reports one available processor.

Now give the same image to four CPUs:

bash

docker run --rm -d --name jvm-cpu-four -p 8082:8080 \
  --cpus=4 \
  ghcr.io/learnk8s/jvm-requests-limits:latest >/dev/null

curl --retry 10 --retry-connrefused --retry-delay 1 \
  -sf localhost:8082/health >/dev/null

The same endpoint now reports four available processors:

bash

curl -s localhost:8082/info | \
  jq '{availableProcessors, forkJoinPoolParallelism, effectiveCpus}'

{
  "availableProcessors": 4,
  "forkJoinPoolParallelism": 3,
  "effectiveCpus": "4.00"
}

docker stop jvm-cpu-four >/dev/null

The first container used half a CPU and reported one processor.

The second used four CPUs and reported four.

ForkJoinPool parallelism went up from 1 to 3.

At 1000m, HotSpot usually reports one processor, and at 1500m it reports two.

That extra 500m crosses a JVM threshold, and when enough memory is visible too, HotSpot 21 may switch from Serial GC to G1.

The processor count can also make the JVM start more worker threads.

What else is competing for the same CPU time?

JVM threads share the container CPU limit

A JVM usually runs several kinds of threads:

All of these threads run in the same container, and Linux sums their CPU usage when it checks the limit.

If a GC thread and a JIT compiler thread run at the same time, the CPU time used by both counts against the same quota.

The previous experiment showed that GC takes longer when the container has to wait for the CPU.

The CPU limit controls how much CPU time is available, and the processor count decides how many worker threads the JVM starts.

The next example shows how changing the processor count changes the number of G1 worker threads.

Processor count changes JVM defaults

The number of application threads does not determine CPU requirements.

HotSpot does use the available processor count to size its internal thread pools, including the G1 worker pools.

Start Java with one active processor and enable GC startup logging:

bash

docker run --rm --entrypoint java \
  ghcr.io/learnk8s/jvm-requests-limits:latest \
  -XX:ActiveProcessorCount=1 -XX:+UseG1GC -Xlog:gc+init=info -version

On the test host, the relevant lines were:

CPUs: 20 total, 1 available
Parallel Workers: 1
Concurrent Workers: 1
Concurrent Refinement Workers: 1

Run the same command with four active processors:

bash

docker run --rm --entrypoint java \
  ghcr.io/learnk8s/jvm-requests-limits:latest \
  -XX:ActiveProcessorCount=4 -XX:+UseG1GC -Xlog:gc+init=info -version
CPUs: 20 total, 4 available
Parallel Workers: 4
Concurrent Workers: 1
Concurrent Refinement Workers: 4

The total CPU count comes from the host machine and can vary between systems.

The flag sets the available processor count shown in the output.

In this example, increasing the available processor count from 1 to 4 also increased the number of G1 parallel workers from 1 to 4.

The number of workers can also depend on the collector and heap size, which makes the startup logs the source of truth for your setup.

The processor count also sets the size of the common ForkJoinPool, which parallel streams and many CompletableFuture operations use.

In the earlier test, the pool had a parallelism of 1 with one visible processor and 3 with four.

Having more worker threads doesn't give you more CPU time.

They only run in parallel if the container has enough CPU to spare.

How to observe CPU throttling

The /stress endpoint keeps two threads busy and reads the Linux CPU counters before and after the test.

Run it in a container limited to half a CPU:

bash

docker run --rm -d --name jvm-cpu-stress -p 8082:8080 \
  --cpus=0.5 \
  ghcr.io/learnk8s/jvm-requests-limits:latest >/dev/null

curl --retry 10 --retry-connrefused --retry-delay 1 \
  -sf localhost:8082/health >/dev/null

curl -s "localhost:8082/stress?threads=2&seconds=5" | jq .

{
  "threads": 2,
  "seconds": 5,
  "availableProcessors": 1,
  "nr_periods_delta": 51,
  "nr_throttled_delta": 51,
  "throttled_usec_delta": 7665874,
  "throttledRatio": "1.00"
}

docker stop jvm-cpu-stress >/dev/null

The container hit its CPU limit in every measured period, and the throttledRatio was 1.00.

Does throttled_usec_delta mean one request waited that long?

No, it is the total throttled time recorded by the cgroup during the test.

The kernel adds the time recorded on each CPU, which means the total can even be longer than the test itself.

On cgroup v1, the equivalent throttled_time counter is reported in nanoseconds instead of microseconds.

The exact numbers change from run to run.

What matters is how often throttling occurs and whether it coincides with slower requests or longer GC pauses.

For a running Kubernetes pod, the demo exposes the same counters through /info:

bash

kubectl exec deploy/jvm-memory-check -- \
  wget -qO- http://localhost:8080/info | \
  jq '{cgroupThrottledPeriods, cgroupTotalPeriods, cgroupThrottledUsec}'

In production, track CPU throttling over time with container metrics rather than a single sample.

HotSpot normally reads the processor count from the container, and you can override it when needed.

Using ActiveProcessorCount carefully

-XX:ActiveProcessorCount=N overrides the processor count HotSpot uses to size GC and other thread pools, and the value must be a positive whole number:

bash

java -XX:ActiveProcessorCount=2 Main

This flag changes only the processor count HotSpot sees.

It does not change how much CPU the container can use.

For example:

deployment.yaml

resources:
  limits:
    cpu: 3
env:
  - name: JAVA_TOOL_OPTIONS
    value: '-XX:ActiveProcessorCount=1'
  • The container can have a `limits.cpu: 3` quota. Linux still allows the container to use up to three CPUs of total execution time.The container can have a limits.cpu: 3 quota. Linux still allows the container to use up to three CPUs of total execution time.
    1/3

    The container can have a limits.cpu: 3 quota. Linux still allows the container to use up to three CPUs of total execution time.

  • With `-XX:ActiveProcessorCount=1`, HotSpot sizes GC workers and the common ForkJoinPool as if one processor were available.With -XX:ActiveProcessorCount=1, HotSpot sizes GC workers and the common ForkJoinPool as if one processor were available.
    2/3

    With -XX:ActiveProcessorCount=1, HotSpot sizes GC workers and the common ForkJoinPool as if one processor were available.

  • The flag changes HotSpot defaults, not the Linux quota. All JVM threads still share the CPU quota set on the container.The flag changes HotSpot defaults, not the Linux quota. All JVM threads still share the CPU quota set on the container.
    3/3

    The flag changes HotSpot defaults, not the Linux quota. All JVM threads still share the CPU quota set on the container.

The container can still use up to three CPUs, but HotSpot sets up GC and the common ForkJoinPool as if there is only one processor.

With fewer workers, the container may reach its CPU quota less often.

The work itself does not disappear; it can end up waiting in a JVM or an application queue instead.

Less throttling on its own does not prove the flag helped.

The relevant comparison includes GC pauses, throughput, and p99 latency before and after the change.

This flag is also not a replacement for a CPU limit.

If you set ActiveProcessorCount=1 on a laptop, HotSpot reports a single processor, even though the process can still use all CPUs on the machine.

A container with a 500m limit can use only half a CPU, regardless of this flag.

ActiveProcessorCount is a tuning tool, not a default fix for throttling.

Setting a CPU limit removes the flag because HotSpot can derive a processor count from the cgroup quota.

One distinction is still missing before you put the whole configuration together: a request is not a limit.

The heap follows the memory limit, not the request

Kubernetes uses requests and limits for different purposes.

Requests decide where the pod lands, and limits decide what the container can do once it is running.

requests.memory tells the scheduler how much memory to account for when it places the pod.

It does not cap memory use.

The request also matters under node memory pressure because pods that use more than their request are more likely to be evicted.

Some cgroup v2 setups also read it as a memory-protection hint.

limits.memory sets the container's memory boundary.

When the container reaches that boundary, the kernel first tries to free memory.

If it cannot free up enough, it may kill a process in the container.

When a memory limit is present, -XX:MaxRAMPercentage uses that limit, not requests.memory.

Without a limit, the request does not cap the heap, and you have to read the maximum heap size from the running JVM.

If your platform sets the request and the limit to the same value, changing that value also moves the heap target.

CPU follows the same pattern:

Now that the roles are separate, here is how container memory, the heap, the collector, and CPU pull on each other.

Putting the resource settings together

The examples in this article connect four settings:

  1. When set, the container memory limit covers the JVM and everything else in the container.
  2. The maximum heap must fit inside that limit with enough memory left for thread stacks, direct buffers, metaspace, code cache, and other container memory.
  3. The collector uses the CPU and memory visible to the JVM. If a particular collector is required, select it explicitly and test it with the real workload.
  4. The CPU limit controls how much CPU time all JVM threads can share. A low limit can make GC pauses and request processing take longer under load.

Changing one setting moves the others.

Lowering the CPU limit can change the number of processors visible to the JVM, which can affect the collector and the size of its worker pools.

Fractional CPU limits are the easiest to misread, because HotSpot 21 rounds the CPU quota up to a whole-number processor count.

Raising the heap target leaves less container memory for everything outside the heap.

A realistic workload validates changes to these settings.

Summary

A JVM in a container has two memory boundaries, and only one of them is the Java heap.

-Xmx and MaxRAMPercentage cap the heap, while limits.memory covers that heap plus thread stacks, metaspace, code cache, direct buffers, GC structures, and everything else in the container.

When the second boundary is reached first, the kernel kills the process before the JVM can report OutOfMemoryError.

When you set limits.cpu, Kubernetes gives the container a CPU quota.

HotSpot 21 turns that quota into a whole-number processor count, bounded by CPU affinity or cpusets.

The JVM uses that processor count, together with visible memory, to pick a collector and size its worker pools.

Two deployments of the same image can start with different garbage collectors when the CPU quota or visible memory changes.

None of this is solved just by picking a better default.

You can size the heap as a percentage of the limit, choose the collector, and set ActiveProcessorCount manually, but each of these choices only works if you test them under a real workload.

The measurements are the part you can't skip: live heap after GC, container memory, throttled periods, GC pauses, and p99 latency. Look at them together, not just one at a time.