Pause time is not all GC time
Java 21 commonly uses G1 by default. G1 divides the heap into regions and collects young objects through evacuation pauses. Portions of its marking work run alongside the application. A pause-time goal is a goal, not a guarantee that every request will satisfy it. Evacuating many live objects, insufficient free regions, remembered-set work, or a costly fallback collection can produce surprising pauses.
Different allocation patterns matter. A large number of short-lived request objects may create frequent young collections. Long-lived caches increase the live set and may raise the amount of work needed to relocate or track objects. A leak can steadily grow live memory until the collector has little room to operate. These are not interchangeable diagnoses, and their fixes differ.
An application that allocates 600 MB each second on a one-gigabyte heap will put heavy pressure on the collector. Yet two workloads with the same allocation rate can behave differently if one retains most objects. I want three measurements: allocation rate, live-data size after collection, and the distribution of actual application pauses. The raw heap-size chart gives only one slice of that story.
Gather evidence without creating another outage
Enable or inspect existing GC unified logs and Java Flight Recorder events, using a short, bounded recording that is appropriate for production. Look for pause types and durations, frequency, heap occupancy before and after, concurrent cycles, allocation hot spots, and safepoint timing. Compare those events to request traces with clocks normalized. A 250 ms GC pause at the same instant as a 300 ms response is meaningful. Five minutes apart is not.
Collect thread dumps or JFR thread-state evidence to exclude blocked database calls and contested monitors. Also inspect container CPU throttling and available memory. GC threads consuming cgroup CPU budget can exacerbate delay. A collector that would complete rapidly on an unconstrained host can struggle when it competes with the application's requests for an undersized quota.
Do not assume a request's reported duration equals the GC pause duration. A request might queue before the pause or behind dozens of others that become runnable at once afterward. The service's p99 can exceed the longest collector pause because of that queueing effect.
Why a bigger heap can make this worse
A bigger heap offers more space between collections, but it can increase the amount of live data examined or relocated when work eventually happens. It can also raise process RSS and press the container into memory reclaim. There is no universal rule that doubling a heap either halves or doubles pause time. The collector, live set, allocation shape, and available CPU determine the outcome.
I'd first eliminate unnecessary allocation from known hot paths. For example, a JSON handler that creates several temporary maps per field may generate far more objects than the representation requires. But avoid speculative object pooling for cheap short-lived objects. Pooling may lengthen lifetimes, increase retained heap, and make concurrent code harder to reason about. Use a profile, not folklore.
If G1's pause distribution remains unsuitable after tuning allocation and memory headroom, compare another collector under the same traffic and hardware. Java 21 provides generational ZGC as an opt-in collector designed for low pause times by doing more work concurrently. That introduces its own memory, CPU, operational and throughput considerations. The fact that a collector advertises low pauses does not absolve the team of measuring throughput and deadline misses.
The reliability decision
A checkout endpoint with a 100 ms deadline may care much more about p99.9 pause overlap than average GC overhead. A nightly report worker may prefer throughput even with occasional longer pauses. That difference should drive collector choice and resource sizing. For the checkout service I would establish a latency budget, measure how many calls overlap pauses, and compare candidates on CPU cost, RSS, sustained throughput, and p99 under load and recovery scenarios.
Finally, check after deployment, not just in a load test. A new cache, changed payload size, or a different live-set composition can change collection behavior without changing QPS. Keep the GC logs, JFR and application tail-latency traces connected enough to answer the next incident in minutes, not hours.