Java Under Heavy Load: Where Performance Starts to Break
A Java service can look perfectly healthy in development and still struggle as soon as real traffic arrives. The first slowdown is not always where developers expect it. CPU usage may remain moderate, the heap may have plenty of room, and individual methods may still execute quickly, while users are already waiting noticeably longer for a response.
The reason is simple: a request does not live inside one method or even one JVM component. It moves through thread scheduling, connection pools, caches, databases, message brokers, file systems, and external APIs. Under load, any one of those stages can become a queue. Once work starts arriving faster than it can be completed, latency rises long before the application actually crashes.
The CPU Is Often Not the First Limit
One of the most misleading production situations is a slow application with CPU usage well below 100 percent. At first glance, the obvious solution seems to be more threads or more application instances. In reality, much of the running code may simply be waiting.
A request can wait for a database connection, a response from another service, a disk operation, a lock, or an available slot in a bounded executor. If one of those resources can handle only a limited number of concurrent operations, increasing the number of incoming requests does not increase useful work. It only increases the number of requests standing in line.
That is why several metrics need to be read together when investigating a slowdown:
- throughput shows how many operations the service actually completes within a given period;
- latency shows how long an individual operation takes from the client’s point of view;
- concurrency shows how many operations are active or waiting at the same time;
- resource saturation reveals whether CPU, memory, connections, disks, or queues are approaching their limits;
- error rate helps show when slower processing begins to turn into failed requests.
Looking at only one of these numbers can hide the real problem. A service may still report impressive throughput while a growing share of users are already hitting long response times.
Virtual Threads Change Concurrency, Not Capacity
Traditional Java server applications often rely on a limited pool of platform threads. That model gives developers a clear upper bound, but it also has an obvious cost: when many threads spend most of their time blocked on network or database I/O, new work has to wait for an available thread.
Virtual threads make that waiting much cheaper. A large number of blocking tasks can be expressed in the familiar thread-per-request style without requiring the same number of operating-system threads. For I/O-heavy services, this can make application code considerably easier to scale and reason about.
What virtual threads do not provide is unlimited downstream capacity. A database pool with 100 connections still has 100 connections. A remote API that allows a fixed number of concurrent requests still has the same limit. If ten thousand virtual threads all reach that boundary, Java can manage the waiting tasks more efficiently, but the bottleneck itself has not disappeared.
The implementation has also improved over recent JDK releases. In JDK 24, changes to synchronization removed nearly all cases in which a virtual thread became pinned to its carrier simply because it blocked inside a synchronized method or statement. That removes an important scalability restriction, although native code and a small number of other cases can still require attention during profiling.
Memory Problems Can Appear Long Before the Heap Is Full
Memory pressure is not synonymous with OutOfMemoryError. A busy backend can create enormous numbers of short-lived DTOs, strings, temporary collections, serialized payloads, log objects, and framework-level wrappers. The heap may never fill completely, yet the allocation rate can still force the garbage collector to work much harder.
This is why garbage collection should be investigated as part of the workload rather than treated as a separate JVM problem. G1 remains a common choice for general server workloads, while ZGC is designed for applications where keeping pauses low is particularly important. In current JDK releases, ZGC operates as a generational collector.
The right collector cannot be chosen from a benchmark headline alone. Heap size, allocation rate, object lifetime, CPU headroom, and latency requirements all matter. A configuration that works well for a service processing large batches may be a poor fit for an API handling thousands of small, short-lived requests.
Several common symptoms can provide a useful starting point:
| Symptom | Possible cause | What to inspect |
|---|---|---|
| Regular latency spikes | GC pauses, locks, slow downstream calls | GC logs, JFR, distributed traces |
| Heap stays high after collection | Retained objects, oversized caches | Heap dump, allocation profile |
| CPU rises with traffic | Computation, serialization, GC, busy loops | CPU profile, flame graph |
| CPU stays low while responses slow down | I/O waits, connection pools, locks | Thread state, pool metrics, tracing |
None of these symptoms proves a cause on its own. Their value is in narrowing the search before profiling and tracing show where the application is actually spending its time.
The Database May Hit Its Limit Before the JVM Does
Application instances are often easier to scale than the systems behind them. Adding another Java service replica in a cloud environment may take seconds. Adding meaningful database capacity, increasing the throughput of a payment processor, or changing the limits of a third-party API is a very different problem.
This mismatch creates cascading slowdowns. Suppose one incoming request makes three downstream calls. As long as all three respond quickly, the service appears healthy. If one begins taking longer, requests remain in flight for more time. More connections stay occupied, queues grow, timeouts begin to overlap, and a slowdown in one dependency starts affecting components that were initially healthy.
That is why high-load architecture depends on limits as much as on raw capacity. Timeouts prevent requests from waiting forever. Backpressure stops producers from overwhelming consumers. Circuit breakers can keep a failing dependency from consuming resources indefinitely. Carefully sized connection pools make a hard downstream limit visible instead of hiding it behind an ever-growing number of waiting tasks.
A message queue can help absorb a temporary spike, but it does not create processing capacity either. If consumers can handle 5,000 events per minute while producers continuously send 8,000, the queue is not solving the imbalance. It is only postponing the point at which it becomes visible.
Find the Queue, Not Just the Slowest Method
Traditional profiling is useful, but a flame graph alone does not explain every production slowdown. A method that takes five milliseconds of CPU time may have little to do with a two-second response if the request spent most of those two seconds waiting for a database connection.
The more useful question is where work starts accumulating. Metrics can show which pool or dependency reaches saturation first. Distributed tracing can reveal which hop adds most of the end-to-end delay. JDK Flight Recorder can then provide JVM-level detail on allocation, thread behavior, locks, CPU activity, and other runtime events.
A practical load investigation can follow a simple sequence:
- record a baseline for throughput, latency, resource use, and error rate;
- increase load gradually instead of jumping directly to the expected peak;
- identify the first metric that stops scaling normally;
- inspect thread pools, connection pools, queues, and downstream limits;
- compare latency changes with CPU, GC, I/O, and external calls;
- change one meaningful variable and repeat the same test.
This approach prevents random JVM tuning from becoming the first response to every slowdown. It also makes it easier to separate the original bottleneck from secondary symptoms created by the backlog.
Average response time deserves particular caution. The average may still look acceptable while a smaller but significant group of requests is already performing badly. Percentiles such as p95 and p99 make those tail-latency problems much easier to see and often expose resource contention before it becomes obvious in broader monitoring.
Java gives developers strong tools for building services that handle substantial traffic. Virtual threads can make I/O-heavy concurrency cheaper, modern garbage collectors can reduce the cost of memory management, and cloud infrastructure makes application-level scaling straightforward. None of those improvements, however, removes the limits imposed by databases, queues, networks, and external systems.
When a service slows down under load, the most useful first step is therefore not another JVM flag or a larger thread pool. It is to find the point where work begins arriving faster than it can leave. Once that point is visible, the remedy becomes much clearer: sometimes it is code, sometimes configuration, sometimes a database query, and sometimes the architecture itself.
← Back to Articles