Memory, Allocations, and GC

Updated

July 30, 2026

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 guess

Reading 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_bytes
  • go_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 once

Better 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 carefully

Reusing 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 memory

GOMEMLIMIT (Go 1.19+)

Soft memory limit that makes GC more aggressive as you approach the limit — excellent for containers:

GOMEMLIMIT=512MiB ./app

Prefer 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

  1. Pooling everything — complexity, bugs, little gain.
  2. Ignoring leaks (slice backing arrays retained by small reslices).
  3. Global caches without bound.
  4. String concat in loops.
  5. defer in micro-benchmarks affecting tiny functions (sometimes); be aware.
  6. Tuning GOGC to hide a leak.
  7. Copying huge structs in interfaces repeatedly.

Exercises

  1. Benchmark + string concat vs strings.Builder for 10k parts.
  2. Find allocs/op of a JSON marshal path; reduce with streaming encoder to Writer.
  3. Use -gcflags=-m on a hot file; explain three escapes.
  4. Introduce a sync.Pool for buffers; benchstat with and without under -cpu=8.
  5. Create a memory leak with growing slice of pointers; find it in heap profile.
  6. Run with GOMEMLIMIT low; observe GC CPU vs without.
  7. Compare make([]byte, 0, n) reuse vs new each time.
  8. Profile fmt.Sprintf("%d", i) vs strconv.Itoa.
  9. Clear a map between requests; ensure no key accumulation.
  10. Write a short design note: when your team allows sync.Pool.

Leak Patterns Specific to Go Services

  1. Unbounded caches (map grows forever per user/id).
  2. Goroutine leaks holding references to large buffers.
  3. time.After in loops (pre-1.23 patterns) retaining timers — prefer NewTimer carefully or tickers.
  4. Registering HTTP handlers / finalizers repeatedly.
  5. 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/prealloc

Save 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/pool

Save 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-gc

Save 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.Pool is for hot reusable buffers—not a general cache; values may be dropped anytime.
  • ReadMemStats is expensive; use sparingly or prefer runtime/metrics in production.

Try next

  • go build -gcflags=-m on a file and read escape analysis notes.
  • Set GOMEMLIMIT and 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