Java is the backbone of enterprise software, and Kubernetes is the standard for deployment. You would assume that by 2026 these two technologies would work perfectly together out of the box. They do not. Most Java workloads on Kubernetes run with default JVM settings that quietly waste memory, throttle CPU, and slow requests.
The data confirms Java’s dominance. According to the Azul State of Java Survey 2025, 68% of enterprises report that the majority of their applications run on the JVM. New Relic’s 2024 State of the Java Ecosystem found that adoption of container-friendly Java versions like Java 17 and 21 has surged by nearly 300%, proving that Java is being aggressively modernized for the cloud.
Unfortunately, while adoption is high, performance and efficiency are low.
In our recent webinar, Stefano Doni (CTO at Akamas) and Bruno Borges (Principal Program Manager at Microsoft and Java Champion) analyzed the state of the industry. After reviewing data from thousands of JVMs running in production environments globally, the verdict was clear: the majority of Java workloads on Kubernetes are running with “default” settings that are actively hurting performance and wasting money.
Here is a look at what the data says, why “safe” defaults are dangerous in the cloud, and how you can fix it.
How Are We Configuring JVMs?
The premise of modern DevOps is automation and efficiency. However, the reality is that most organizations are “lifting and shifting” their Java applications into containers without adjusting the Java Virtual Machine (JVM) configuration.
This isn’t a new problem, but it is getting worse. During the session, Bruno Borges referenced historical data from New Relic, highlighting that for years, over 65% of workloads lacked explicit Garbage Collection (GC) tuning.
But that was about 5 years ago. How is the situation now? Stefano Doni then presented Akamas’s 2025 research, analyzing thousands of Java workloads being optimized in our platform. The data confirms that despite the maturity of Kubernetes and Java, these bad habits persist today.

According to our data:
- 60% of JVMs have an unset Garbage Collector. The JVM then picks a default algorithm that may not match your container size or latency requirements.
- The majority of Heap configurations are unset. Most developers rely on the JVM’s automatic memory management.
- Most containers are too small. Many JVMs run with less than 1 CPU or less than 1 GiB of RAM. That is a major bottleneck for Java’s multi-threaded architecture.


Why is this happening? As Bruno Borges highlighted, many developers assume “the JVM will tune itself”. The JVM ergonomics are smart, but they were originally designed for large, shared physical servers, not for the constrained resources of isolated Kubernetes pods.
Ergonomics is the process by which the Java Virtual Machine (JVM) and garbage collection tuning, such as behavior-based tuning, improve application performance. The JVM provides platform-dependent default selections for the garbage collector, heap size, and runtime compiler. These selections match the needs of different types of applications while requiring less command-line tuning. In addition, behavior-based tuning dynamically tunes the sizes of the heap to meet a specified behavior of the application.
The reality is that default JVM heuristics are far from optimal. Their impact on your workloads is invisible but significant.
Wasting Cloud Resources
The most common mistake is leaving the Heap Size unset (no -Xmx or -XX:MaxRAMPercentage).
Modern JVMs (Java 10+) are “container-aware”, meaning they attempt to self-configure based on the resources assigned to the container. However, this default behavior is incredibly conservative. For typical production containers (those allocated above 512MB of memory), the JVM uses only 25% of the container memory limit for the heap.
Note: For very small containers (<256MB), the JVM acts differently, taking up to 50%, but most enterprise workloads fall into the larger category where the 25% rule applies.
This default creates a paradox around resource efficiency. Imagine you configure a pod with 2GB of memory requests and limits. You are reserving 2GB of cluster capacity for that container. Because you relied on defaults, the JVM sees that limit but allocates only 512MB to your application’s heap. The remaining 1.5GB is technically available for non-heap memory, yet a typical microservice rarely needs 1.5GB of overhead. You are effectively leaving almost 75% of your memory on the table, paying for capacity your application is forbidden from touching.
Beyond the waste, this configuration undermines performance and stability. Because the heap is restricted to a small fraction of available memory, your application fills it up much faster than necessary. That triggers frequent, aggressive Garbage Collection cycles to reclaim space, leading to CPU spikes and latency, even though the container has plenty of free memory.
What happens when you leave your GC Unset?
The Garbage Collector (GC) is the single most impactful factor on application latency and throughput. The research data shows that nearly 60% of Java workloads leave the Garbage Collector type unset. This forces the JVM to rely on its ergonomics to choose an algorithm for you.
By default, modern Java versions prefer the G1GC collector, designed for high throughput and low latency. However, the JVM only enables G1GC if it detects at least 2 CPUs and 1791MB of RAM.

If you deploy a microservice with a standard “cost-saving” configuration of 1 CPU or less, the JVM silently downgrades the Garbage Collector to SerialGC. Unlike G1GC, which runs concurrently, SerialGC performs memory cleanup using a single thread, freezing your application entirely for the duration of the collection.
There is a second trap just above that threshold. When a container is just large enough to select G1GC (say, exactly two CPUs), you hit a technical paradox. G1GC spins up worker threads to clean memory in parallel. In such a constrained environment those threads crowd the very few cores available. Instead of streamlining the work, the CPU wastes cycles on constant context switching between your application logic and the swarm of background GC threads.
This convenience of “automatic” selection is a double-edged sword. It often chooses a GC that is not optimal for your performance or footprint goals, and it does so silently. Because the JVM shifts its entire memory management strategy based on an apparently unrelated setting like the CPU limit, it can introduce performance penalties that are hard to diagnose. Relying on these defaults means ignoring the delicate balance between threads and cores. You are left at risk of a single-threaded bottleneck on one side and multi-threaded overhead on a small node on the other.
The High Cost of “Micro-Containers”
One of the most pervasive trends is the deployment of JVMs with less than 1 CPU. This looks efficient on a spreadsheet, and it is often chosen to reduce the “blast radius” by spreading instances. For Java it creates a fundamental architectural conflict, forcing a trade-off between cost and application stability.
A Kubernetes CPU limit is not a “speed limit” that slows down the processor. It is a “time quota” enforced by the Linux kernel’s Completely Fair Scheduler (CFS). Typically the kernel divides time into 100ms windows. If you assign a container a limit of 500m (0.5 CPU), you are granting it 50ms of runtime every 100ms. Your JVM can still see all the CPUs of your Kubernetes node. Once that quota is exhausted, the kernel throttles the container: it freezes all threads until the next window begins.
This mechanism is disastrous for Java because of its multi-threaded nature. The JVM is not just running your code. It constantly runs background threads for the Just-In-Time (JIT) compiler and the Garbage Collector.
When your application starts or hits a new “hot” code path, the JIT compiler kicks in to transform bytecode into optimized machine code. This process is CPU-intensive. In a micro-container, a JIT burst can consume the entire 50ms quota in an instant. The Linux kernel then forcibly pauses your application for the remaining 50ms of the window. To your users this looks like latency, but it is actually an OS-enforced sleep where no code is executing.
The sheer density of threads inside the JVM makes this worse. Even a simple application spawns dozens of threads: Garbage Collection threads, compiler threads, and your application threads. When you constrain a Java pod to less than 1 CPU, all these threads fight for those microscopic time slices. The background maintenance tasks, which are supposed to be invisible helpers, become active obstructors of your business logic.
This contention damages performance regardless of which Garbage Collector you use. Modern concurrent collectors (like G1GC, ZGC, and Shenandoah) clean memory in the background using worker threads named ParallelGCThreads and ConcGCThreads. Tight CPU limits force the Kubernetes scheduler to throttle these threads, slowing user requests so the GC can proceed. When the inevitable “Stop-The-World” pause occurs, a phase required by all collectors, the lack of available CPU cycles means the cleanup takes significantly longer.
This explains the findings from the benchmarks discussed by Bruno Borges. When comparing six small replicas (1 CPU each) against two larger replicas (3 CPUs each), the results revealed a clear performance gap. With the exact same total CPU capacity, the two larger replicas delivered significantly better throughput and lower tail latency. Giving the JVM “breathing room” reduces the probability of hitting these throttling walls. It also ensures background threads run on separate cores without freezing the request-processing threads.

The Ecosystem Is Reacting
The industry is aware of these challenges, and during the webinar Bruno Borges shared several initiatives aimed at mitigating them.
Tools like the Azure Command Launcher for Java (“jaz”) automate the configuration of JVM flags based on the environment, effectively tuning the startup command for you. Projects within the OpenJDK ecosystem, such as Project Leyden and CRaC (Coordinated Restore at Checkpoint), are making strides to reduce startup times and memory footprint through static images and snapshotting.
These advancements help developers start on the right foot. They do not eliminate the fundamental need to manage the performance and efficiency trade-offs of running Java in Kubernetes. Even with a perfectly tuned startup command or a faster boot time, once your application is running under load the tension between memory, CPU, and latency remains.
Stop Guessing, Start Measuring
The days of manual tuning are over. Even well-intentioned attempts to “fix” these issues by hand often backfire.
Real efficiency requires balancing Throughput, Latency, and Footprint. Improving one often comes at the expense of the others.
- Maximize Throughput? You may need a larger heap, increasing your Footprint.
- Minimize Footprint? You risk squeezing the JVM and destroying your Latency.
To solve this puzzle you cannot look at the JVM in isolation. You must align JVM configuration with Kubernetes container resources and HPA scaling policies at the same time. It is a trade-off between wasting money on over-provisioning and risking reliability with under-provisioning. It has to be done for every Java workload, continuously, as traffic changes and new releases reach production.
You don’t need to solve this multi-variable equation manually. You can use Akamas Insights to run a comprehensive health check of your Java applications on your Kubernetes clusters. By connecting to your existing observability tool (like Prometheus, Datadog, and Dynatrace), Akamas Insights analyzes the full picture: JVM metrics, container limits, and actual usage. It tells you exactly where you are sacrificing reliability for cost, or the reverse.
Stop flying blind: Get your free Application Health Score with Akamas Insights now.
FAQs
Which garbage collector does the JVM use by default on Kubernetes?
It depends on the resources the JVM detects. With at least 2 CPUs and 1791MB of RAM, the JVM selects G1GC. Below that threshold it silently falls back to the single-threaded SerialGC.
How much heap does the JVM allocate by default in a container?
For containers above 512MB, a container-aware JVM (Java 10+) uses only 25% of the memory limit for the heap by default. Set -XX:MaxRAMPercentage or -Xmx to reclaim the rest.
Why do Java pods with less than 1 CPU perform poorly?
A Kubernetes CPU limit is a time quota enforced by the Linux CFS scheduler, not a speed limit. A 500m limit grants 50ms of runtime per 100ms window. JIT and GC threads exhaust that quota quickly, so the kernel throttles the pod and requests stall.
What is CPU throttling in Kubernetes?
Throttling is the kernel pausing a container once it spends its CFS time quota for the current window. The container waits, idle, until the next window. For Java this freezes application, JIT, and GC threads together.
Are fewer large replicas better than many small ones for Java?
Often yes. At equal total CPU, benchmarks showed two 3-CPU replicas beat six 1-CPU replicas on throughput and tail latency, because larger CPU limits reduce throttling and give background threads room to run.

