Files
2026-07-24 16:23:24 +07:00

8.9 KiB

Memory Optimization

Allocation reduction is the single highest-ROI optimization in most Go programs. Every allocation eventually requires garbage collection — reducing allocation count and size directly reduces GC pauses and CPU overhead.

Allocation Patterns

Diagnose: 1- go tool pprof -alloc_objects — rank functions by number of heap allocations; expect hot-path functions (request handlers, serializers) near the top with thousands of alloc/op 2- go build -gcflags="-m -m" — verbose escape analysis showing why variables escape; look for "leaking param", "too large for stack", or "captured by closure" on variables you expect to stay on the stack 3- go test -bench -benchmem — measure allocs/op and B/op per benchmark; expect the target function to show >0 allocs/op that can be eliminated

Reuse slices via append(s[:0], ...)

Reslicing to zero length retains the backing array, turning what would be a new allocation into a no-op:

// Bad — allocates new slice, old one becomes garbage
mode = []T{item}

// Good — reuses existing backing array (0 allocations)
mode = append(mode[:0], item)

Direct indexing vs append

When the output size equals the input size, use make([]T, len(input)) with direct assignment instead of make([]T, 0, len(input)) with append. Direct assignment avoids per-element bounds checking and length increment:

// Slower — append overhead per element
result := make([]T, 0, len(input))
for i := range input { result = append(result, transform(input[i])) }

// Faster — direct assignment
result := make([]T, len(input))
for i := range input { result[i] = transform(input[i]) }

Use append when the result might be smaller (filtering) or when early error return could discard partial results.

Eliminate redundant map lookups

for k := range m { use(m[k]) } does two lookups per iteration. Capture the value from range:

// Bad — two lookups per iteration
for k := range in { result[k] = fn(in[k]) }

// Good — single lookup
for k, v := range in { result[k] = fn(v) }

Map size hints

make(map[K]V) starts with a small number of buckets and rehashes as it grows. Providing a size hint avoids rehashing:

m := make(map[string]int, len(items)) // single allocation, no rehashing

Sentinel errors vs fmt.Errorf

fmt.Errorf allocates on every call. For predictable errors in hot paths, use preallocated sentinels:

var ErrNegative = errors.New("value is negative") // allocated once

func validate(x int) error {
    if x < 0 { return ErrNegative } // zero allocation
    return nil
}

Only use fmt.Errorf when you need dynamic context (field names, values).

Interface boxing

Passing concrete types through any/interface{} forces heap allocation for boxing. In hot paths, use typed parameters or generics:

// Bad — boxes each int, allocates
func sum(values []any) int { ... }

// Good — no boxing, no allocation
func sum(values []int) int { ... }

// Good — generic, still no boxing
func sum[T ~int | ~int64](values []T) T { ... }

Backing Array Leaks

Diagnose: 1- go tool pprof -inuse_space — show currently live heap memory by allocation site; look for unexpectedly large live objects (MB-sized) that should have been GC'd — a sign of backing array retention 2- go tool pprof -alloc_space — show cumulative bytes allocated over time; look for allocation sites producing far more bytes than the final data they hold (e.g., 100MB allocated for 16-byte results)

Slice reslicing retains the entire backing array

A small reslice of a large slice keeps the entire original array in memory:

// Bad — retains entire megabyte-sized backing array
func getHeader(data []byte) []byte { return data[:16] }

// Good — independent copy, original can be GC'd
func getHeader(data []byte) []byte {
    header := make([]byte, 16)
    copy(header, data[:16])
    return header
}

Substring memory leaks

Substrings share the backing array of the original string:

// Bad — keeps entire longMsg in memory
func extractID(msg string) string { return msg[:8] }

// Good — independent copy (Go 1.20+)
func extractID(msg string) string { return strings.Clone(msg[:8]) }

Map never shrinks

Go maps grow but never release bucket memory when entries are deleted. A map that once held millions of entries retains its allocation forever:

// Recreate periodically to reclaim memory
func compact(old map[string]Data) map[string]Data {
    m := make(map[string]Data, len(old))
    for k, v := range old { m[k] = v }
    return m // old map becomes eligible for GC
}

String and Byte Optimization

Diagnose: 1- go tool pprof -alloc_objects — look for string/byte conversion functions (runtime.stringtoslicebyte, runtime.slicebytetostring) appearing as top allocators 2- go test -bench -benchmem — measure allocs/op; expect repeated conversions to show 1+ alloc/op per conversion that can be reduced to zero by caching

Cache string-to-byte conversions — converting between string and []byte allocates a copy each time. Convert once and reuse the result.

Use bytes package directly — bytes.Contains, bytes.HasPrefix, bytes.Split, bytes.ToUpper etc. operate on []byte without string conversion. The bytes package mirrors most of strings.

sync.Pool Hot-Path Patterns

Diagnose: 1- go tool pprof -alloc_objects — identify hot allocation sites creating the same object type repeatedly (e.g., []byte buffers, temp structs); expect one site with thousands of allocs/s that can be pooled

sync.Pool recycles objects across GC cycles, reducing allocation pressure. Use it for frequently allocated, short-lived objects in hot paths (HTTP handlers, serialization, logging):

var bufPool = sync.Pool{
    New: func() any {
        buf := make([]byte, 0, 4096)
        return &buf
    },
}

func handleRequest(data []byte) []byte {
    bp := bufPool.Get().(*[]byte)
    buf := (*bp)[:0] // reset length, keep capacity
    defer func() { *bp = buf; bufPool.Put(bp) }()

    // ... process data into buf ...

    result := make([]byte, len(buf))
    copy(result, buf) // return a copy — buf goes back to pool
    return result
}

Rules:

  • Reset state before Put() — clear references to avoid retaining large object graphs across GC cycles
  • Return copies, not pooled buffers — callers must not hold references to pooled memory
  • Don't pool objects >32KB — large allocations bypass the pool's size classes and GC already handles them efficiently
  • Don't pool infrequently used objects — pool overhead exceeds benefit when allocations are rare

→ See samber/cc-skills-golang@golang-concurrency skill for sync.Pool API reference and basic usage patterns.

Memory Layout

Diagnose: 1- fieldalignment ./... — detect structs with wasted padding bytes; expect warnings like "struct of size 40 could be 24" listing which structs benefit from reordering 2- unsafe.Sizeof/Alignof/Offsetof — measure exact byte sizes and field offsets; use to confirm savings before/after and document them in code comments

Struct field alignment

Go adds padding between fields to satisfy alignment requirements. Reorder fields from largest to smallest:

// Bad — 24 bytes (7 + 3 bytes padding)
type Bad struct {
    a bool    // 1 byte + 7 padding
    b int64   // 8 bytes
    c bool    // 1 byte + 3 padding
    d int32   // 4 bytes
}

// Good — 16 bytes (2 bytes padding)
type Good struct {
    b int64   // 8 bytes
    d int32   // 4 bytes
    a bool    // 1 byte
    c bool    // 1 byte + 2 padding
}

Alignment requirements: bool/byte = 1, int16 = 2, int32/float32 = 4, int64/float64/string/[]T/*T = 8.

Inspect layout: unsafe.Sizeof(T{}), unsafe.Alignof(T{}), unsafe.Offsetof(T{}.field)

Zero-size field at end of struct

If the last field has zero size (struct{}), the compiler adds word-sized padding to prevent a pointer to that field from overlapping the next memory block:

// Bad — 16 bytes (8 for Value + 8 padding for Flag)
type Entry struct { Value int64; Flag struct{} }

// Good — 8 bytes (0 for Flag + 8 for Value)
type Entry struct { Flag struct{}; Value int64 }

Having a struct{} field in a struct is rare and almost useless.

Pointer receivers for large structs

Value receivers copy the entire struct on every method call. Use pointer receivers for structs larger than ~128 bytes. If any method uses a pointer receiver, all methods should for consistency.

Map of pointers for large, frequently updated structs

Map values are not addressable — you cannot modify a field in place. For large structs with frequent updates, map[K]*V avoids the copy-modify-reassign pattern:

players := map[string]*Player{"alice": {Score: 100}}
players["alice"].Score += 10 // direct modification, no copy

Trade-off: each pointer is a separate heap allocation, adding GC pressure. For small, mostly-read structs, map[K]V (value) is better.