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.

graph LR R["roots:<br/>stacks, globals"] --> A["A (black)"] A --> B["B (grey)"] B --> C["C (white → will be greyed)"] D["D (white, unreachable)"] style A fill:#333,color:#fff style B fill:#999 style D fill:#fff,stroke-dasharray: 5 5

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 GOGC roughly halves the number of cycles and the GC CPU, and costs roughly live heap × ΔGOGC/100 more 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=off was slower than GOGC=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 leave GOGC at its default. Normal operation is unchanged; near the limit, the GC works harder instead of the kernel OOM-killing the process.
  • GOGC=off + GOMEMLIMIT only 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

  1. Allocate less in hot paths — the biggest lever, because it cuts the number of cycles (Escape Analysis, Reducing Allocations).
  2. Keep less live data, or keep it without pointers: a []Record of values instead of []*Record; offsets into a big []byte instead of millions of small strings; integer IDs instead of pointers. Pointer-free memory is skipped by the marker entirely.
  3. Set GOMEMLIMIT to use the memory you are paying for.
  4. Raise GOGC if there is memory to spare and GC CPU is significant.
  5. 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

  1. Why is the cost of one GC cycle proportional to live heap, while the number of cycles is proportional to allocation rate?
  2. What would go wrong without a write barrier during concurrent marking?
  3. In 0.020+5.0+0.030 ms clock, which numbers are pauses, and which phase runs concurrently?
  4. Why was GOGC=off slower than GOGC=400 in the experiment?
  5. Why does a GOMEMLIMIT just above the live heap make a program several times slower, and what does the GC CPU limiter do about it?
  6. Why is a []int64 of 1 GB cheaper for the GC than a []*int64 of the same length?