Core Java

Kubernetes CPU Limits and JVM Throttling: Why p99 Spikes With Plenty of Idle CPU

The dashboard looks calm. CPU usage sits near 20%, memory is fine, and yet the p99 latency graph keeps spiking. Many teams hit this puzzle after moving a Java service into Kubernetes, and the culprit is often a CPU limit.

In this article, we explain how a CPU limit really works, why the JVM is especially exposed, and what throttling looks like in a measured experiment. Then we cover practical fixes.

What a CPU Limit Really Is

A quota per period, not a speed cap

Kubernetes enforces a CPU limit with the Linux CFS bandwidth controller. According to the kernel documentation, a group gets a quota of run-time within each period, and the cgroup exports throttling counters such as nr_throttled. In Kubernetes, the kubelet applies this through the CFS quota, with a period that defaults to 100 ms, as the GKE kubelet reference describes.

Consequently, a limit of 500m means 50 ms of CPU time in every 100 ms window. Once the container spends that budget, every thread in it is paused until the next window starts, even if the node has idle cores.

Why averages hide the problem

Monitoring tools usually average CPU over many seconds. Throttling happens in 100 ms slices. Therefore, a service can look 20% busy over a minute and still run out of quota during a burst.

Why the JVM Is Especially Exposed

Many threads, one quota

A JVM is never just one thread. Request threads, garbage collector workers, and JIT compiler threads all run at once. As Microsoft’s guidance for Java on Kubernetes points out, quotas measure CPU time, not the number of CPUs, so a multi-threaded runtime can use several processors at once even with a limit of one vCPU.

For example, with a 50 ms quota, four busy threads can consume the whole budget in about 12 ms of real time. After that, all threads freeze for the rest of the period, including the ones serving requests.

The processor count is a rounded guess

The JVM reads the container limit at startup and divides quota by period, rounding up, as the code in this OpenJDK change shows. The same Microsoft guide notes that the JVM does not adjust later, and that you can set the count yourself with -XX:ActiveProcessorCount.

As a result, a 500m limit still tells the JVM it has one processor, while the quota math allows only half of one. The JVM sizes its thread pools from that number, not from the budget.

What the Numbers Say

To see the effect directly, I ran a real experiment inside a Linux cgroup with a CFS quota. A single-threaded worker handled 282 requests that arrived randomly, about 10 per second, and each needed 20 ms of CPU. I repeated the same traffic under five quota settings.

CPU limitAvg CPU usedp50p95p99Periods throttled
None18.9%20.5 ms36.9 ms46.2 msno quota
1.0 CPU (100 ms)18.9%20.6 ms39.0 ms54.5 ms0 of 247
0.5 CPU (50 ms)18.9%20.7 ms67.9 ms92.4 ms30 of 256 (12%)
0.3 CPU (30 ms)19.0%40.9 ms239.0 ms360.1 ms117 of 270 (43%)
0.25 CPU (25 ms)18.7%100.6 ms369.1 ms428.3 ms180 of 277 (65%)
Figure 1. Request latency by CPU limit, same traffic every run. Data note: my own single run per setting, 30 s each, in a sandbox with one CPU and cgroup v1. Absolute numbers will differ on your nodes; the pattern is the point.
Figure 2. Share of 100 ms periods in which the cgroup was throttled (from its own cpu.stat counters). Average CPU stayed near 19% in every run.

Notably, the 0.5 CPU row is the telling one. That limit gives 2.6 times more CPU than the service uses on average, yet p99 doubled and 12% of periods were throttled. At 0.25 CPU the headroom is thin, so some queueing is expected, but the trend is the same: average usage never changed, while tail latency exploded.

How to Fix It

FixWhat it doesTrade-off
Raise the limitGives bursts room to finish inside one periodCosts more reserved CPU
Remove the limit, keep the requestLets the pod use idle node CPUNoisy neighbors can compete at busy times
Disable CFS quota on the nodeKeeps the pod’s QoS class without enforcementA node-wide policy decision
Reduce JVM parallelismFewer GC and compiler threads burn the quotaSlower parallel work

Importantly, opinions differ. Some vendors, such as PerfectScale, advise avoiding CPU limits when performance matters, while others warn that unlimited containers compete during concurrent bursts. Therefore, measure your own workload before choosing.

Quick checklist

  • Check the throttling counters, not just average CPU.
  • Compare p99 latency with and without the limit.
  • Set -XX:ActiveProcessorCount to match the real budget.
  • Give the quota headroom for GC and JIT bursts.
  • Decide deliberately between limits and requests only.

What We Have Learned

A Kubernetes CPU limit is a quota of CPU time per 100 ms period, not a speed limit, and it pauses every thread in the container once the budget is spent. The JVM makes this worse, because many threads share one quota and the processor count it sees is rounded up from that quota. In our experiment, average CPU stayed near 19% in every run, yet p99 grew from 46 ms to 92 ms at a 0.5 CPU limit and to over 400 ms at 0.25. So, when p99 spikes with idle CPU, check throttling first, then raise or remove the limit, and tune the JVM’s thread counts to match.

Eleftheria Drosopoulou

Eleftheria is an Experienced Business Analyst with a robust background in the computer software industry. Proficient in Computer Software Training, Digital Marketing, HTML Scripting, and Microsoft Office, they bring a wealth of technical skills to the table. Additionally, she has a love for writing articles on various tech subjects, showcasing a talent for translating complex concepts into accessible content.
Subscribe
Notify of
guest

This site uses Akismet to reduce spam. Learn how your comment data is processed.

0 Comments
Oldest
Newest Most Voted
Back to top button