Why do pointers make the GC more expensive, and how can data layout, unique.Handle, and weak pointers reduce memory and GC cost?
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 aunique.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/netipuses it for IPv6 zone names.weak(Go 1.24):weak.Make(p)returns aweak.Pointer[T]that doesn't keep*palive;Value()returns nil after the object is collected.runtime.AddCleanup(Go 1.24): replacesSetFinalizerfor 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
- Q404How do you benchmark concurrent code with b.RunParallel, and why does sync.RWMutex often fail to scale for read-heavy workloads?
- Q405Go 1.24 replaced the built-in map implementation with Swiss tables. What changed, and what does it mean for performance-sensitive code?