Go Garbage Collector

Go’s garbage collector is a concurrent, tri-color, mark-and-sweep collector that is non-generational and non-moving. “Concurrent” means it does the bulk of its work while the application keeps running, stopping the world only for two brief synchronization points. “Tri-color mark-and-sweep” means it discovers live objects by tracing pointers from roots, coloring objects as it goes, then reclaims everything it never reached. “Non-generational” means it scans the whole heap every cycle rather than focusing on young objects. “Non-moving” means a live object keeps its address forever, which is what makes Go’s pointers, interior pointers, and cheap cgo interop possible (golang/go src/runtime/mgc.go; Go GC guide). These four commitments still describe Go’s collector as of Go 1.26 (February 2026), even though that release replaced the marking algorithm with the Green Tea collector by default — Green Tea changes how the heap is traversed during marking, not the concurrent, non-moving, non-generational, tri-color character described here. This note is the entry point for Section 7 of the Go Internals MOC: it frames the collector and walks the full cycle, then hands off to siblings for the tri-color algorithm, the write barrier, the pacer, and the Go 1.26 redesign.

Mental Model

A garbage collector exists to answer one question continuously: which heap objects can the program still reach, and which can be reclaimed? Go answers it by tracing — starting from a set of roots (global variables, and every goroutine’s stack and registers) and following every pointer transitively. Anything reached is live; anything not reached is garbage.

The Go collector makes four design commitments that together define its character:

  • Concurrent. Tracing happens alongside the running program (the “mutator”). The program is paused only for two short stop-the-world (STW) windows per cycle — entering and leaving the mark phase — each on the order of tens to hundreds of microseconds. The Go team’s stated north star was sub-millisecond pauses, and since Go 1.8 that has been routinely achieved (Go ISMM keynote).
  • Non-moving. A live object is never relocated. Its address is stable for its entire lifetime. The cost is that Go cannot compact the heap to fight fragmentation; the benefit is that interior pointers (&s.field, &arr[3]) are valid and stable, and passing a Go pointer to C is sound. The Go team calls this “probably the most important thing that differentiates Go from other GCed languages.”
  • Non-generational. Most tracing collectors exploit the generational hypothesis — most objects die young — by collecting a small “young generation” frequently and the rest rarely. Go does not. Its rationale: in Go, short-lived objects overwhelmingly never reach the heap at all — escape analysis keeps them on the stack — so the heap is already biased toward longer-lived objects, and a young generation would buy little. Every GC cycle therefore traces the entire live heap.
  • Tri-color mark-and-sweep. The tracing algorithm itself. Objects are conceptually white (unproven), grey (proven live, not yet scanned), or black (proven live and fully scanned). See Tricolor Mark and Sweep for the algorithm in depth.
flowchart LR
    OFF["_GCoff<br/>(sweeping in background,<br/>write barrier OFF)"] -->|"trigger:<br/>heap reaches goal,<br/>or 2-min timer,<br/>or runtime.GC()"| STWa["STW: sweep termination<br/>+ enable write barrier"]
    STWa --> MARK["_GCmark<br/>(concurrent marking,<br/>write barrier ON,<br/>~25% CPU, assists active)"]
    MARK -->|"distributed termination:<br/>no grey objects left"| STWb["STW: mark termination<br/>(_GCmarktermination)"]
    STWb --> OFF

The figure is the GC cycle. The insight: a cycle is sweep → off → mark → mark-termination → back to sweep, and the program is only fully paused during the two short STW bands at the phase boundaries — everything in between runs concurrently with the application.

The GC Cycle, Phase by Phase

The runtime’s own description of the algorithm lives in the header comment of src/runtime/mgc.go, which calls it “a concurrent mark and sweep that uses a write barrier … non-generational and non-compacting.” The cycle has four numbered steps.

1. Sweep termination (STW)

The world is stopped — every P is brought to a GC safe-point. Any spans still unswept from the previous cycle are swept now. Normally there is nothing to do here: sweeping is concurrent and usually finishes well before the next cycle. Leftover unswept spans only exist if this cycle was forced earlier than expected (e.g. by runtime.GC() or the sysmon two-minute timer).

2. Mark phase (mostly concurrent)

This is the heart of the cycle.

  • 2a — Mark setup (STW). Still stopped, the runtime sets gcphase = _GCmark, enables the write barrier on every P, enables mutator assists, and enqueues the root marking jobs. The STW here exists for one reason: no object may be scanned until every P has its write barrier turned on, and that requires a global synchronization point. The runtime constant gcphase and setGCPhase enforce this — writeBarrier.enabled = gcphase == _GCmark || gcphase == _GCmarktermination.
  • 2b — Start the world. Marking now proceeds concurrently. Dedicated background mark worker goroutines do the tracing; user goroutines may be conscripted into GC assists (below). Crucially, from this moment every newly allocated object is immediately colored black — it is born live, so the collector need not trace into it this cycle.
  • 2c — Root marking. Workers scan all roots: every goroutine’s stack (briefly pausing each goroutine to scan it, then resuming — this is where the _Gscan bit from Goroutine Lifecycle and States is used), all global variables, and pointers in off-heap runtime structures.
  • 2d — Drain the grey set. Workers repeatedly take a grey object, scan it (find its pointers), shade every white object it points to grey, and color the scanned object black. This is the tri-color loop. See Tricolor Mark and Sweep.
  • 2e — Termination detection. Because mark work is spread across per-P local caches, “marking is done” is not a single flag. The runtime runs a distributed termination algorithm (gcMarkDone) to prove no P has any grey objects left and no root jobs remain. When that holds, the cycle moves to mark termination.

3. Mark termination (STW)

The world is stopped again. gcphase is set to _GCmarktermination, mark workers and assists are disabled, and per-P housekeeping (flushing mcaches, accounting) is performed. This STW is short because — by design, since Go 1.8 — it does not rescan stacks. Eliminating the stack rescan is what dropped Go’s pause times below a millisecond; the hybrid write barrier is what made the elimination sound.

4. Sweep phase (concurrent)

gcphase returns to _GCoff, the write barrier is disabled, and the world restarts. Every span is now flagged “needs sweeping.” Sweeping — walking spans and reclaiming the memory of unmarked (white) objects — happens concurrently: a background sweeper goroutine works span by span, and, additionally, any goroutine that needs a new span sweeps on demand first. From this point newly allocated objects are again white (the next cycle will have to prove them live). When enough new memory has been allocated, the trigger fires and step 1 begins again.

How the Cycle Stays Correct While the Program Runs

Two mechanisms make concurrency sound.

The write barrier. While the application runs during the mark phase, it mutates the pointer graph the collector is tracing. Without protection, the program could move a pointer from an unscanned object to an already-scanned (black) one, hiding a live object from the collector — which would then reclaim memory still in use. The write barrier is a small snippet of code the compiler inserts around every heap pointer write; while marking, it shades the relevant objects grey so nothing reachable is lost. Go uses a hybrid (Dijkstra + Yuasa) write barrier — see Write Barriers in Go and Tricolor Mark and Sweep.

GC assists. If the program allocates faster than the background mark workers can trace, the heap could outrun the collector and blow past its memory target. To prevent that, when a goroutine allocates during the mark phase it may be made to “pay” by doing a proportional amount of mark work itself before its allocation is granted — a GC assist. This is a back-pressure mechanism: a high allocation rate is throttled by the very goroutines causing it. See GC Assists and Mark Workers.

The background mark workers themselves target 25% of CPU during the mark phase (gcBackgroundUtilization = 0.25 in mgcpacer.go) — on an 8-core machine, roughly two cores’ worth of marking — with the fractional remainder and the assists making up the rest.

When Does a Cycle Start? — Triggers and the Pacer

A GC cycle begins for one of three reasons:

  1. Heap growth (the normal trigger). The pacer projects, from the last cycle, a heap goal: a target heap size at which the next cycle should finish. It then starts the cycle early enough that concurrent marking completes right around when the heap reaches the goal. The goal is controlled by GOGC (default 100): with GOGC=100, the heap is allowed to grow to roughly twice the live set before a cycle completes. The guide’s formula is Target heap memory = Live heap + (Live heap + GC roots) × GOGC / 100 (Go GC guide). Doubling GOGC roughly doubles heap footprint and halves GC CPU cost. See GC Pacing and GOGC.
  2. The soft memory limit. GOMEMLIMIT (Go 1.19+) caps total runtime memory; as the heap approaches it, the pacer runs cycles more aggressively, even ignoring GOGC. It is a soft limit — the runtime will exceed it rather than thrash. See GOMEMLIMIT and the Soft Memory Limit.
  3. The two-minute timer. If neither trigger fires for two minutes (forcegcperiod), sysmon forces a cycle so a low-allocation program still collects garbage, runs finalizers, and returns memory.

runtime.GC() also forces an immediate, blocking cycle, primarily for tests and benchmarks.

Code: Observing and Controlling the GC

import (
	"runtime"
	"runtime/debug"
)
 
func main() {
	debug.SetGCPercent(200)        // equivalent to GOGC=200: 3x live-set heap, ~half the GC CPU
	debug.SetMemoryLimit(4 << 30)  // GOMEMLIMIT = 4 GiB soft cap
	// debug.SetGCPercent(-1)      // GOGC=off: disable the heap-growth trigger entirely
 
	runtime.GC()                   // force a full, blocking GC cycle now
 
	var st debug.GCStats
	debug.ReadGCStats(&st)
	// st.NumGC, st.PauseTotal, st.PauseQuantiles ...
}
  • SetGCPercent is the runtime equivalent of the GOGC environment variable; it returns the previous value.
  • SetMemoryLimit sets GOMEMLIMIT; passing math.MaxInt64 means “no limit.”
  • SetGCPercent(-1) disables the heap-growth trigger but not the two-minute sysmon timer or explicit runtime.GC() — garbage is still eventually collected.
  • runtime.GC() blocks the caller until a cycle completes; never call it on a hot path.

The standard way to watch the GC is the gctrace debug knob:

GODEBUG=gctrace=1 ./server
# gc 1 @0.012s 3%: 0.018+1.3+0.005 ms clock, 0.14+0.30/1.2/0+0.044 ms cpu, 4->4->1 MB, 5 MB goal, 8 P
  • gc 1 — cycle number; @0.012s — time since start; 3% — cumulative fraction of CPU spent in GC.
  • 0.018+1.3+0.005 ms clock — wall-clock time in STW sweep-termination + concurrent mark + STW mark-termination. The two small numbers are the STW pauses; the middle is concurrent.
  • 4->4->1 MB — heap size at GC start → at GC endlive heap after.
  • 5 MB goal — the pacer’s heap goal for this cycle. 8 P — number of Ps.

Failure Modes and Common Misunderstandings

“The GC stops my program.” It mostly does not. The two STW windows per cycle are typically tens to hundreds of microseconds. What users feel as “the GC” is usually (a) GC assists stealing CPU from latency-sensitive goroutines under a high allocation rate, or (b) the 25% CPU the mark workers consume. The cure for both is to allocate less — see Escape Analysis — or to raise GOGC/GOMEMLIMIT to collect less often.

“More memory always helps.” Raising GOGC trades memory for CPU and can help latency by spacing cycles out. But it cannot reduce the per-cycle mark cost, which is proportional to the live heap, not the total heap. A program with a huge live set pays a large mark cost every cycle regardless of GOGC.

Expecting compaction. Go’s GC is non-moving; it never compacts. Long-running programs with churny allocation patterns can fragment the heap. The size-segregated allocator (see Go Memory Allocator) mitigates this, but Go offers no compacting collector option.

GOGC=off means no GC.” It disables only the heap-growth trigger. sysmon’s two-minute timer and runtime.GC() still run cycles. To truly suppress collection you must also avoid those — rarely advisable.

The gctrace allocation spiral. A program that allocates faster than it can mark sees gctrace lines bunch up — back-to-back cycles, rising assist time. That is the pacer and assists doing their job (preventing OOM), but it is a signal to reduce allocation, not to tune knobs.

Alternatives and When to Choose Them

Go’s design is a deliberate point in the GC trade-off space, chosen for low latency over peak throughput:

  • Java’s collectors (G1, ZGC, Shenandoah) are generational and moving/compacting. They achieve higher throughput on allocation-heavy workloads and avoid fragmentation, at the cost of moving objects (so no stable interior pointers, more complex FFI) and, historically, longer pauses — though ZGC and Shenandoah now also target sub-millisecond pauses with concurrent compaction.
  • Reference counting (Swift, CPython’s primary mechanism) reclaims memory deterministically and immediately but cannot collect cycles without an auxiliary tracer and adds per-pointer-write counter traffic.
  • Manual management / arenas / Rust-style ownership eliminate the collector entirely, trading runtime simplicity for compile-time or programmer burden.

Within Go, the only first-class tuning is GOGC and GOMEMLIMIT — there is intentionally no zoo of knobs. As of Go 1.26 (released February 2026) the marking algorithm itself was redesigned: the Green Tea Garbage Collector is now the default collector, after shipping as an experiment in Go 1.25 (Go 1.26 release notes; Green Tea GC blog). Green Tea improves marking locality and CPU scalability by scanning spans of small objects in memory order rather than chasing the pointer graph object-by-object, which slashes cache misses and unlocks SIMD. The release notes quote, with the caveat “benchmark results vary,” a 10–40% reduction in GC overhead for real-world programs that heavily use the collector, plus a further reduction “on the order of 10%” on newer amd64 CPUs (Intel Ice Lake or AMD Zen 4 and newer) where the collector “now leverages vector instructions for scanning small objects when possible” — the blog identifies the key vector primitive as AVX-512’s VGF2P8AFFINEQB (Go 1.26 release notes; Green Tea GC blog). Critically, Green Tea changes how marking traverses memory, not the concurrent, non-moving, non-generational, tri-color character described in this note — the tri-color invariant, the hybrid write barrier, the two STW windows, and the pacer all still hold. It can be disabled with GOEXPERIMENT=nogreenteagc, an opt-out the release notes say “is expected to be removed in Go 1.26’s successor,” i.e. Go 1.27.

Point-in-time

The 10–40% / ~10% figures are the Go team’s projections over a benchmark suite, explicitly caveated “benchmark results vary” in the Go 1.26 release notes — they are not a guaranteed number for any specific program. To measure the effect on a real workload, benchmark it built with and without GOEXPERIMENT=nogreenteagc. (As of 2026-05-28, against Go 1.26.0.)

Production Notes

The single most effective production lever is usually GOMEMLIMIT, added in Go 1.19. Setting it slightly below a container’s memory limit turns the GC into a backstop against out-of-memory kills: as the heap nears the limit the pacer collects harder, trading CPU to stay under the cap. The classic failure it prevents is a service that, with only GOGC=100, lets the heap grow to twice a transient live-set spike and gets OOM-killed; with GOMEMLIMIT the runtime instead spends CPU and survives.

gctrace=1 is the workhorse for GC diagnosis in the field — cheap, always available, one line per cycle. Correlating it with GODEBUG=schedtrace (see Scheduler Tracing and GODEBUG schedtrace) shows whether GC activity lines up with scheduler pressure. For deeper analysis, the execution tracer visualizes exact STW windows and assist time, and pprof’s heap profile attributes allocation to call sites — the starting point for reducing allocation, which is the real fix for GC pressure.

See Also