Golang - Memory Allocation & Escape Analysis

Go's runtime manages memory automatically through a combination of stack allocation, escape analysis, and an optimized multi-tier heap allocator inspired by Google's TCMalloc (Thread-Caching Malloc).

Understanding how Go allocates memory under the hood is essential for writing low-latency, high-throughput systems.

Memory Architecture: The TCMalloc Hierarchy

To prevent lock contention on multi-core systems, Go's heap allocator organizes memory into three distinct tiers:

+-------------------------------------------------------------------------+
|                        Go Runtime Heap Allocator                        |
+-------------------------------------------------------------------------+
|                                                                         |
|  +--------------------+        +--------------------+                   |
|  |   Processor (P0)   |        |   Processor (P1)   |                   |
|  |     +------------+ |        |     +------------+ |                   |
|  |     |   mcache   | |        |     |   mcache   | |  (No Locks!)      |
|  |     +------------+ |        |     +------------+ |                   |
|  +-----------+--------+        +-----------+--------+                   |
|              |                             |                            |
|              +--------------+--------------+                            |
|                             |                                           |
|                             v                                           |
|                   +-------------------+                                 |
|                   |      mcentral     |  (Span locks per size class)    |
|                   +-------------------+                                 |
|                             |                                           |
|                             v                                           |
|                   +-------------------+                                 |
|                   |       mheap       |  (Global lock; virtual memory)  |
|                   +-------------------+                                 |
|                             |                                           |
|                             v                                           |
|                   +-------------------+                                 |
|                   |     OS (mmap)     |  (Demand-paged physical RAM)    |
|                   +-------------------+                                 |
+-------------------------------------------------------------------------+

1. mcache (Thread-Local Cache)

  • Each logical processor (P) in the G-M-P scheduler owns its private mcache.
  • When a goroutine running on P allocates small objects, it satisfies the request directly from mcache.
  • Zero lock contention: Because only one thread (M) accesses a given P at any time, allocation from mcache requires no mutex locks or atomic synchronization, taking only a handful of CPU cycles.

2. mcentral (Central Span Cache)

  • Collects and manages memory spans of a single size class.
  • When a mcache runs out of memory blocks of a specific size, it requests a new span from mcentral.
  • Locking granularity: Each size class has its own mcentral with an independent lock. Goroutines allocating 32-byte objects do not block goroutines allocating 64-byte objects.

3. mheap (Global Heap)

  • Represents the entire virtual memory space allocated to the Go runtime.
  • Manages memory in units of pages (8 KB per page) and tracks page allocation bitmasks.
  • If mcentral runs out of spans, it requests free pages from mheap. If mheap lacks memory, it issues mmap (on Unix) or VirtualAlloc (on Windows) system calls to request virtual address space from the OS kernel.

Size Classes & Allocation Strategy

Go partitions heap allocations into three tiers based on object size:

Category Size Range Allocation Path Performance
Tiny Allocation < 16 bytes (without pointers) Combined into 16-byte blocks in mcache.tiny. Microscopic footprint; avoids internal fragmentation.
Small Allocation 16 bytes to 32 KB Allocated from 67 distinct size classes in mcache. O(1) allocation; lock-free under normal load.
Large Allocation > 32 KB Allocated directly from mheap in whole 8 KB page multiples. Guarded by global mheap lock; triggers GC pacing checks.

The Tiny Allocator

Small objects like 2-byte integer wrappers, string headers, or small closures frequently cause memory fragmentation.

Go's Tiny Allocator coalesces multiple tiny pointer-free allocations into a single 16-byte memory block. New tiny allocations simply advance an internal offset pointer within the 16-byte chunk, reducing metadata overhead and heap fragmentation.

Stack Management: Contiguous Stack Copying

Every goroutine starts with a dynamic stack of just 2 KB (compared to 1 MB–8 MB for traditional OS threads).

+------------------+             +----------------------------------+
|  Old Stack (2KB) |             |        New Stack (4KB)           |
|                  |             |                                  |
|  [Frame 1]       | -- Copy --> |  [Frame 1 (pointers fixed)]      |
|  [Frame 2]       |             |  [Frame 2 (pointers fixed)]      |
|  [Overflow!]     |             |  [Free space for new frames]     |
+------------------+             +----------------------------------+

Why Go Switched From Segmented Stacks

  • Pre-Go 1.4 (Segmented Stacks): When a goroutine exceeded its stack, the runtime allocated a new detached memory segment linked by pointers.
    • The "Hot Split" Problem: If a function call on the boundary executed in a tight loop, the runtime repeatedly allocated and freed stack segments on every loop iteration, degrading performance by over 10x.
  • Go 1.4+ (Contiguous Stacks): When a goroutine stack overflows (detected during the function prologue check morestack):
    1. The runtime allocates a contiguous memory buffer twice the size (e.g. 2 KB -> 4 KB -> 8 KB).
    2. It copies all existing stack frames into the new memory block.
    3. It walks all pointers pointing into the old stack and rewrites them to point to the corresponding addresses in the new stack.
    4. The old stack memory is released.

Maximum goroutine stack size defaults to 1 GB on 64-bit systems and 250 MB on 32-bit systems.

Escape Analysis Under the Hood

The Go compiler uses static analysis called Escape Analysis during compilation to decide whether a variable should be placed on the stack or on the heap.

Stack vs Heap Cost

  • Stack Allocation: Fast. Zero garbage collection tracking. Memory is freed automatically as soon as the function frame unwinds.
  • Heap Allocation: Slower. Allocates via mallocgc, registers pointer references with the garbage collector's write barrier, and incurs CPU scanning costs during mark-and-sweep phases.

Common Escape Triggers

1. Returning a Pointer to a Local Variable

type Config struct {
    Timeout int
}

func NewConfig() *Config {
    c := Config{Timeout: 30}
    return &c // Escapes to heap! Lifetime exceeds function frame.
}

The compiler proves that c is referenced after NewConfig() returns, so c is allocated on the heap.

2. Storing Data in interface{} / any

val := 42
fmt.Println(val) // val escapes to heap!

fmt.Println accepts ...any. Converting a concrete type to an interface value requires packaging the type metadata (itab) and a pointer to the value, causing the value to escape to the heap.

3. Slices With Dynamic or Unknown Size

// Stays on stack (constant size, known at build time)
buf1 := make([]byte, 64)

// Escapes to heap (size is variable and determined at runtime)
buf2 := make([]byte, dynamicSize)

4. Sending Pointers Through Channels

ch <- &data // Escapes to heap: receiver goroutine's lifetime is unknown

Inspecting Escape Decisions

You can inspect the compiler's escape analysis decisions directly using -gcflags:

$ go build -gcflags="-m" main.go
# Or for deeper diagnostic reasoning:
$ go build -gcflags="-m -m" main.go

Sample compiler output:

./main.go:8:2: moved to heap: c
./main.go:12:13: ... argument does not escape
./main.go:13:13: val escapes to heap

Optimizing hot paths by eliminating unnecessary heap escapes is one of the highest-yield performance tunings in production Go systems.