Go’s Garbage Collector: Tricolor Marks, Write Barriers, and the Green Tea Era

Most Go developers can recite one fact about the garbage collector: it exists, and it mostly works. That is usually enough — until a service starts burning 30% of its CPU in runtime.gcBgMarkWorker, or a container gets OOM-killed because the heap ballooned to eight times its live set. At that point you need an actual model of how the collector behaves: when it runs, what it costs, and which knobs change the trade-off.

This post builds that model: how allocation reaches the heap, how the concurrent mark-sweep cycle works (tricolor abstraction, write barriers, mark assists), what changed with the Green Tea collector that became the default in Go 1.26, and how to reason about the two tuning knobs that matter — GOGC and GOMEMLIMIT — plus the diagnostics that tell you which one you need.

First: not everything is the GC’s problem

The Go compiler performs escape analysis at compile time. Values whose lifetimes can be proven limited to the current stack frame live on the goroutine’s stack and cost the collector nothing — their space is reclaimed by popping the frame. Only values the compiler cannot prove safe “escape to the heap,” and only those participate in the object graph the GC walks. A slice whose length depends on user input, a value stored in an interface, a pointer retained by a struct that outlives the function — those escape. You can inspect the compiler’s decisions with go build -gcflags=-m, which prints every escape decision and its reason.

This matters for tuning because GC cost scales with the live heap and its pointer structure, not with your request rate. Two services handling identical traffic can have wildly different GC profiles depending on how much of their working set escapes. Reducing allocations is not a stylistic preference; it directly shrinks the work every mark cycle must do.

The mark-sweep cycle

The standard toolchain’s collector is a tracing garbage collector: it determines liveness by following pointers transitively from known-live starting points — goroutine stacks and global variables — rather than by counting references. It is also specifically a mark-sweep design. During the mark phase it walks the object graph, marking everything reachable; once tracing completes, it sweeps the heap, returning unmarked spans to the allocator. Sweeping is fast enough relative to marking that the guide’s cost model treats it as negligible.

Two properties of this design surprise people coming from other runtimes. First, Go’s GC is non-moving: it never relocates objects, so your pointers stay valid, but the heap can fragment. Second, it is not generational: no nursery, no promotion, no assumption that most objects die young — every cycle scans the whole live heap regardless of object age. (Green Tea attacks the cost of that assumption from a different angle, below.)

The cycle runs concurrently with your program, which raises the classic tracing-GC problem: mutator threads keep mutating the graph while the collector is reading it. Go’s answer is the tricolor abstraction: objects are white (unvisited), grey (found but not yet scanned), or black (fully scanned), and the invariant is that a black object never points directly to a white one. Your goroutines break that invariant every time they store a pointer into an already-scanned black object, so the compiler instruments those stores with a write barrier — since Go 1.8, a hybrid barrier that shades the old destination value too — letting the collector catch pointers that moved during marking.

The runtime caps GC-dedicated CPU at roughly 25% of the machine while marking, using dedicated background workers. But if your goroutines allocate faster than the workers can keep up, fairness kicks in from the other side: allocating goroutines get drafted into mark assists and pay scan work proportional to the memory they allocate. This is why an allocation-heavy hot path shows up as latency spikes rather than just CPU burn — the collector quietly taxes your request goroutines. Two short stop-the-world pauses remain, at sweep termination and mark termination; they are microseconds in a healthy service, but their histogram is worth watching.

Green Tea: the default collector since Go 1.26

The classic implementation maintained a per-object work list: when a grey object was scanned, its pointers were pushed onto a global list, producing pointer-chasing access patterns with poor cache locality — expensive when modern services allocate many small, short-lived objects (decoded JSON, request structs, error chains). The Green Tea collector, introduced as an experiment in Go 1.25 and enabled by default in Go 1.26, reorganizes marking around memory spans instead of individual objects. It scans small objects in batches, processing a whole span’s worth of work at a time so that scanning benefits from spatial locality and plays better with CPU caches and parallelism.

The release notes frame expectations honestly: results vary, but programs that heavily use the GC should see somewhere between a 10–40% reduction in GC overhead, with further improvements (on the order of 10%) on newer amd64 CPUs — Intel Ice Lake and AMD Zen 4 onward — where the collector uses vector instructions for scanning small objects. The fundamental character of the collector is unchanged: still concurrent mark-sweep, still non-moving, still not generational, still tuned by the same two knobs described below — only the efficiency of marking changed. If you need to compare, the old implementation remains available at build time via GOEXPERIMENT=nogreenteagc, though that opt-out is expected to be removed in Go 1.27 — so treat any fork in behavior as a bug to report, not a configuration to depend on.

GOGC and GOMEMLIMIT

The collector decides when to start a cycle using a heap target: once the heap grows to live heap plus GOGC/100 × (live heap + GC roots), a cycle begins. With the default GOGC=100, the target is roughly double the live set. The official GC guide works through the arithmetic in detail, but the steady-state intuition is worth memorizing: doubling GOGC doubles the memory overhead (more garbage accumulates between cycles) and roughly halves GC CPU cost (cycles happen half as often). It is a straight memory-for-CPU exchange, and the live heap itself is not yours to tune — it is a property of your program.

GOGC alone has a failure mode: if the live heap suddenly spikes — a large batch import, a big query result — the collector lets the heap grow to twice that before reacting, and in a memory-limited container that is an OOM kill. GOMEMLIMIT, added in Go 1.19, is a soft cap on the total memory the runtime manages: as the heap approaches the limit, the collector runs more frequently to stay under it. It composes with GOGC — a common production setup is a generous GOGC for normal operation plus GOMEMLIMIT as the guardrail. Two caveats. The limit is soft: the runtime will exceed it rather than deadlock, so leave headroom for non-heap memory (stacks, runtime structures) inside your container limit. And if your live set genuinely approaches the limit, the GC burns CPU trying to avoid exceeding it — the runtime caps that effort at about 50% of a CPU and lets the process exceed the limit rather than spin forever. Set the limit with honest headroom or it becomes an expensive way to lose.

package main

import (
	"fmt"
	"runtime/debug"
)

func main() {
	debug.SetGCPercent(200)       // twice the default headroom over live heap
	debug.SetMemoryLimit(4 << 30) // soft cap: 4 GiB, leave headroom to the container limit
	fmt.Println("GC tuned: GOGC=200, memory limit 4GiB")
}

Both values can also be set via the GOGC and GOMEMLIMIT environment variables, which is usually the right move in Kubernetes — the process does not own its policy, the deployment does.

Diagnosing before tuning

The fastest first look is GODEBUG=gctrace=1, which prints one line per cycle to stderr:

GODEBUG=gctrace=1 ./server
gc 12 @58.214s 1%: 0.089+4.2+0.041 ms clock, 0.71+1.1/8.0/0.23+0.33 ms cpu,
48->61->24 MB, 61 MB goal, 2 MB stacks, 0 MB globals, 8 P

Read the line as: cycle 12, at 58 seconds into the run, consuming 1% of CPU so far; the phases (sweep termination, concurrent mark and scan, mark termination) took 4.2 ms of clock time; the heap went from 48 MB, through a 61 MB goal, down to 24 MB live after the cycle. The number that deserves daily attention is the live set — the last value before the goal. If it trends upward and never comes back down, you have a leak or a growing working set, and no GOGC value will fix it. If the live set is stable but GC CPU is high, the exchange-rate knobs apply. For continuous collection rather than stderr spam, the runtime/metrics package exposes the same data programmatically:

package main

import (
	"fmt"
	"runtime/metrics"
)

func main() {
	samples := []metrics.Sample{
		{Name: "/gc/heap/live:bytes"},
		{Name: "/gc/cycles/total:gc-cycles"},
		{Name: "/gc/pauses:seconds"},
	}
	metrics.Read(samples)

	for _, s := range samples {
		fmt.Printf("%s = %v\n", s.Name, s.Value)
	}
}

Export these as Prometheus metrics — live heap size, GC cycle count and pause distribution — alongside per-endpoint latency. GC pauses are one of the few remaining sources of tail latency in a well-tuned Go service, and the /gc/pauses:seconds histogram tells you whether your p99 includes a mark termination or whether the problem is elsewhere.

A note on finalizers, cleanups, and weak pointers

Sometimes an object holds a non-memory resource — a file descriptor, a socket — and you want the runtime’s help reclaiming it. The old tool, runtime.SetFinalizer, was a trap: the finalizer receives the object itself, resurrecting it for another cycle and delaying reclamation, and a reachable object’s finalizer never runs at all. Go 1.24 replaced it with runtime.AddCleanup, which takes a separate cleanup function and argument — deliberately making it impossible to resurrect the object by capturing it — and added a real weak pointer type for canonicalization caches. Both remain sharp-edged tools with nondeterministic timing, and neither is a substitute for explicit resource management. Use them as a backstop, not as the design.

Wrapping up

The model in one breath: escaping values land on a heap that a concurrent, non-moving, non-generational mark-sweep collector walks from stack and global roots, using write barriers to survive concurrent mutation; Green Tea (default since Go 1.26) made that walk 10–40% cheaper for allocation-heavy programs by scanning spans instead of chasing pointers; GOGC sets the memory-for-CPU exchange rate and GOMEMLIMIT caps the downside. Diagnose with gctrace and runtime/metrics before touching anything, and remember that the cheapest GC cycle is the one your escape analysis prevented.

Leave a Reply

Your email address will not be published. Required fields are marked *