Memory, Allocations, and GC
Memory, Allocations, and GC
Memory behavior is latency behavior. Allocation patterns influence GC pressure, which affects tail latency even when average CPU looks fine.
Why / Overview
Go’s GC is a concurrent, tri-color mark-and-sweep collector designed for low pause times. It is not free: marking work scales with live heap and allocation rate.
allocate -> object live? -> may promote to longer-lived -> GC mark -> reclaim unreachable
Goals of this chapter:
- See allocations in profiles and benches
- Reduce hot-path allocs without unreadable code
- Use pooling only with evidence
- Understand GOGC / GOMEMLIMIT knobs
Stack vs Heap (Practical View)
Escape analysis decides whether a value can live on the stack (cheap) or must be on the heap (GC-managed).
go build -gcflags='-m=2' ./...
# look for "escapes to heap"Common escape causes:
- Returning pointers to local vars
- Interface conversion of large values
- Closures capturing variables
- Sending pointers on channels that outlive the stack frame
// Often heap: returned pointer
func New() *User { u := User{}; return &u }
// May stay stack if inlined and not escaped further — measure, don't guessReading Allocation Metrics
From benchmarks:
BenchmarkX-8 123456 9876 ns/op 4096 B/op 12 allocs/op
- B/op — bytes allocated per op
- allocs/op — number of heap allocations
From runtime metrics / expvar / Prometheus Go collector:
go_memstats_heap_alloc_bytesgo_memstats_heap_objects- GC pause quantiles
From pprof heap:
- inuse_space — currently live
- alloc_space — cumulative allocated (good for “who allocates a lot”)
Hot-Path Techniques
1. Preallocate slices
// BAD: many grow copies
var out []T
for _, x := range in {
out = append(out, f(x))
}
// GOOD
out := make([]T, 0, len(in))2. Reuse buffers
buf := make([]byte, 0, 4096)
for _, item := range items {
buf = buf[:0]
buf = appendJSON(buf, item)
_, _ = w.Write(buf)
}3. Avoid fmt in hot paths
// fmt.Sprintf allocates; strconv and append often cheaper
b = strconv.AppendInt(b, n, 10)4. Beware of []byte(string) / string([]byte) conversions
They allocate copies. Sometimes necessary for APIs; don’t thrash.
5. Interfaces and methods
Storing concrete values in interface{} / any can allocate. Stream APIs with concrete types when profiling shows it matters.
sync.Pool
var bufPool = sync.Pool{
New: func() any {
b := make([]byte, 0, 4096)
return &b
},
}
func process() {
bp := bufPool.Get().(*[]byte)
buf := (*bp)[:0]
defer func() {
*bp = buf
bufPool.Put(bp)
}()
// use buf
}Rules:
- Only for short-lived, frequently allocated objects
- Clear sensitive data before put if buffers hold secrets
- Pools can be wiped on GC — do not store important state only in pool
- Prove benefit with benchmarks under concurrent load
String Builders
var b strings.Builder
b.Grow(n)
b.WriteString(s1)
b.WriteString(s2)
out := b.String() // may allocate onceBetter than repeated + in loops.
Maps and Reuse
Clearing maps:
// Go 1.21+: clear(m)
clear(m)
// Or keep map and delete keys if you need reuse without rehash cost carefullyReusing maps across requests can reduce allocs; watch for leftover key retention (memory leaks of keys).
GC Tuning Knobs
GOGC
GOGC=100 (default) roughly means GC trigger when heap grows 100% past live set. Higher GOGC → less frequent GC, larger heap. Lower → more GC CPU, smaller heap.
GOGC=50 ./app # more GC, less memory
GOGC=200 ./app # less GC, more memoryGOMEMLIMIT (Go 1.19+)
Soft memory limit that makes GC more aggressive as you approach the limit — excellent for containers:
GOMEMLIMIT=512MiB ./appPrefer setting memory limits in orchestrators and GOMEMLIMIT to reduce OOM kills from GC softness.
Ballast (historical)
Old trick of large unused allocation to influence GC — largely superseded by GOMEMLIMIT. Avoid new ballast designs.
Symptoms of Allocation Problems
| Symptom | Clue |
|---|---|
High CPU in runtime.gc* / scanobject |
GC pressure |
| p99 spikes periodic | GC pacing |
| Growing heap inuse | Leak or retention |
| High allocs/op in bench | Hot path churn |
| Throughput drops with more goroutines | Alloc+sched pressure |
Production Checklist
- Critical path benches report allocs
- Heap profiles captured under load
- Container memory limit + GOMEMLIMIT aligned
- GOGC only changed with measured reason
- Pooling justified by numbers
- No unbounded caches without eviction
- Escape analysis consulted for hot packages (
-m) - Large buffers not retained accidentally on structs
Common Pitfalls
- Pooling everything — complexity, bugs, little gain.
- Ignoring leaks (slice backing arrays retained by small reslices).
- Global caches without bound.
- String concat in loops.
deferin micro-benchmarks affecting tiny functions (sometimes); be aware.- Tuning GOGC to hide a leak.
- Copying huge structs in interfaces repeatedly.
Exercises
- Benchmark
+string concat vsstrings.Builderfor 10k parts. - Find allocs/op of a JSON marshal path; reduce with streaming encoder to
Writer. - Use
-gcflags=-mon a hot file; explain three escapes. - Introduce a
sync.Poolfor buffers; benchstat with and without under-cpu=8. - Create a memory leak with growing slice of pointers; find it in heap profile.
- Run with
GOMEMLIMITlow; observe GC CPU vs without. - Compare
make([]byte, 0, n)reuse vs new each time. - Profile
fmt.Sprintf("%d", i)vsstrconv.Itoa. - Clear a map between requests; ensure no key accumulation.
- Write a short design note: when your team allows
sync.Pool.
Leak Patterns Specific to Go Services
- Unbounded caches (
mapgrows forever per user/id). - Goroutine leaks holding references to large buffers.
time.Afterin loops (pre-1.23 patterns) retaining timers — preferNewTimercarefully or tickers.- Registering HTTP handlers / finalizers repeatedly.
- Appending to a slice shared via pointer without bounds.
Heap profiles over two timestamps (-base) make growth obvious.
More examples
Preallocate slice capacity
mkdir -p /tmp/go-prealloc && cd /tmp/go-prealloc
go mod init example.com/preallocSave as main.go:
package main
import (
"fmt"
"runtime"
)
func allocs(fn func()) uint64 {
var m1, m2 runtime.MemStats
runtime.GC()
runtime.ReadMemStats(&m1)
fn()
runtime.ReadMemStats(&m2)
return m2.Mallocs - m1.Mallocs
}
func main() {
n := 1000
a := allocs(func() {
var s []int
for i := 0; i < n; i++ {
s = append(s, i)
}
_ = s
})
b := allocs(func() {
s := make([]int, 0, n)
for i := 0; i < n; i++ {
s = append(s, i)
}
_ = s
})
fmt.Println("grow allocs >= prealloc:", a >= b)
fmt.Println("prealloc mallocs:", b)
}go run .Expected output:
grow allocs >= prealloc: true
prealloc mallocs: <small number>
sync.Pool reuse
mkdir -p /tmp/go-pool && cd /tmp/go-pool
go mod init example.com/poolSave as main.go:
package main
import (
"bytes"
"fmt"
"sync"
)
func main() {
var pool = sync.Pool{New: func() any { return new(bytes.Buffer) }}
buf := pool.Get().(*bytes.Buffer)
buf.Reset()
buf.WriteString("hello")
fmt.Println(buf.String())
pool.Put(buf)
buf2 := pool.Get().(*bytes.Buffer)
// May be the same buffer; Reset before use in real code.
buf2.Reset()
buf2.WriteString("world")
fmt.Println(buf2.String())
}go run .Expected output:
hello
world
Runnable example
Compare allocations with testing.AllocsPerRun, reuse a buffer with sync.Pool, and print basic GC stats via runtime.ReadMemStats.
mkdir -p /tmp/go-alloc-gc && cd /tmp/go-alloc-gc
go mod init example.com/alloc-gcSave as main.go:
package main
import (
"fmt"
"runtime"
"sync"
"testing"
)
var bufPool = sync.Pool{
New: func() any {
b := make([]byte, 0, 4096)
return &b
},
}
func workNew(n int) int {
b := make([]byte, 0, n)
for i := 0; i < n; i++ {
b = append(b, 'x')
}
return len(b)
}
func workPool(n int) int {
bp := bufPool.Get().(*[]byte)
b := (*bp)[:0]
for i := 0; i < n; i++ {
b = append(b, 'x')
}
// keep cap; return to pool
*bp = b
bufPool.Put(bp)
return len(b)
}
func main() {
aNew := testing.AllocsPerRun(100, func() { _ = workNew(2048) })
aPool := testing.AllocsPerRun(100, func() { _ = workPool(2048) })
fmt.Printf("allocs workNew=%.2f workPool=%.2f\n", aNew, aPool)
var before, after runtime.MemStats
runtime.ReadMemStats(&before)
sink := 0
for i := 0; i < 10000; i++ {
sink += workNew(1024)
}
runtime.GC()
runtime.ReadMemStats(&after)
fmt.Printf("heap_alloc_after=%d num_gc=%d total_alloc_delta=%d sink=%d\n",
after.HeapAlloc, after.NumGC, after.TotalAlloc-before.TotalAlloc, sink)
fmt.Println("note: Pool helps under concurrent reuse; measure in real load")
}go run .Expected output (illustrative):
allocs workNew=1.00 workPool=0.00
heap_alloc_after=... num_gc=... total_alloc_delta=...
note: Pool helps under concurrent reuse; measure in real load
What to notice
- Allocation rate drives GC CPU; reducing allocs/op often beats micro-tweaking algorithms later.
sync.Poolis for hot reusable buffers—not a general cache; values may be dropped anytime.ReadMemStatsis expensive; use sparingly or prefer runtime/metrics in production.
Try next
go build -gcflags=-mon a file and read escape analysis notes.- Set
GOMEMLIMITand observe GC behavior under a memory-hungry loop.
Further Reading
- Go GC guide and runtime metrics
- Previous: profiling workflow to find alloc sites
- Next: Contention and Concurrency Tuning