Your p99 is a garbage collector setting

> $ stat metadata
Date: 2026.09.27
Time: 6 min read
Tags: [jvm, garbage-collection, latency, performance, java, observability]

An endpoint has a median latency of 12 milliseconds and a p99 of 340. Tracing shows the slow requests spending their time in no particular place: the spans inside them add up to about 15 milliseconds and the request took 340.

The missing 325 milliseconds is not in any span because the thread was not running. It was stopped, along with every other application thread, while the garbage collector did its work.

Nothing in the application is slow. The application was paused.

The signature is that everything slows at once

A stop the world pause halts every application thread at a safepoint. It does not matter what a thread was doing or which endpoint it was serving.

That produces a distinctive pattern: unrelated endpoints slow simultaneously for the same brief window, then recover together. A health check that does nothing takes 200 milliseconds. A cache hit that never touches the network takes 200 milliseconds. If your slow requests have nothing in common except their timestamps, stop looking at the code.

flowchart TB
    A["Request A: 8ms of work"] --> P["Stop the world pause<br/>320ms, all threads halted"]
    B["Request B: 3ms of work"] --> P
    C["Health check: 0.2ms of work"] --> P
    P --> R["All resume. All report<br/>320ms of unexplained latency."]

    style P stroke:#ef4444,stroke-width:3px,color:#fff

Confirming it takes GC logs and a timestamp comparison:

-Xlog:gc*:file=/var/log/gc.log:time,uptime,level,tags:filecount=5,filesize=100M

That is the Java 9 and later syntax. Line up the pause timestamps against the slow request timestamps and the question is answered in a minute. It is worth having this on permanently, because the overhead is negligible and the alternative is not being able to answer this during an incident.

Allocation rate is the lever, not heap size

The instinct when GC is implicated is to give the JVM more memory. That is often the wrong direction and it is worth understanding why.

Generational collectors work on the observation that most objects die young. New allocations go into the young generation, which is collected frequently. A young collection copies the objects that survived and discards the rest, so its cost scales with how much survived, not with how much was allocated.

Two things follow.

How often you collect is determined by how fast you fill the young generation, which is your allocation rate.

How long each collection takes is determined by how many objects are still alive, which is your retention.

A service allocating 2GB per second with a 512MB young generation triggers a young collection roughly four times a second. Enlarging the heap makes those collections less frequent and gives each one more to look at.

For throughput that is a good trade. For tail latency it can be actively bad, because the tail is defined by the longest pauses and you have just made them longer while making them rarer. Rarer long pauses still land on somebody’s request.

Reducing allocation improves both terms at once, which is why it is the first thing to look at.

Where the garbage comes from

Allocation profiling answers this directly, and Java Flight Recorder is built in and cheap enough to leave running.

java -XX:StartFlightRecording=duration=120s,filename=alloc.jfr \
     -XX:FlightRecorderOptions=stackdepth=128 -jar service.jar

Open the recording and sort by allocation. The offenders are consistent across services:

Logging that formats messages which are then discarded by the level filter. log.debug("state: " + expensiveToString()) builds the string before the call, every time, regardless of whether debug is enabled. Parameterised logging avoids it.

Boxing in hot loops. A Map<Long, Long> counter allocates a Long per increment past the small value cache, which at high request rates is a lot of short lived garbage. A primitive map from Eclipse Collections or fastutil removes it.

Reading a whole response into memory when it is going to be streamed anyway.

Defensive copying inside frequently called methods, where the copy exists to protect against a mutation that never happens.

Serialising large objects with fields that are never read, which allocates the whole graph to produce output nobody uses.

None of these are exotic. They are ordinary code that is fine at low volume and becomes an allocation problem at high volume, which is why they survive review.

Choosing a collector by what you care about

CollectorPause behaviourReasonable for
Parallellong pauses, best throughputbatch jobs, offline processing
G1targets a pause goal, default since Java 9most services
ZGCsub-millisecond, largely heap size independentlatency sensitive, large heaps
Shenandoahsub-millisecond, concurrent compactionsame as ZGC, different tradeoffs

G1 is a reasonable default and -XX:MaxGCPauseMillis is a goal rather than a guarantee, which is a distinction worth internalising. Setting it to 10 does not produce 10 millisecond pauses; it makes G1 choose smaller collection sets to try to get there, and if it cannot, it will exceed it.

ZGC is the one I would reach for when the tail is the thing being optimised. It does concurrent marking and relocation, keeping pauses in the sub-millisecond range mostly independent of heap size, and it pays for that with some throughput and extra memory. For a service where p99 is a product requirement rather than a curiosity, that is usually a good exchange.

Switching collectors is a one flag change and it is worth measuring rather than reasoning about, because the answer depends on allocation patterns that are hard to predict from the outside.

The interaction with everything else

A GC pause is invisible to most of your instrumentation, and it interacts with systems that assume progress.

A pause longer than a health check timeout gets a perfectly healthy pod restarted, at the moment it was under enough load to be collecting. A pause during a pub/sub message delivery loses the message, because nothing retries a fire and forget broadcast. A pause while holding a database connection extends the holding time, which feeds directly into the pool sizing arithmetic.

This is the part that makes GC worth understanding even if you never tune it. The pause is not confined to the process. It leaks into every timeout, every heartbeat, and every assumption about liveness that anything else makes about you.

What I would do first

Turn on GC logging permanently, because the cost is near zero and its absence during an incident is expensive.

Compare pause timestamps to slow request timestamps before touching any flag. That one comparison either confirms or eliminates GC in a couple of minutes.

If confirmed, profile allocation rather than raising the heap. The change that removes garbage helps frequency and duration together, while the change that adds memory trades one for the other.

Then consider the collector, because on a modern JVM with a latency sensitive workload the default is not always the right choice and changing it is a single flag.

Frequently Asked Questions

How do you tell if garbage collection is causing latency spikes?
Enable GC logging and compare pause timestamps against the slow requests. A stop the world pause halts every application thread at once, so the signature is many unrelated endpoints slowing simultaneously for the same short window, rather than one code path being slow. If application level tracing shows a request taking two hundred milliseconds while the sum of its own spans is fifteen, the missing time was spent paused rather than executing.
Does increasing the heap size reduce garbage collection pauses?
It reduces how often collections happen and can make each one longer, because there is more to scan and more surviving objects to move. For a throughput oriented workload that is a good trade. For latency sensitive services it can make the tail worse, since you have exchanged frequent short pauses for rarer long ones and the tail is measured by the long ones. Reducing allocation rate helps both frequency and duration at the same time.
Which JVM garbage collector should you use for low latency?
G1 is the default from Java 9 onward and targets a configurable pause goal, which is adequate for most services. ZGC and Shenandoah do the majority of their work concurrently with the application and keep pauses in the sub-millisecond range largely independent of heap size, at the cost of some throughput and additional memory overhead. For a service where tail latency is the primary concern and heaps are large, ZGC is usually the better default.

[ RELATED_LOGS ]

TTFB: -- ms LOAD: -- s PAYLOAD: -- kb