Go

Why do pointers make the GC more expensive, and how can data layout, unique.Handle, and weak pointers reduce memory and GC cost?

Question 406HardGo 1.22 to 1.25

The mark phase must scan every live object that contains pointers. Objects whose type has no pointers are allocated in "noscan" spans and are never scanned. So GC CPU is driven by the amount of live, pointer-bearing memory, not by total heap size. A 2 GB []int64 costs almost nothing to mark; a 2 GB tree of *Node costs a lot.

// Pointer-heavy: 1M separate objects, each one scanned, poor locality.
type NodeP struct {
    Left, Right *NodeP
    Key         string // a string header contains a pointer too
}

// Pointer-free: one noscan allocation, indices instead of pointers.
type Node struct {
    Left, Right int32  // index into Tree.nodes, -1 = none
    KeyOff      uint32 // offset into Tree.keys
    KeyLen      uint32
}

type Tree struct {
    nodes []Node // noscan
    keys  []byte // all key bytes in one arena, noscan
}

Newer standard library tools for memory-heavy services:

  • unique (Go 1.23): unique.Make(v) interns a comparable value and returns a unique.Handle[T]. Equal values get the same handle, so comparison is a pointer compare and duplicates share one copy. Entries are removed automatically once no handles remain. net/netip uses it for IPv6 zone names.
  • weak (Go 1.24): weak.Make(p) returns a weak.Pointer[T] that doesn't keep *p alive; Value() returns nil after the object is collected.
  • runtime.AddCleanup (Go 1.24): replaces SetFinalizer for most uses. You can attach several cleanups to one object, cycles don't leak, and the object is freed right away instead of being kept alive for one more GC cycle.
type WeakCache[K comparable, V any] struct {
    mu sync.Mutex
    m  map[K]weak.Pointer[V]
}

func (c *WeakCache[K, V]) Get(k K) *V {
    c.mu.Lock()
    defer c.mu.Unlock()
    if wp, ok := c.m[k]; ok {
        return wp.Value() // nil if already collected
    }
    return nil
}

func (c *WeakCache[K, V]) Put(k K, v *V) {
    c.mu.Lock()
    defer c.mu.Unlock()
    if c.m == nil {
        c.m = make(map[K]weak.Pointer[V])
    }
    c.m[k] = weak.Make(v)
    // The cleanup must not reference v, or v would never become unreachable.
    runtime.AddCleanup(v, func(key K) {
        c.mu.Lock()
        defer c.mu.Unlock()
        if wp, ok := c.m[key]; ok && wp.Value() == nil { // don't delete a newer entry
            delete(c.m, key)
        }
    }, k)
}

Gotchas: a cleanup closure (or its argument) that references the object keeps it alive forever. Cleanups run on a runtime goroutine at some point after collection, possibly never before exit, so don't use them to release scarce resources like file descriptors; use explicit Close. Interning helps only when duplication is high. Interviewer is looking for: the link between pointer density and GC cost, and choosing layout changes before turning GC knobs.

More on Performance, Profiling & Testing

All 38 Performance, Profiling & Testing questions