Garbage collection pauses
A Socratic walk-through of garbage collection pauses — reasoned out one step at a time, not lectured.
The question we started with
THE QUESTION #Why does a program that frees its own memory sometimes freeze at the worst moment?
A service runs happily for hours, then stalls for two hundred milliseconds. No request was unusual; no lock was held. The runtime simply stopped every thread and did some housekeeping.
The folk explanation is that the program had made a lot of garbage and clearing it took a while. It is a natural inference and it gets the causality backwards. Ask what a collector actually does to a dead object, and the answer is: nothing. It never visits it, never reads it, never writes to it. Dead objects are cheap to dispose of precisely because they are unreachable — the collector could not find them if it tried. So if disposing of garbage costs nothing, what is the runtime doing for two hundred milliseconds?
Reasoning it through
REASONING #It is answering a much harder question: which objects are still alive. And "alive" is not a property stored anywhere. It means reachable by some chain of references from a root — a local variable, a register, a static field — so establishing it requires walking the object graph and marking whatever is found. The cost of that walk is proportional to the live data, not to the garbage.
That single inversion explains most of what is surprising. A program allocating enormous quantities of short-lived rubbish can be cheap to collect, because almost nothing survives the walk. A program holding a large in-memory cache is expensive even when it produces no garbage at all, because the same live graph must be traced again and again.
Now, why stop the world? Because the program is editing the graph while the collector is reading it. If a thread moves the only reference to an object from a place the collector has already visited to one it has already passed, the collector concludes the object is dead and reclaims memory still in use. Concurrent collectors solve this only by making the program help: every reference write, or every read, goes through a barrier that reports the change. The pause has not vanished; it has become a tax on ordinary work.
Which brings us to the worst moment, and this is the part worth dwelling on, because it is not bad luck. Consider a generational collector, betting that most objects die young. Under light load that bet pays: by the time the nursery fills, nearly everything in it is dead. Now double the traffic. The nursery fills twice as fast, so each object has had half as long to become garbage — and objects still in flight for a request are, by definition, live. Survival rates rise, more objects get promoted into the older region, the older region fills, and the expensive full collection is triggered by exactly the traffic peak you least wanted it during.
The same correlation shows up in concurrent collectors. They reclaim memory at some rate alongside the program; if the program allocates faster than that, the runtime either makes the allocating thread do collection work itself — so throughput sags rather than stopping — or falls back to a stop-the-world collection. Both failure modes are load-triggered.
One contributor is easy to miss: before any collection begins, every thread must reach a safepoint where its state is inspectable. A thread inside a long loop with no safepoint check, or one that has just been descheduled or is faulting a page back in, holds up all the others. Some pauses attributed to collection are almost entirely the wait to begin collecting.
The analogy
THE ANALOGY #Think of a librarian who must find out which books are still on loan. She does not care about the returned ones — they are on the shelves and cost her nothing. She starts from the borrower list and follows it: this reader has three books, one lent on to a colleague, and so on. The work is proportional to what is out, not to what came back. And she cannot do it while books are changing hands, so she closes the desk. The busiest afternoon is both when the trace takes longest and when closing the desk hurts most.
A librarian can ask borrowers where the books went, whereas a collector has no such channel — it can only follow references it can see, which is why concurrent collectors must have the program itself report every handover as it happens.
Clarifying the model
THE MODEL #Three refinements.
First, "pause" and "cost" are different quantities and the tuning literature conflates them. A collector that never pauses can still consume a large share of your CPU and slow every reference write with a barrier. Modern low-latency collectors target sub-millisecond pauses largely independent of heap size, by moving nearly all the work concurrently and paying for it in throughput and memory headroom.
Second, headroom is the cheapest lever there is, and the least intuitive. Work per collection is set by the live set, while memory recovered is the heap minus the live set. So the amortised cost per byte allocated falls as the heap grows relative to what is live — a memory-hungry program can often be made faster simply by giving it more memory than it needs. Trading space for time is the defining property of the family.
Third, and honestly: none of this makes the alternative obviously better. Manual freeing spreads the cost thin and predictable across the program, at the price of a class of bugs — use-after-free, double-free, leaks — that tracing eliminates by construction. Reference counting frees promptly without a global trace, but pays on every pointer assignment, cannot reclaim cycles unaided, and can stall when dropping the last reference to a large structure cascades. No arrangement is short-pausing, high-throughput, and memory-frugal at once, which is why runtimes ship several collectors and ask you to choose.
A picture of it
THE PICTURE #How to readEvery axis is oriented so that further from the centre is better, which makes the shapes comparable at a glance — and the point is that no shape encloses another. The stop-the-world curve reaches furthest on throughput and footprint and collapses toward the centre on pause length; the fully concurrent curve does the reverse, buying short pauses with CPU and with memory it keeps spare to stay ahead of the program. These positions are ordinal judgements about design trade-offs, not measurements of any particular runtime, so read the outline rather than a single spoke.
What became clearer
WHAT CLEARED #The pause is not the price of throwing things away — dead objects are never touched. It is the price of proving what is still alive, which means tracing a reference graph sized by the live data while the program that keeps editing it is held still. That reframes almost every symptom: a program producing mountains of garbage can be cheap to collect, one holding a big cache is expensive even when idle, and adding memory can make collection faster because it increases what is recovered without increasing what is traced. And the stall arrives at the worst moment because the worst moment causes it: heavy load means more objects live at any instant, higher survival, faster promotion, and a full collection triggered by the peak itself.
Where to go next
ONWARD #- How write barriers and coloured pointers let a collector move objects while the program is still reading them.
- Why compaction matters even when memory is plentiful, and how fragmentation causes allocation failures in a mostly empty heap.
Key terms
TERMS #| Term | What it means |
|---|---|
| Tracing collector | one that determines liveness by walking references from a set of roots, rather than by tracking counts. |
| Safepoint | a point in execution where a thread's references are inspectable; a pause cannot start until all threads reach one. |
| Write barrier | runtime code injected on reference stores so a concurrent collector learns about graph changes it would otherwise miss. |
Every term the collection defines is gathered in the glossary.