9.9 Memory Management

Understanding Go's memory management is crucial for writing efficient programs. Go uses automatic garbage collection, but understanding how it works helps you write better code.

Garbage Collection

Go uses a concurrent, tri-color mark-and-sweep garbage collector.

Garbage collection exists to provide automatic memory management (no manual memory allocation/deallocation), safety (prevents memory leaks and use-after-free bugs), and productivity (developers focus on logic, not memory management).

Go's GC operates using:

Tri-color marking

  1. White: Objects not yet visited (candidates for collection)
  2. Gray: Objects visited but their references not yet processed
  3. Black: Objects visited and all references processed (reachable)

Tri-color marking enables concurrent execution (GC runs alongside program execution), incremental collection (doesn't stop the world for long), and efficiency (only marks reachable objects).

GC phases

  1. Mark phase: Identify reachable objects
  2. Sweep phase: Free unreachable objects
  3. Concurrent execution: Runs alongside your program

Concurrent GC matters because it provides low latency (program continues running during GC), better throughput (better overall performance), and responsiveness (applications stay responsive).

GC tuning

# Set GC target percentage (default: 100)
GOGC=200 go run main.go  # More aggressive (less frequent GC)

# Disable GC (not recommended)
GOGC=off go run main.go

GC tuning allows you to trade memory for CPU (or vice versa), adjust for latency-sensitive applications, and optimize for throughput.

Memory Allocation

Go manages memory allocation automatically, but understanding allocation helps optimize.

Allocation works through stack allocation (fast, automatic cleanup), heap allocation (managed by GC, slower), and compiler decisions (compiler decides stack vs heap).

Allocation matters because stack allocation is faster (performance), heap allocations trigger GC (GC pressure), and stack is limited while heap is larger (memory usage).

Allocation patterns

// Stack allocation (likely)
func stackAlloc() {
    x := 42  // Allocated on stack
    // x automatically freed when function returns
}

// Heap allocation (likely)
func heapAlloc() *int {
    x := 42  // May escape to heap
    return &x  // Returned pointer forces heap allocation
}

Escape analysis determines whether values "escape" the function. The compiler analyzes this, and escaped values must be on the heap.

Stack vs Heap

Understanding stack vs heap allocation is key to performance.

Stack allocation

Stack allocation is fast because it's just incrementing a pointer. No complex memory management is needed.

Heap allocation

Heap allocation is slower because it requires finding free memory, tracking allocations, garbage collection, and dealing with potential fragmentation.

Escape analysis

The compiler determines if a value escapes to the heap:

// Stays on stack
func stackExample() int {
    x := 42
    return x  // Value returned, not pointer
}

// Escapes to heap
func heapExample() *int {
    x := 42
    return &x  // Pointer returned, x escapes
}

Escape analysis matters because stack allocation is much faster (performance), heap allocations increase GC work (GC pressure), and understanding escapes helps optimize code.

Viewing escape analysis

go build -gcflags="-m" main.go

Output shows which values escape:

./main.go:10:6: moved to heap: x

Escape Analysis

Escape analysis determines whether values can be allocated on the stack or must be on the heap.

When values escape

  1. Returning pointers

    func escape1() *int {
    x := 42
    return &x  // x escapes to heap
    }
  2. Storing in data structures

    func escape2() {
    m := make(map[int]*int)
    x := 42
    m[0] = &x  // x escapes to heap
    }
  3. Interface values

    func escape3() interface{} {
    x := 42
    return x  // x escapes (interface{} can hold any type)
    }
  4. Closures

    func escape4() func() int {
    x := 42
    return func() int {
        return x  // x escapes (captured by closure)
    }
    }

These values escape because they must outlive the function (lifetime), multiple goroutines or functions need access (shared access), and interface values need heap allocation (dynamic types).

Preventing escapes

// ✅ GOOD: Return value, not pointer
func noEscape() int {
    x := 42
    return x  // Stays on stack
}

// ❌ BAD: Return pointer
func escape() *int {
    x := 42
    return &x  // Escapes to heap
}

Preventing escapes helps with performance (stack allocation is faster), reduces GC work (less work for garbage collector), and improves memory management (stack memory is automatically freed).

Optimization Tips

1. Reduce allocations

// ❌ BAD: Many allocations
func process(items []Item) {
    for _, item := range items {
        result := processItem(item)  // Allocation each iteration
        // ...
    }
}

// ✅ GOOD: Reuse buffer
func process(items []Item) {
    var buffer bytes.Buffer  // Allocate once
    for _, item := range items {
        buffer.Reset()  // Reuse
        processItemToBuffer(item, &buffer)
    }
}

2. Use sync.Pool for temporary objects

var bufferPool = sync.Pool{
    New: func() interface{} {
        return new(bytes.Buffer)
    },
}

func process() {
    buf := bufferPool.Get().(*bytes.Buffer)
    defer bufferPool.Put(buf)
    buf.Reset()
    // Use buf
}

3. Pre-allocate slices

// ❌ BAD: Many reallocations
func buildSlice() []int {
    var result []int
    for i := 0; i < 1000; i++ {
        result = append(result, i)  // May reallocate
    }
    return result
}

// ✅ GOOD: Pre-allocate
func buildSlice() []int {
    result := make([]int, 0, 1000)  // Pre-allocate capacity
    for i := 0; i < 1000; i++ {
        result = append(result, i)  // No reallocation
    }
    return result
}

4. Avoid unnecessary pointer returns

// ✅ GOOD: Return value
func getValue() int {
    return 42  // Stack allocation
}

// ❌ BAD: Unnecessary pointer
func getValue() *int {
    x := 42
    return &x  // Heap allocation
}

5. Use value receivers for small structs

// ✅ GOOD: Value receiver (small struct)
type Point struct {
    X, Y int
}

func (p Point) Distance() float64 {
    return math.Sqrt(float64(p.X*p.X + p.Y*p.Y))
}

// ⚠️ CONSIDER: Pointer receiver for large structs
type LargeStruct struct {
    // Many fields
}

func (s *LargeStruct) Process() {
    // ...
}

6. Profile memory usage

go test -memprofile=mem.prof
go tool pprof mem.prof

7. Monitor GC

import "runtime"

func printGCStats() {
    var m runtime.MemStats
    runtime.ReadMemStats(&m)
    fmt.Printf("Alloc: %d KB\n", m.Alloc/1024)
    fmt.Printf("TotalAlloc: %d KB\n", m.TotalAlloc/1024)
    fmt.Printf("NumGC: %d\n", m.NumGC)
}

8. Use strings.Builder for string concatenation

// ❌ BAD: Many allocations
func buildString(parts []string) string {
    result := ""
    for _, part := range parts {
        result += part  // New string each time
    }
    return result
}

// ✅ GOOD: Builder
func buildString(parts []string) string {
    var builder strings.Builder
    builder.Grow(len(parts) * 10)  // Pre-allocate
    for _, part := range parts {
        builder.WriteString(part)
    }
    return builder.String()
}

Understanding memory management helps you write efficient Go code. Use profiling to identify allocation hotspots, and apply these optimization techniques where they matter most.