The idea: a collector that runs next to your program
Go frees memory with a tracing collector: an object is kept if a chain of pointers leads to it from a root (a goroutine's stack, a global), and freed otherwise. Go's collector is mark-sweep and non-moving: it never copies objects, and it has no generations. What makes it special is that it is concurrent: your goroutines (the mutator) keep running while the collector marks. To keep track of its progress while the heap changes under it, the collector paints every object one of three colours.
White, grey and black
- White: not reached yet. At the end of marking, white means garbage.
- Grey: reached, but its pointer fields have not been scanned. The grey objects are the collector's work list.
- Black: reached and scanned. The collector never looks at a black object again in this cycle.
Marking starts by shading the roots' targets grey. Then a GC worker takes a grey object, shades its white children grey and turns it black, until no grey object is left. (Demo: one GC cycle, the mutator idle.) A, D and F are grey after the root scan; scanning them reaches B, E and C. G and H are never reached, stay white and are swept. The rule that keeps this correct is the tri-colour invariant: no black object may point to a white one, or that white object could be missed.
The phases of one cycle
A cycle has two very short stop-the-world (STW) pauses and two long concurrent phases. The pauses are usually well under a millisecond, whatever the heap size, because they do not mark anything.
| Phase | World | What happens |
|---|---|---|
| Sweep termination | stopped | finish the last sweep, turn the write barrier on |
| Mark | running | scan stacks and globals, drain the grey work list; dedicated GC workers use 25% of GOMAXPROCS, and goroutines that allocate fast help (mark assist) |
| Mark termination | stopped | check no grey work is left, turn the write barrier off |
| Sweep | running | free white objects, lazily, span by span, as goroutines allocate |
The write barrier and the lost object
Because the mutator runs during marking, it can break the invariant with two ordinary pointer writes. (Demo: no write barrier, a live object is freed.) After A is scanned (black), g1 runs A.next2 = E: black A now points to white E. Then D.next = nil: grey D no longer leads to E. The collector will never rescan A, and nothing grey leads to E, so E stays white and is swept, while x → A → E still reaches it. A now points to freed memory: a crash or silent corruption.
A write barrier is a few instructions the compiler adds to every pointer store into the heap, active only while the GC is marking. Go uses a hybrid barrier (since Go 1.8): it shades the old target of the slot being overwritten (a Yuasa deletion barrier) and the new target (a Dijkstra insertion barrier). (Demo: the mutator moves a pointer, the write barrier saves E.) The store into A shades E grey, so E is scanned and survives. The hybrid barrier also means goroutine stacks never need a second scan at mark termination, which is what keeps that pause short.
Allocating during marking
(Demo: allocating during mark, the new object is black.) An object allocated while marking is running is born black: the collector does not have to scan it, and it cannot be swept by mistake. The price is floating garbage: T1 dies as soon as g1 returns, but it survives this cycle (live heap 7 instead of 6) and is only freed by the next one.
GOGC and the pacer
Go does not wait for the heap to fill up. After each cycle the pacer sets a heap goal:
goal = live heap after the last mark × (1 + GOGC/100)
With the default GOGC=100 the heap may grow to twice the live heap before the next cycle. (Demo: GOGC=100, allocate until the heap goal starts GC.) The live heap is 6 MB, so the goal is 12 MB: the fourth allocation reaches it and a cycle starts, freeing G, H and the four temporary objects. (Demo: GOGC=50, GC runs twice as often.) The goal is 9 MB, so the same four allocations run two cycles.
| GOGC | Goal for a 6 MB live heap | GC cycles | Memory |
|---|---|---|---|
| 50 | 9 MB | more often | less |
| 100 (default) | 12 MB | balanced | up to 2× live |
| 200 | 18 MB | less often | up to 3× live |
| off | none | never (unless GOMEMLIMIT) | grows without bound |
The real pacer starts marking a little before the goal, so that marking finishes just as the heap reaches it; it also counts stacks and globals in the goal. Since Go 1.19, GOMEMLIMIT adds a soft memory limit: near it, the collector runs more often whatever GOGC says.
Go versus the JVM collectors
| Question | Go | JVM (G1, ZGC) |
|---|---|---|
| Generations | none: one heap | young and old (ZGC generational since JDK 21) |
| Moves objects | never (no compaction) | yes: copy and compact regions |
| Barrier | write barrier during marking | G1 write barriers; ZGC load barriers |
| Main knob | GOGC, GOMEMLIMIT | heap size, pause-time goal, many flags |
Go keeps fewer short-lived objects on the heap in the first place: escape analysis puts many of them on the goroutine stack. See also Java garbage collectors for generations, regions and coloured pointers, and Mark-sweep vs reference counting for the stop-the-world mark-sweep that Go's collector makes concurrent.
What the page leaves out
One goroutine and one GC worker; real marking runs on several CPUs at once with per-P work buffers. Not shown: stack scanning one goroutine at a time, mark assists, the exact pacer formula and the early trigger, lazy sweeping by span and size class, the "shade the new pointer only while the stack is grey" detail of the hybrid barrier, finalizers, weak pointers (Go 1.24 weak package), and how the memory goes back to the operating system (the scavenger).