GC behaviour sets your service's CPU overhead, memory footprint and tail latency.
Go uses a concurrent, tri-colour, mark-and-sweep collector. It is non-generational and non-moving: objects never relocate, which keeps pointers stable and makes interop simpler. Most marking runs concurrently with your code; only two short stop-the-world phases bracket it, usually well under a millisecond. During marking a write barrier records pointer updates so concurrently running code can't hide a live object.
When does a cycle start? The heap goal is controlled by GOGC (default 100): after a collection, the next one triggers when the heap grows by GOGC% of the live heap (plus GC roots such as stacks and globals). With 100 MB live, the next GC targets about 200 MB.
- Higher
GOGC→ fewer cycles, less CPU, more memory.GOGC=offdisables collection. GOMEMLIMIT(Go 1.19+) is a soft memory limit: as total runtime memory approaches it, the GC runs more often regardless of GOGC. Setting it a bit below a container's memory limit, often combined with a higher GOGC, avoids OOM kills while keeping GC cheap.- Both can be set from code via
runtime/debug.SetGCPercentandSetMemoryLimit.
If allocation outpaces marking, the runtime makes allocating goroutines do mark assist work, which shows up as latency. GC cost scales mainly with the amount of live, pointer-containing memory and the allocation rate — so reducing allocations is the most effective tuning.
Observe it with GODEBUG=gctrace=1 (one line per cycle), runtime.ReadMemStats, the runtime/metrics package, and the GC lanes in go tool trace. Go 1.24 added runtime.AddCleanup and the weak package as better alternatives to runtime.SetFinalizer; Go 1.25 shipped the experimental "Green Tea" collector improving marking locality.
Example
package main
import (
"fmt"
"runtime"
"runtime/debug"
)
var sink [][]byte
func main() {
debug.SetGCPercent(200) // like GOGC=200
debug.SetMemoryLimit(512 << 20) // like GOMEMLIMIT=512MiB
for range 1000 {
sink = append(sink, make([]byte, 64<<10)) // ~6 MiB stays live
if len(sink) > 100 {
sink = sink[1:] // older buffers become garbage
}
}
var m runtime.MemStats
runtime.ReadMemStats(&m)
fmt.Printf("heap=%d MiB cycles=%d\n", m.HeapAlloc>>20, m.NumGC)
}Remember
- Concurrent, non-moving, non-generational mark-sweep with a write barrier
- GOGC trades CPU for memory; GOMEMLIMIT caps memory softly
- Fewer allocations and less live pointer data = cheaper GC
Watch out for
- Setting GOMEMLIMIT below the program's actual live heap — the GC runs continuously and the program crawls (thrashing).