Garbage Collector¶
The design in one paragraph¶
Go's GC is a concurrent, non-moving, non-generational, tri-color
mark-sweep collector. Concurrent: marking runs alongside your
goroutines, with only two short stop-the-world (STW) pauses per cycle,
typically tens of microseconds. Non-moving: objects never change
address, which keeps unsafe and cgo interop simple but means the
heap can fragment. Non-generational: every cycle marks the whole
live heap, so the cost of a cycle is proportional to live memory,
and the number of cycles is proportional to allocation rate. That
one sentence is the whole tuning model:
GC CPU cost ≈ (number of cycles) × (live heap to mark) number of cycles ≈ allocation rate ÷ (heap headroom, set by GOGC / GOMEMLIMIT)
Measurements: Go 1.26.4, Apple M1 Pro, 8 Ps.
Tri-color marking and the write barrier¶
Every object is conceptually white (not yet seen), grey (seen, its pointers not yet scanned), or black (seen and scanned). Marking starts by greying the roots — goroutine stacks, globals, runtime structures — and repeatedly takes a grey object, greys everything it points to, and blackens it. When no grey objects remain, white objects are garbage.
Because your goroutines keep mutating pointers during marking, a goroutine could store a pointer to a white object into a black object and delete the only other path to it; the collector would never see it and would free a live object. The write barrier prevents that: while marking is active, every pointer write in Go code also shades the relevant objects grey. Go uses a hybrid (Yuasa deletion + Dijkstra insertion) barrier, which is what lets it avoid re-scanning stacks at the end of the cycle — the reason STW pauses are short and do not grow with the number of goroutines.
The write barrier is why pointer writes are slightly more expensive
during a GC cycle, and why data structures without pointers are
cheaper: the GC does not scan them at all (a []int64 of 1 GB costs
nothing to mark; a []*T of the same length costs a pointer chase per
element).
Phases of one cycle¶
| Phase | STW? | What happens |
|---|---|---|
| Sweep termination | Yes (µs) | Finish any leftover sweeping, enable the write barrier |
| Mark | No | Background mark workers use 25% of GOMAXPROCS; goroutines that allocate too fast are drafted into mark assists proportional to their allocation |
| Mark termination | Yes (µs) | Disable the write barrier, compute the next heap goal |
| Sweep | No | Free white objects lazily, span by span, as allocation needs space and in the background |
Mark assists are how GC shows up as latency rather than just CPU:
a request handler that allocates during a cycle is forced to do some
marking work before its allocation succeeds. In a CPU profile they
appear as runtime.gcAssistAlloc under your own functions.
Reading GODEBUG=gctrace=1¶
gc 6 @0.081s 5%: 0.020+5.0+0.030 ms clock, 0.16+0.015/8.8/20+0.24 ms cpu, 196->201->114 MB, 220 MB goal, 0 MB stacks, 0 MB globals, 8 P
| Field | Meaning |
|---|---|
gc 6 @0.081s 5% |
6th cycle, 81 ms after start, 5% of total CPU spent in GC so far |
0.020+5.0+0.030 ms clock |
Wall time: STW sweep termination + concurrent mark + STW mark termination. The two STW numbers are your pauses: 20 µs and 30 µs |
0.16+0.015/8.8/20+0.24 ms cpu |
CPU time: STW + (assist / background / idle mark workers) + STW. Large assist time means your goroutines are paying for GC inline |
196->201->114 MB |
Heap at cycle start → heap at mark end → live heap (marked reachable) |
220 MB goal |
Heap size at which the next cycle should finish: 114 MB live heap (plus roots) × 2 for GOGC=100 |
8 P |
GOMAXPROCS |
Watch two numbers in production: the live heap (the last of the three arrows — what your program actually needs) and the percentage (total GC CPU).
GOGC: trading memory for CPU¶
GOGC (default 100) sets the heap goal as a percentage of headroom
over the live heap and GC roots:
heap goal = live heap + (live heap + stacks + globals) × GOGC / 100
An experiment: a program keeps a 100 MB live heap of small
pointer-rich objects and allocates 4 GB of short-lived garbage
(64 M objects). Varying only GOGC:
| Setting | GC cycles | Total STW pause | GC CPU | Wall time | Peak RSS |
|---|---|---|---|---|---|
GOGC=50 |
89 | 3.3 ms | 6.0% | 1.65 s | 193 MB |
GOGC=100 (default) |
45 | 1.8 ms | 3.5% | 1.40 s | 249 MB |
GOGC=200 |
22 | 0.9 ms | 1.8% | 1.31 s | 378 MB |
GOGC=400 |
12 | 0.4 ms | 1.0% | 1.25 s | 633 MB |
GOGC=off |
1 | 0.03 ms | — | 1.48 s | 4,470 MB |
GOGC=off GOMEMLIMIT=300MiB |
30 | 1.7 ms | 2.5% | 1.36 s | 303 MB |
What the table teaches:
- Doubling
GOGCroughly halves the number of cycles and the GC CPU, and costs roughlylive heap × ΔGOGC/100more memory. - Pauses are tiny in every row: total STW over 45 cycles was under 2 ms. In modern Go, GC cost is throughput and assist latency, not pause time.
GOGC=offwas slower thanGOGC=400: never reusing memory means 4.4 GB of fresh pages, each one page-faulted in by the kernel (Page Faults). Reusing warm memory is itself a performance feature.- A memory limit with GC otherwise off achieved the lowest cycle
count that fits a budget — the idea behind
GOMEMLIMIT.
GOMEMLIMIT: a soft memory limit¶
GOMEMLIMIT (Go 1.19+; or debug.SetMemoryLimit) tells the runtime
the total memory it may use (heap, stacks, runtime structures — not
memory allocated by C code). As usage approaches the limit, the GC runs
more often regardless of GOGC. It replaces the old "memory ballast"
hack.
It is a soft limit, deliberately. Running the same program with a limit just above and then below its 100 MB live heap:
| Setting | GC cycles | GC CPU | Wall time |
|---|---|---|---|
GOGC=100 |
45 | 3.5% | 1.40 s |
GOGC=off GOMEMLIMIT=130MiB |
1,342 | 19.5% | 9.71 s |
GOGC=off GOMEMLIMIT=110MiB (below what the program needs) |
754 | 14.6% | 5.92 s |
With only 30 MB of headroom, the GC ran almost continuously and the program was 7x slower — a "GC death spiral". Below the live heap the limit cannot be met at all; the runtime's GC CPU limiter caps GC at roughly 50% of CPU over a time window and lets the heap exceed the limit rather than stop the program making progress. The memory limit protects against OOM kills from temporary spikes; it cannot make a program fit in less memory than it needs.
Practical settings for a service in a container with a memory limit:
GOMEMLIMIT≈ 85–95% of the container limit (leave room for non-Go memory: cgo, the binary, thread stacks, page cache charged to the cgroup), and leaveGOGCat its default. Normal operation is unchanged; near the limit, the GC works harder instead of the kernel OOM-killing the process.GOGC=off+GOMEMLIMITonly for batch jobs with a known, stable live heap and a dedicated memory budget.- Alert on GC CPU fraction. If it rises above ~25–50%, you are in or near the spiral: the fix is more memory or less live data, not tuning.
Green Tea: the Go 1.26 collector¶
Go 1.25 shipped an experimental marking algorithm called Green Tea,
and Go 1.26 made it the default. The classic marker traverses the
object graph object by object, jumping around memory with poor cache
locality; profiles of GC-heavy programs are dominated by cache misses
in scanobject. Green Tea instead queues spans (8 KB pages of
same-sized small objects) and scans the marked objects within a span
together, so memory is read in contiguous chunks and work is batched
per page. The same benchmark, built both ways:
| Build | GC cycles | GC CPU fraction | Wall time |
|---|---|---|---|
| Go 1.26 default (Green Tea) | 45 | 3.4–3.5% | 1.40–1.42 s |
GOEXPERIMENT=nogreenteagc |
44–45 | 6.2% | 1.36–1.38 s |
On this workload marking cost dropped by about 45%, while the total runtime was within noise (marking is only a few percent of it here). The benefit is largest for programs with large heaps of small objects on many cores. The opt-out exists for regressions; if you find one, it is worth a bug report with a benchmark.
Where heap memory goes, and RSS¶
The allocator is TCMalloc-like (Memory Allocator):
objects ≤ 32 KB are rounded up to one of ~68 size classes and
carved from spans; each P has an mcache of spans per size class, so
most allocations take no lock. Larger objects get their own spans.
Process RSS is larger than the live heap and does not drop the instant the heap shrinks:
- heap goal headroom (up to 2x live with
GOGC=100); - fragmentation within spans (a span stays as long as one object in it is live — the GC never compacts);
- goroutine stacks, runtime metadata, and the binary;
- freed memory is returned to the OS gradually by the scavenger
(
madvise), not immediately.
runtime/metrics (/memory/classes/..., /gc/heap/live:bytes,
/gc/heap/goal:bytes, /cpu/classes/gc/...) gives the exact
breakdown; runtime.ReadMemStats is the older, stop-the-world API.
Reducing GC cost, in order¶
- Allocate less in hot paths — the biggest lever, because it cuts the number of cycles (Escape Analysis, Reducing Allocations).
- Keep less live data, or keep it without pointers: a
[]Recordof values instead of[]*Record; offsets into a big[]byteinstead of millions of small strings; integer IDs instead of pointers. Pointer-free memory is skipped by the marker entirely. - Set
GOMEMLIMITto use the memory you are paying for. - Raise
GOGCif there is memory to spare and GC CPU is significant. - Only then consider off-heap techniques (manual arenas over
[]byte,mmap), which give up safety.
Related tools: runtime.AddCleanup (Go 1.24, the replacement for
finalizers), and the weak package (Go 1.24) for caches that should
not keep values alive on their own.
Common pitfall¶
Reading high RSS as a memory leak. A service's container shows 1.8 GB
RSS while the heap profile shows 400 MB in use, and someone starts
hunting for a leak. With GOGC=100 the heap is allowed to grow to
about 2x the live heap between cycles, a recent traffic spike left
partially-used spans behind, and the scavenger returns free pages to
the OS over minutes, not instantly. Before calling it a leak, check
the live heap trend (/gc/heap/live:bytes, or the last number in
gctrace's A->B->C): a leak is a live heap that grows without
bound across many GC cycles. A stable live heap with a large RSS is
headroom and fragmentation — tune GOMEMLIMIT, don't hunt.
Review questions¶
- Why is the cost of one GC cycle proportional to live heap, while the number of cycles is proportional to allocation rate?
- What would go wrong without a write barrier during concurrent marking?
- In
0.020+5.0+0.030 ms clock, which numbers are pauses, and which phase runs concurrently? - Why was
GOGC=offslower thanGOGC=400in the experiment? - Why does a
GOMEMLIMITjust above the live heap make a program several times slower, and what does the GC CPU limiter do about it? - Why is a
[]int64of 1 GB cheaper for the GC than a[]*int64of the same length?