Garbage Collection: Pause, Throughput, Footprint — Pick Two
A collector trades pause time against throughput against memory footprint, and no tuning flag escapes the triangle. The lever you actually control is not the collector — it is how much garbage your code produces per request.
Frame the diagnosis
Performance work starts from a symptom and a signal — never from a resource dashboard.
The triangle every collector negotiates
A garbage collector must find unreachable objects and reclaim them. Every design decision about *how* trades three quantities against each other: how long the application is stopped (pause time), how much total CPU is spent collecting rather than serving (throughput), and how much memory the heap occupies above the live set (footprint).
The trades are concrete. Collecting concurrently with the application shrinks pauses but costs throughput, because the collector must coordinate with a mutating heap and do extra bookkeeping on writes. Collecting less often improves throughput but raises footprint and makes each collection longer. Giving the heap more headroom reduces collection frequency and raises memory cost. There is no configuration that improves all three, which is why "GC tuning" that produces a dramatic win in one dimension usually paid for it in another that nobody measured.
Not every runtime plays the same game. CPython uses reference counting for the common case with a cycle collector on top, so it has no large stop-the-world pause but pays a continuous per-operation cost and struggles with reference cycles — see CPython Performance: The Interpreter Tax and the GIL. C++ has no collector at all, moving the cost to deterministic destruction and allocator behaviour (C++ Memory Performance: Allocation, Copies and Locality). Claims about "GC pauses" are claims about a particular runtime, and should be labelled as such.
| If you want… | Typical mechanism | What it costs | Watch this signal |
|---|---|---|---|
| Shorter pauses | Concurrent / incremental collection, smaller regions | Total CPU spent on GC rises; write barriers slow the application | GC CPU %, throughput at fixed load |
| Higher throughput | Collect less often, larger young generation | Longer individual pauses; higher footprint | p99 latency, pause duration histogram |
| Smaller footprint | Collect more often, tighter heap target | More GC CPU; more frequent pauses | GC frequency, allocation rate |
| All three | Allocate less per request | Engineering effort in application code | Allocation rate (bytes/s) — the lever you own |
Allocation pressure is the lever you actually control
Collector flags get the attention, but the input to the whole system is allocation rate. A service allocating 50 MB/s and one allocating 500 MB/s on the same collector and heap configuration have completely different GC behaviour: the second collects ten times as often, spends roughly ten times the CPU on collection, and produces ten times as many opportunities to pause during a request.
This makes allocation profiling the highest-leverage measurement here, not GC log analysis. GC logs tell you the collector is working hard; an allocation profile tells you *which code path* is making it work hard, which is the actionable version (Allocation Rate Is a Cost Even Without a Leak). The usual culprits are boring: string concatenation in loops, JSON serialization of large payloads, defensive copying of collections, boxing in hot paths, and per-request object graphs that could be reused.
The connection to Payload Size: 20KB, 200KB, 5MB is worth naming: an endpoint returning a 5 MB response allocates that payload at least once and often several times through serialization. Trimming the response is simultaneously a network fix, a serialization-CPU fix and a GC-pressure fix. The same change shows up in three different dashboards, which is a good sign you have found a real cause rather than a symptom.
| Signal | Value | What it tells you | Verdict |
|---|---|---|---|
| allocation rate | 480 MB/s → 60 MB/s | The input to everything else. Cutting it is what actually changed the picture. | smoking gun |
| GC frequency | 14/s → 2/s | Fell roughly in proportion to allocation rate, as expected. | normal |
| GC CPU | 22% → 4% | A fifth of the machine was being spent collecting garbage the code did not need to create. | normal |
| p50 latency | 38 ms → 36 ms | Barely moved — which is why nobody noticed the problem from the average. | normal |
| p99 latency | 410 ms → 74 ms | The entire benefit lived in the tail, where the pauses were. | smoking gun |
| heap live set | 1.2 GB → 1.2 GB | Unchanged — this was garbage churn, not a leak. Different problem, different fix (Memory Leaks: Growth That Does Not Come Back). | normal |
Finding the pause in a trace
GC pauses are invisible to application instrumentation by construction: the application is stopped, so it cannot record anything. What you see in a trace is a *gap* — a parent span whose duration exceeds the sum of its children with nothing accounting for the difference. That unexplained gap is the fingerprint, and it is the reason a waterfall is more useful here than a metric.
Confirming it takes one correlation: overlay GC pause events on the latency timeline and check whether the slow requests coincide with collections. Coincidence is not proof — a periodic batch job could produce the same rhythm — but combined with an allocation profile pointing at a hot path, it is usually enough to act on. Keep Correlation Is Not the Root Cause in mind: the honest statement is "requests overlapping a collection pause are slow", and the test is whether reducing allocation reduces the spikes.
One practical note on tail sampling: if your tracing samples traces at a fixed low rate, you will rarely capture the affected requests, because GC-affected requests are by definition a small fraction. Tail-based sampling that keeps slow traces is what makes this diagnosable at all (Sampling Without Throwing Away the Evidence).
Key points
- Pause time, throughput and footprint form a triangle; every collector setting trades one against the others, and no flag wins all three.
- Allocation rate is the input you control — halving it roughly halves collection frequency and GC CPU on the same configuration.
- GC pauses live entirely in the tail: p50 barely moves while p99 moves a lot, so averages hide the problem completely.
- In a trace, a GC pause appears as an unexplained gap where children do not sum to the parent, because the process was stopped.
- Claims about GC behaviour are runtime-specific — reference-counted CPython and unmanaged C++ do not have the same failure mode at all.
Progressive depth
Overview
Managed runtimes reclaim memory automatically. Doing so requires periodically finding what is unreachable, and that work has to happen somewhere — sometimes while your request is waiting.
Practical
Watch allocation rate, GC frequency, pause duration histogram and GC CPU. Pauses show up as p99 spikes with a flat p50, and as unexplained gaps in traces. Reduce allocation in the hot path before touching any flag.
Advanced
The pause/throughput/footprint triangle governs every collector choice. Generational collection exploits the observation that most objects die young, so a cheap young-generation pass reclaims most garbage; objects surviving several passes get promoted, and promotion rate is a better early-warning signal than heap size. Concurrent collectors trade write-barrier overhead on every mutation for shorter stop-the-world phases.
Internals
Tracing collectors mark from a root set (stacks, globals, registers) and must observe a consistent heap, which is why some phase stops the application — reaching a safepoint where every thread can be inspected is itself a source of pause time, and a thread in a long uninterruptible loop delays everyone. Compaction moves surviving objects to eliminate fragmentation, which requires updating every reference and is where large pauses concentrate. This connects to Reading Memory: RSS, Heap, Working Set and the Number on Your Dashboard and to OS-level paging: a heap larger than physical memory turns collection into a paging storm, because marking touches pages across the whole heap in an access pattern with no locality.
Allocation Pressure and Pauses
Change an input and watch which number moves — and which one does not.
Modelled on a generational collector where collection frequency follows allocation rate and pause length grows with heap size. Real collectors differ substantially — concurrent and region-based collectors trade throughput for shorter pauses, and some do most of the work off the application thread. The relationship, not the numbers, is the lesson.
Comfortable. Collection is not your problem at this allocation rate, and tuning the collector would be premature.
Follow the diagnosis
The causal chain, hop by hop — and the readings that invite the wrong conclusion.
- 1Request handler → allocator: response serialization allocates several megabytes per request, driving allocation rate to 480 MB/s.
- 2Allocator → collector: the young generation fills roughly 14 times per second, so collections run at that frequency.
- 3Collector → process: each collection stops the application briefly; at 14/s a meaningful fraction of in-flight requests overlaps a pause.
- 4Pause → trace: affected requests show a parent span 180ms longer than the sum of its children, with no span covering the gap.
- 5Trace → user: p50 is unaffected because most requests miss the pauses, while p99 reaches 410ms because the unlucky ones absorb them.
- • "Average latency is fine, so the runtime is fine" — GC pauses are a tail phenomenon and the mean is designed to hide them.
- • "Memory is growing, this is a leak" — check the live set *after* collection; high churn with a flat live set is allocation pressure, not a leak.
- • "Tune the collector flags" — flags redistribute cost around the triangle; only lower allocation reduces total work.
- • "The gap in the trace is a missing instrumentation span" — sometimes, but an unexplained gap correlating with collection events is the classic pause fingerprint.
- • "We switched to a low-pause collector, problem solved" — shorter pauses usually mean more GC CPU, which shows up as reduced throughput at the same load.
Measure, fix, validate
An optimization is not finished until the metric that motivated it has moved.
- • Allocation rate in bytes/s, and an allocation profile attributing it to call sites — this is the actionable measurement, not the GC log.
- • GC pause duration as a histogram (not an average) plus collection frequency, both correlated against the latency timeline.
- • GC CPU as a percentage of total process CPU, which quantifies what collection is costing you in capacity terms.
- • Heap live set after collection, to separate garbage churn (live set flat) from a genuine leak (live set climbing — see [[memory-leaks]]).
- • p50 against p99 for the affected endpoint, since the entire effect is in the difference between them.
- • Reduce allocation in the hottest paths identified by an allocation profile — smaller payloads, reused buffers, streaming instead of materializing, fewer defensive copies.
- • Trim response sizes and avoid serializing data nobody reads, which cuts allocation, CPU and network in one change ([[payload-size]]).
- • Give the heap headroom if footprint is cheap for you: fewer collections at the cost of memory is often the best available trade for a latency-sensitive service.
- • Consider a lower-pause collector configuration only after allocation work is done, and measure throughput at fixed load to see what it cost.
- • Enable tail-based trace sampling so the affected requests are actually captured for diagnosis.
- • Allocation rate falls and GC frequency falls roughly in proportion — if frequency does not follow, the allocation you removed was not the dominant source.
- • p99 latency falls materially while p50 is roughly unchanged; that asymmetry is the signature of a genuine GC fix.
- • GC CPU percentage drops, confirming reclaimed capacity rather than merely redistributed pauses.
- • Throughput at fixed load holds or improves — a collector change that reduced pauses but cut throughput has moved the cost, not removed it.
- • Larger heaps reduce collection frequency and increase memory cost per instance, which changes instance sizing and therefore bill.
- • Low-pause collectors typically cost throughput via write barriers and concurrent work — you serve fewer requests per core for smoother tails.
- • Object reuse and buffer pooling reduce allocation and introduce lifetime bugs, including use-after-return and cross-request data leakage, which is a correctness and security risk.
- • Tail-based sampling costs more at the collector, since traces must be buffered until the decision can be made.
- • Allocation rate tracked as a first-class metric with an alert on sustained increase, since allocation regressions arrive with ordinary feature work.
- • A p99 (not average) latency SLO on the affected endpoints, which is the only threshold that can see this class of problem.
- • A load test in CI that asserts allocation per request stays under a budget, so a new serialization path cannot silently reintroduce the pressure.
- • GC pause histograms retained across deploys, so a collector or runtime version change is visible as a shape change rather than discovered in an incident.
Accuracy
Performance numbers are conditional. These are the conditions.
- RUNTIME-SPECIFICPause behaviour, generational layout and tuning knobs differ substantially between the JVM, Go, .NET and V8, and change between major versions. Treat the triangle as the transferable idea and verify specifics against your runtime's documentation.
- ILLUSTRATIVEThe 480 MB/s → 60 MB/s figures and the 410ms p99 are constructed to show the shape of the improvement. The proportional relationship between allocation rate and collection frequency is general; the magnitudes are not.
Misconceptions
Apply it
Where the depth lives
A heap that exceeds physical memory turns every marking pass into a storm of page faults, which is why GC and swap interact so badly.