Garbage Collection
Overview
Go uses automatic garbage collection (GC) to manage memory. Understanding how it works helps you write GC-friendly code.
Modern GC Architecture
Concurrent Mark and Sweep
Go’s GC is concurrent and non-moving. At a high level: 1. Mark Setup: (STW) Prepare for marking. 2. Marking: (Concurrent) Mark reachable objects. 3. Mark Termination: (STW) Finalize marking. 4. Sweeping: (Concurrent) Reclaim memory.
flowchart TD
subgraph Phases ["Go GC Concurrent Execution Phases"]
STW1["Mark Setup (STW: <10µs)"] --> Mark["Concurrent Mark (Worker Goroutines & Write Barrier Active)"]
Mark --> STW2["Mark Termination (STW: <10µs)"]
STW2 --> Sweep["Concurrent Sweep (Reclaims Memory to Free Spans)"]
end
Go 1.26: Green Tea GC (GTGC)
Go 1.26 switched the default collector to Green Tea GC (GTGC) for lower pause times and better cache locality in pointer-heavy workloads.
flowchart LR
subgraph LegacyGC ["Legacy Tracing Scanner"]
LRoots["Global / Stack Roots"] -->|Random Heap Jumps| LObj1["Heap Object A"]
LObj1 -->|Cache Miss| LObj2["Heap Object B (Far Span)"]
end
subgraph GreenTeaGC ["Green Tea GC (GTGC in Go 1.26)"]
GRoots["Global / Stack Roots"] -->|Span-Aware Local Scan| GSpan1["Span 1 Block (High Locality)"]
GSpan1 -->|Vectorized Bitmaps| GSpan2["Span 2 Block"]
end
- Span-Aware Locality: Groups pointer scanning by physical memory spans to maximize CPU L1/L2 cache hits.
- Lower CPU Overhead: Reduces write-barrier costs in concurrent mutator workers.
- Opt-Out: If needed for debugging or legacy performance comparisons:
GOEXPERIMENT=nogreenteagc go test ./...GOGC and Memory Limits
Think of GOGC and GOMEMLIMIT as two controls:
GOGCcontrols when to trigger based on heap growth.GOMEMLIMITputs a soft cap on total Go-managed memory.
Heap growth trigger Memory budget trigger
live heap * (1 + GOGC/100) total Go memory near GOMEMLIMIT
Tuning with GOGC
# Default: GC when heap doubles (100% growth)
GOGC=100 ./myapp
# More aggressive: GC at 50% growth (uses less memory)
GOGC=50 ./myapp
# Less frequent: GC at 200% growth (faster, more memory)
GOGC=200 ./myapp
# Disable GC (not recommended)
GOGC=off ./myappTuning with GOMEMLIMIT
# Keep process around 2 GiB Go-managed memory
GOMEMLIMIT=2GiB ./myappProgrammatic control:
import "runtime/debug"
func init() {
debug.SetMemoryLimit(2 << 30) // 2 GiB
}Memory Stats
var m runtime.MemStats
runtime.ReadMemStats(&m)
fmt.Printf("Alloc: %d MB\n", m.Alloc/1024/1024)
fmt.Printf("Total Alloc: %d MB\n", m.TotalAlloc/1024/1024)
fmt.Printf("Heap Objects: %d\n", m.HeapObjects)
fmt.Printf("GC Cycles: %d\n", m.NumGC)A low-overhead option for production telemetry:
import "runtime/metrics"
samples := []metrics.Sample{
{Name: "/gc/heap/live:bytes"},
{Name: "/gc/heap/goal:bytes"},
}
metrics.Read(samples)Reducing GC Pressure
1. Reduce Allocations
// Bad: allocates each call
func getBuffer() []byte {
return make([]byte, 1024)
}
// Good: reuse with sync.Pool
var bufPool = sync.Pool{
New: func() any { return make([]byte, 1024) },
}
func getBuffer() []byte {
return bufPool.Get().([]byte)
}
func putBuffer(b []byte) {
bufPool.Put(b)
}2. Preallocate
result := make([]int, 0, expectedSize)3. Use Value Types
// More allocations
type Points []*Point
// Fewer allocations
type Points []Point4. Avoid String Concatenation in Loops
// Bad: allocates each iteration
s := ""
for _, part := range parts {
s += part
}
// Good: single allocation
var b strings.Builder
for _, part := range parts {
b.WriteString(part)
}
s := b.String()Profiling
# CPU profile
go test -cpuprofile=cpu.out
go tool pprof cpu.out
# Memory profile
go test -memprofile=mem.out
go tool pprof mem.out
# View allocations
go tool pprof -alloc_space mem.outSummary
| Optimization | Technique |
|---|---|
| Reuse memory | sync.Pool |
| Preallocate | make([]T, 0, cap) |
| Values vs pointers | Use values for small types |
| String building | strings.Builder |
More examples
Example: preallocate slice capacity
Save as main.go and go run . (with go mod init example if needed).
package main
import "fmt"
func main() {
// Growing without capacity reallocates more often.
var grow []int
for i := 0; i < 5; i++ {
grow = append(grow, i)
}
// Preallocated capacity reduces realloc churn.
ready := make([]int, 0, 5)
for i := 0; i < 5; i++ {
ready = append(ready, i)
}
fmt.Println("grow:", grow, "len", len(grow), "cap", cap(grow))
fmt.Println("ready:", ready, "len", len(ready), "cap", cap(ready))
}Expected:
grow: [0 1 2 3 4] len 5 cap 8
ready: [0 1 2 3 4] len 5 cap 5
Example: strings.Builder vs naive concat
Save as main.go and go run . (with go mod init example if needed).
package main
import (
"fmt"
"strings"
)
func main() {
var b strings.Builder
for i := 0; i < 4; i++ {
fmt.Fprintf(&b, "%d,", i)
}
fmt.Println("builder:", b.String())
// Educational contrast: repeated + creates more temporary strings.
s := ""
for i := 0; i < 4; i++ {
s += fmt.Sprintf("%d,", i)
}
fmt.Println("concat:", s)
}Expected:
builder: 0,1,2,3,
concat: 0,1,2,3,
Runnable example
Save as main.go. Then:
go mod init example
go run .package main
import (
"fmt"
"runtime"
)
func readStats() runtime.MemStats {
var m runtime.MemStats
runtime.ReadMemStats(&m)
return m
}
func printStats(label string, m runtime.MemStats) {
// HeapAlloc / HeapObjects are more stable educational signals than Alloc alone.
fmt.Printf("%s: HeapAlloc=%d bytes HeapObjects=%d NumGC=%d\n",
label, m.HeapAlloc, m.HeapObjects, m.NumGC)
}
func main() {
runtime.GC()
before := readStats()
printStats("before alloc", before)
// Keep a live reference so the GC cannot free these yet.
keep := make([][]byte, 0, 100)
for i := 0; i < 100; i++ {
keep = append(keep, make([]byte, 64*1024)) // 64 KiB each
}
afterAlloc := readStats()
printStats("after alloc", afterAlloc)
// Drop references, then force a collection.
keep = nil
runtime.GC()
afterGC := readStats()
printStats("after GC", afterGC)
fmt.Printf("objects grew by ~%d then GC cycles advanced by %d\n",
int64(afterAlloc.HeapObjects)-int64(before.HeapObjects),
afterGC.NumGC-before.NumGC)
}Expected output: (numbers vary by platform and Go version; shape is stable)
before alloc: HeapAlloc=... bytes HeapObjects=... NumGC=...
after alloc: HeapAlloc=... bytes HeapObjects=... NumGC=...
after GC: HeapAlloc=... bytes HeapObjects=... NumGC=...
objects grew by ~100 then GC cycles advanced by 1
Typically HeapAlloc and HeapObjects rise after the loop, then fall after keep = nil + runtime.GC(), while NumGC increases.
What to notice: Live pointers keep heap memory reachable; clearing the root and forcing GC lets the collector reclaim it. Absolute byte counts are not portable — look at direction of change.
Try next: Run with GOGC=50 vs GOGC=200 and compare how soon NumGC climbs during a longer allocation loop; try sync.Pool to reuse the 64 KiB buffers.