skip to content

Tri-Color Concurrent Marking

How the collector finds live objects while your program keeps running: everything starts white, roots go grey, and whatever is still white when marking ends is garbage. Interviewers want the invariant stated correctly, plus why Go's collector neither moves objects nor uses generations.

part ofGo (Golang)overview, primer and where to startread it →
on this pageshow

questions

4

Does Go's garbage collector move objects in memory or use generations?

level: juniorimportance: must knowfreq 55%

answer

  1. three colours, but no age groups
  2. the address never changes
  3. size classes instead of compaction
  4. every cycle traces the whole live set
  5. cycles collect fine, nothing is reference-counted

basics

~20 s

Go's garbage collector is a concurrent tri-colour mark-and-sweep collector that is neither generational nor moving. A heap object keeps the same address for its whole life, and there are no young or old generations to promote between.

solid answer

~50 s

No on both counts. Go runs a concurrent tri-colour mark-and-sweep collector: it starts from roots (package-level variables and every goroutine's stack), follows real pointers to mark everything reachable while your goroutines keep running, and then sweeps what it never reached. It is **non-generational** — there is no nursery and no promotion, so every cycle traces the whole live set — and **non-moving**, so an allocated object is never copied or compacted and its address stays valid for its lifetime. Because nothing is compacted, the allocator fights fragmentation with size-class spans instead: a span is carved into equal-sized slots and a freed slot is reused by the next object of that size. Go can afford tracing the whole heap because escape analysis keeps many short-lived values on the stack and structs are embedded by value, so a Go heap has far fewer pointers per byte than a reference-heavy runtime.

code

go · 6 lines
go
type node struct{ next *node }

n := new(node)
before := fmt.Sprintf("%p", n)
runtime.GC()
fmt.Println(before == fmt.Sprintf("%p", n)) // true: nothing is moved or compacted

go deeper

for a junior

Be ready to say the two words that matter: non-generational and non-moving, plus concurrent mark-and-sweep. Knowing that an address stays put and that reference cycles are collected anyway is enough at this level.

for a middle

Explain the mechanics: roots are globals and goroutine stacks, marking follows real pointers using the type's pointer map, and fragmentation is handled by size-class spans because nothing is compacted.

for a senior

Show where the design bites in production: cost scales with the live heap, so a large pointer-dense live set is expensive every cycle, and the fix is fewer allocations and lower pointer density rather than a collector knob.

for a principal

Own the consequence for architecture: a service that must hold a big graph in memory pays for it on every cycle, so the real choices are sharding the data, moving it out of the Go heap, or accepting the CPU share — not tuning your way out of a non-generational design.

## The short version Go's collector is **concurrent, tri-colour, mark-and-sweep, non-generational and non-moving**. Green Tea, the default marking algorithm since Go 1.26, changed *how* the marker walks memory, not any of those five properties. ## What "tri-colour" describes During a collection every heap object is conceptually one of three colours: - **white** — not yet found; if it is still white at the end of marking, it is garbage; - **grey** — found, but its own pointer fields have not been scanned yet; - **black** — found *and* fully scanned. Marking greys the roots — package-level variables and each goroutine's stack — then repeatedly takes a grey object, scans its pointer fields, greys any white object it points at, and blackens it. When nothing grey is left, every object still white is unreachable. The colours are bookkeeping, not something you can see from Go code: the runtime keeps mark bits per span and a work queue of pending objects. Two things follow immediately. First, **reference cycles are not a problem**: a ring of objects that point at each other but that nothing reachable points into is simply never greyed, so it is collected like any other garbage. Go does not reference-count. Second, the marker only follows *real Go pointers in typed memory*. It uses the type's pointer map to know which words in an object are pointers, so an address you have stashed in a `uintptr` or written into a `[]byte` keeps nothing alive. ## Non-generational Most JVM collectors bet on the weak generational hypothesis: most objects die young, so collect a small young generation often and the old generation rarely. Go makes no such split. Every cycle traces the entire live heap, and the cost of a cycle scales with how much is **live**, not with how much garbage was produced. That sounds expensive and mostly isn't, because Go removes much of the pressure that a nursery is designed to absorb: - escape analysis puts values whose lifetime provably ends with the frame on the goroutine's stack, so they are never heap objects at all; - structs and arrays are embedded **by value**, so one heap object often holds data that another runtime would spread over several pointer-linked objects; - marking is concurrent, so tracing time is CPU spent alongside your program rather than a pause. The real cost shows up when the *live* heap is huge and pointer-dense — a long-lived in-memory graph, say — because every cycle re-traces all of it. ## Non-moving Go never copies or compacts a live heap object. Consequences worth knowing: 1. **Addresses are stable.** `&x` for a heap value keeps pointing at the same bytes forever. This is what makes interior pointers, `unsafe.Pointer` work and passing a Go pointer into a C call feasible at all. (Stability is not permission to hide a pointer from the collector — an object with no reachable Go pointer to it is still garbage, whatever numbers you saved.) 2. **No compaction means no defragmentation pass.** Instead the allocator uses **size classes**: the heap is made of spans (runs of 8 KiB pages), each span is dedicated to one size class and carved into equal slots, and freeing a slot returns it to that span's free list. Objects larger than 32 KiB get their own spans. Waste is bounded *internal* fragmentation (the gap between your object's size and its size class), not the unbounded external fragmentation that plain mark-sweep is famous for. 3. **Allocation is not a bump pointer** into a nursery; it is a fast pick from a per-P cache of spans, which is still only a handful of instructions on the common path. One nuance that surprises people: **goroutine stacks do move.** A stack starts small and grows by allocating a bigger one and copying, with the runtime rewriting pointers into that stack. Heap objects never move; stacks do. That is exactly why C code must not hold on to a pointer to a Go stack variable. ## What a Go developer should take from this Don't design around a nursery — "make it short-lived so it dies in the young gen" is not a strategy here. If GC cost matters, reduce allocation and pointer density: reuse buffers, prefer values over pointers in hot structures, and keep the *live* set small. And don't expect an address to change under you, or expect memory to be handed back to the operating system the moment a cycle ends.

  • If Go never compacts, what keeps the heap from fragmenting badly?
    Size classes. The heap is built from spans of 8 KiB pages, each span dedicated to one size class and carved into equal slots, with objects over 32 KiB getting their own spans. A freed slot is immediately reusable by any object of that class, so waste is bounded internal fragmentation rather than unusable gaps between differently sized objects.
  • Tracing the whole live heap every cycle sounds expensive. Why is that acceptable in Go?
    Because marking runs concurrently with your goroutines rather than as a pause, and because Go heaps are comparatively pointer-poor: escape analysis keeps many short-lived values on the stack, and structs and arrays are embedded by value instead of being separate pointer-linked objects. Cost scales with the live set, so it does bite when the live heap is large and pointer-dense.
  • You said heap objects never move. Is a pointer to a goroutine's stack variable equally stable?
    No. Goroutine stacks start small and grow by allocating a larger stack and copying, and the runtime rewrites pointers into the stack as part of that. Heap objects are never relocated, stacks are. It is one reason the cgo pointer rules forbid C code from retaining a pointer to Go memory after the call returns.

saying these in an interview costs you the question

  • Says Go has a young generation and promotes survivors
  • Claims the collector compacts the heap to remove fragmentation
  • Thinks the program is fully stopped for the whole marking phase
  • Believes an object's address can change after a collection
  • Assumes objects in a reference cycle leak in Go
open as a page

After Go's garbage collector finishes marking, when is unreachable memory actually reclaimed?

level: middleimportance: should knowfreq 38%

basics

~20 s

Go sweeps lazily. Marking only decides what is live; spans are swept afterwards, mostly by goroutines that are allocating, so a dead object's slot becomes reusable when its span is swept, and it stays inside the process heap.

open as a page

How do you confirm Go's garbage collector freed an object your program was still using?

level: seniorimportance: should knowfreq 26%

basics

~20 s

Assume the program hid the pointer, not that the collector lost it. Reproduce under GODEBUG=clobberfree=1 to make the use-after-free loud, then GODEBUG=gccheckmark=1: if it never panics, marking was complete and your unsafe or cgo code dropped the last Go pointer.

open as a page

What did Go 1.26's Green Tea garbage collector change, and what stayed the same?

level: seniorimportance: nice to knowfreq 22%

basics

~20 s

Green Tea, on by default since Go 1.26, changes how the marker scans memory: span by span for better locality instead of chasing objects one pointer hop at a time. The collector stays concurrent, tri-colour, non-generational and non-moving.

open as a page