41. Stack, heap and the garbage collector

📖 Reading · 11 min
💡 Most code boxes below are live — edit one and hit Run. Boxes without a Run button are reference-only (they can't run in your browser).

You never call free in Go, and you never decide where a value lives. But knowing what the compiler and the collector are doing turns "my program is slow" from a mystery into a checklist.

Two places a value can live

The stack is per-goroutine scratch space. Function called: frame pushed. Function returns: frame popped, everything in it gone. Allocation is literally moving a pointer, and cleanup is free.

The heap is shared memory for values that must outlive the function that created them. Allocation costs more, and the garbage collector has to track it.

  goroutine stack                  heap
 ┌────────────────┐        ┌──────────────────────┐
 │ main()         │        │                      │
 │   x = 42       │        │   Config{...} ◄──────┼── still referenced
 │   c ───────────┼───────►│                      │
 ├────────────────┤        │   []byte{...}        │
 │ helper()       │        │                      │
 │   tmp = 7      │        │   (unreachable) ─────┼── collectable
 └────────────────┘        └──────────────────────┘
   pop = free               GC finds what's reachable

Go decides which one automatically, per value, using escape analysis.

Escape analysis

The rule is simple to state: if the compiler can prove a value doesn't outlive its function, it goes on the stack. If it can't, the value "escapes" to the heap.

func stays() int {
    x := 42          // stack: never leaves
    return x         // the VALUE is copied out
}

func escapes() *int {
    x := 42          // heap: its address leaves the function
    return &x
}

func alsoEscapes() {
    buf := make([]byte, 1024)
    process(buf)     // if process stores buf somewhere, it escapes
}

You can see the decisions the compiler made:

$ go build -gcflags='-m' main.go
./main.go:8:2: moved to heap: x
./main.go:14:13: make([]byte, 1024) escapes to heap

-gcflags='-m' is the tool to reach for when you're chasing allocations. It tells you exactly which line escaped and why.

Common causes of escape:

  • returning a pointer to a local
  • storing a pointer in a longer-lived structure (a global, a struct field, a channel)
  • passing a value to an interface{}/any parameter — fmt.Println(x) boxes x, and that boxing escapes
  • a size the compiler can't know at compile time (make([]byte, n))
  • closures capturing a variable that outlives the function

The garbage collector, briefly

Go's GC is concurrent and tri-colour mark-and-sweep. In practice:

  • It runs alongside your program, on all cores, most of the time.
  • It pauses every goroutine only briefly — sub-millisecond in normal programs, and the pause doesn't grow with heap size.
  • It starts a cycle when the heap has roughly doubled since the last one (tunable with GOGC, default 100).
  • It frees whatever is unreachable from your goroutines' stacks and globals.

You do not tune this. runtime.GC() exists and you should not call it. The one knob worth knowing is GOMEMLIMIT (a soft ceiling for containers), and you reach for it when a container is being OOM-killed, not before.

What you actually control: allocation count

The GC's cost scales with how much garbage you make. So the practical performance lever isn't the collector, it's producing less work for it:

package main

import (
    "fmt"
    "strings"
)

func main() {
    words := []string{"go", "is", "efficient", "when", "you", "avoid", "garbage"}

    // 1. pre-size a slice you'll fill
    out := make([]string, 0, len(words))
    for _, w := range words {
        out = append(out, strings.ToUpper(w))
    }

    // 2. build strings with a Builder, not +=
    var b strings.Builder
    for _, w := range out {
        b.WriteString(w)
        b.WriteByte(' ')
    }

    // 3. reuse a buffer instead of allocating per iteration
    buf := make([]byte, 0, 64)
    total := 0
    for _, w := range words {
        buf = buf[:0]
        buf = append(buf, w...)
        total += len(buf)
    }

    fmt.Println(strings.TrimSpace(b.String()))
    fmt.Println("bytes processed:", total, "with one buffer")
}

Three habits, all from earlier modules, now with a reason attached:

  1. Pre-size with make([]T, 0, n) — skips the doubling copies.
  2. strings.Builder instead of += — one growing buffer instead of a new string per concatenation.
  3. buf[:0] to reuse a buffer — keeps the array, resets the length, allocates nothing.

Pointers aren't automatically faster

A common wrong instinct is "pointers avoid copying, so pointers are faster." Often they're slower:

// value: stays on the stack, no GC involvement
func sumRect(r Rectangle) float64 { return r.W * r.H }

// pointer: likely escapes, adds a heap allocation and GC work
func sumRectPtr(r *Rectangle) float64 { return r.W * r.H }

A small struct passed by value is copied into a register or a few stack words — cheap, cache-friendly, and invisible to the GC. Turning it into a pointer can force a heap allocation that costs far more than the copy you avoided.

Choose pointers for the semantics (mutation, "unset", non-copyable types). Choose them for performance only after measuring — module 12 shows you how with go test -bench and -benchmem.

Memory leaks in a GC language

Go can absolutely leak. The collector frees what's unreachable, so a leak is anything you're still holding by accident:

package main

import "fmt"

func main() {
    huge := make([]byte, 10_000_000)
    for i := range huge[:100] {
        huge[i] = byte(i)
    }

    leaky := huge[:10]

    safe := make([]byte, 10)
    copy(safe, huge[:10])

    fmt.Println("both hold the same 10 bytes:", leaky[5] == safe[5])
    fmt.Println("but 'leaky' pins all", len(huge), "bytes from being freed")
}

The three real leak sources in Go programs:

  1. Sub-slices of big arrays, as above — the whole backing array stays alive. Copy the piece you need.
  2. Goroutines that never exit — a goroutine blocked forever on a channel is unreachable-but-alive, and everything it references stays too. (Next module.)
  3. Unbounded caches and maps — a map that only ever grows is a leak with extra steps. Note that deleting keys shrinks the map's length but doesn't return the buckets to the OS.

Your turn

Fix the leak: firstTen currently returns a sub-slice that pins the whole 10-million-byte array. Return an independent copy instead, so the big array can be collected:

10 10
independent: true
package main

import "fmt"

func firstTen() []byte {
    huge := make([]byte, 10_000_000)
    for i := 0; i < 10; i++ {
        huge[i] = byte(i)
    }
    return huge[:10] // pins all 10 MB — fix this
}

func main() {
    out := firstTen()
    fmt.Println(len(out), cap(out))
    fmt.Println("independent:", cap(out) == 10)
}
package main

import "fmt"

func firstTen() []byte {
    huge := make([]byte, 10_000_000)
    for i := 0; i < 10; i++ {
        huge[i] = byte(i)
    }
    out := make([]byte, 10)
    copy(out, huge[:10])
    return out
}

func main() {
    out := firstTen()
    fmt.Println(len(out), cap(out))
    fmt.Println("independent:", cap(out) == 10)
}

That's memory. Next module is the one Go is famous for: concurrency.