Every go process you ship is quietly running a second program: the garbage collector. It shares your heap, pauses your goroutines at moments of its own choosing, and grows or shrinks the amount of memory your container reports to the kernel. Most of the time that arrangement works so well you can forget it exists. Then a pod gets OOM-killed at 3 a.m. with GOMEMLIMIT unset, or a “reusable” allocation turns out to pin hundreds of megabytes, and understanding what the runtime is actually doing stops being optional.
This post is a guided tour of Go’s memory machinery as it works today: the allocator’s size classes and per-P caches, the structure of a span, what a stack actually is and how it grows, the mechanics of the concurrent mark-and-sweep cycle, and the two runtime variables — GOGC and GOMEMLIMIT — that give you control when the defaults fight your deployment. Everything here applies to the current generation of Go releases (1.21 through 1.25); where behavior changed recently, the text says so explicitly.
The Allocator: Size Classes, Spans, and Per-P Caches
Go’s allocator is a hybrid design that borrows from both thread-caching allocators like Google’s TCMalloc and older arena-style allocators. The core insight is that almost every allocation in real programs is small, so small allocations are worth optimizing aggressively.
The foundation is the size class system. Go maintains a table of 67 predefined sizes, ranging from 8 bytes up to 32KB, spaced so that rounding up wastes only a small, bounded fraction of each object. An allocation request for 24 bytes gets a 24-byte object exactly; a request for 25 bytes rounds up to the 32-byte class. Objects larger than 32KB are “large objects” allocated directly from the heap with page granularity.
Memory itself is carved into spans — contiguous runs of pages (one page is 8KB in the current runtime) dedicated to a single size class. A span serving the 32-byte class holds thousands of 32-byte slots; a span for large objects may be just the object itself. Each span tracks its slots with an allocation bitmap alongside the collector’s mark bitmap, so the allocator and the GC share one picture of what’s live.
The performance trick is in the caching hierarchy. The runtime maintains a page heap (the global pool of spans, managed by the mheap structure), a per-P span cache (mcaches — each logical processor holds partial and full span lists for the size classes it’s actively serving), and a tiny-object allocator (mallocgc‘s small path) that packs micro-objects — 16 bytes and under, no pointers — into blocks fetched from size-class spans in bulk. A goroutine allocating a small object usually ends up bumping a pointer in memory owned by its P, with no lock contention at all. Only when the P’s cache runs dry does it reach into the central mcentral list for that size class, and only when mcentral is empty does the runtime grab fresh pages from mheap, which may involve asking the OS for more memory via mmap.
This is why allocation-heavy Go code doesn’t necessarily show up as lock contention in profiles: most small allocations are effectively contention-free. It’s also why heap fragmentation in Go rarely looks like C++ fragmentation — the size-class system bounds waste per object, and spans are returned to the page heap wholesale once empty.
Stacks: Segmented History, Contiguous Present
Every goroutine starts with a small stack — 2KB in current releases, as it has been since the contiguous-stack redesign — and grows and shrinks dynamically, which is what makes spawning hundreds of thousands of goroutines practical. (The runtime’s stackMin constant in runtime/stack.go pins this number; the OS thread stacks it sits on top of are a different, much larger, fixed allocation.)
The growth mechanism has a history worth knowing. Early Go used segmented stacks: when a goroutine ran out, the runtime allocated a new segment and linked it to the old one. The problem was the “hot split” — a function called across a segment boundary paid the growth check penalty on every single iteration of a hot loop, because the stack kept splitting and merging at the same point. Go 1.3 replaced this with contiguous stacks: when the stack overflows, the runtime allocates a new stack of double the size, copies every frame over, and adjusts pointers. Copying is possible because Go knows exactly where every pointer into the stack lives, thanks to precise stack maps generated by the compiler.
Every function prologue still contains a stack-overflow check — compare the stack pointer against a bound, call into the runtime to grow if needed — but after one growth, the check is nearly free since the stack is now large. Stacks also shrink: during garbage collection, if a goroutine is using less than a quarter of its stack, the runtime can copy it down to reclaim memory.
One practical consequence: deep recursion in Go is more expensive than in languages with fixed stacks, because each growth step copies. A recursive algorithm that ends up with a 1GB stack will have paid for many doublings along the way. If you need a deeply recursive computation, converting it to an explicit heap-allocated work queue avoids repeated copies and gives you explicit control over memory.
Escape Analysis: Why Allocation Is Often a Lie
Here’s the punchline of Go’s memory design: the code most Go programmers write “allocates” far less than they think, because the compiler performs escape analysis at build time. If the compiler can prove a value never outlives the function that created it — no pointer to it escapes the call frame — the value is allocated on the goroutine’s stack instead of the heap. Stack allocation is nearly free: bumping a stack pointer, reclaimed on return, invisible to the garbage collector.
You can see the compiler’s decisions with go build -gcflags='-m'. For example:
type User struct {
Name string
Email string
}
func NewUser(name, email string) *User {
// &User{...} escapes: the returned pointer outlives this frame,
// so the compiler heap-allocates it.
return &User{Name: name, Email: email}
}
func nameLength(u *User) int {
n := len(u.Name)
// nameCopy and n are provably frame-local: stack allocated.
nameCopy := u.Name
return n + len(nameCopy)
}
func main() {
u := NewUser("ada", "ada@example.com") // one real heap allocation
_ = nameLength(u)
}
Run this with go build -gcflags='-m' and the compiler prints its reasoning line by line: the &User{...} escapes to the heap because the pointer outlives the function, while the locals inside nameLength stay on the stack. A common and pleasant surprise: taking a pointer to a local value doesn’t automatically mean heap allocation — it’s the lifetime that decides, not the &. The rule the analysis enforces is simple — stack allocation whenever lifetime is provably frame-local, heap whenever it isn’t.
What actually forces escapes in practice:
- Storing a pointer in a struct field or global that outlives the call.
- Sending values over channels or storing them in interfaces (interface method calls with values of unknown concrete type force indirection).
- Closures that capture variables by reference when the closure outlives the frame.
- Slices and maps that grow beyond their compile-time-provable bounds.
- Calling functions through
reflectorfmton values the compiler can’t fully trace.
None of this means you should contort code to avoid escapes. It means allocation counts in CPU and GC profiles are compiler decisions you can inspect, not a tax automatically levied on every & in your source.
The Collector Itself: Concurrent Tri-Color Mark and Sweep
Go’s GC is a concurrent, tri-color, mark-and-sweep collector — non-generational, non-compacting, and designed to keep pause times in the sub-millisecond-to-low-millisecond range while CPU overhead stays low. Its most distinctive property is what it doesn’t do: it never compacts, which is only possible because Go doesn’t require compaction for locality (its spans already group same-sized objects) and because moving objects would require updating every pointer, including those held by non-Go code via cgo.
The mark phase is where the interesting engineering lives. The tri-color abstraction works like this: objects start white (not yet visited). The GC begins by marking roots gray — the goroutine stacks and globals. It then repeatedly takes a gray object, scans it for pointers, and grays anything white those pointers reference, turning the scanned object black. When no gray objects remain, everything white is garbage, and the sweep phase walks the heap freeing it. The invariant that keeps this correct while the program runs concurrently is that no black object may point directly to a white object without the collector noticing.
Preserving that invariant with a concurrent mutator is the hard part, and Go handles it with two mechanisms:
- Write barriers. During marking, the compiler-inserted write barrier intercepts pointer writes. When a program stores a pointer into an object, the barrier grays the old value the slot held (a “delete” barrier — this is the Yuasa-style deletion barrier) and sometimes the new value (a Dijkstra-style insertion barrier; Go uses a hybrid of both since 1.8). The hybrid barrier’s elegant property is that the mutator never sees a white object disappear that was reachable at the start of the cycle, which lets the GC skip an expensive initial stop-the-world stack re-scan.
- Stop-the-world pauses at cycle boundaries. Two brief STW windows bracket the mark phase: one to enable write barriers and prepare root scanning (sub-millisecond in modern releases), and one to finalize. The actual marking of millions of objects happens concurrently with your goroutines, using about 25% of CPU by default, with background workers picking up the remainder.
Sweeping is even lazier than marking: it happens concurrently and on-demand. When the allocator needs a span’s slots, the span is swept first if it hasn’t been. Dead objects’ memory simply becomes available for reallocation — no separate pass required.
The collector decides when to start a new cycle using a heap-growth target: the heapLive (live heap bytes) times the GC’s growth factor determines the heapGoal. When the live heap grows to the goal, the next cycle triggers. That growth factor is exactly what GOGC controls.
GOGC: The Throughput-Memory Dial
GOGC sets how much the heap may grow between cycles, as a percentage of the live heap. The default, GOGC=100, means the next GC starts when the heap has grown to twice the size of the live heap after the last cycle. Halving it to 50 makes the collector run twice as often — trading CPU for lower memory overhead. Doubling it to 200 runs GC half as often — trading memory for CPU.
The key mental model: GOGC is proportional. Its memory cost scales with your live heap. A service with a 10MB live heap at GOGC=100 peaks around 20MB total. The same service with a 4GB live heap peaks around 8GB — the same GC frequency in relative terms, but vastly more absolute memory held back to keep amortized GC costs low. This proportionality is exactly what breaks containerized deployments: a service with a small live heap but a spiky request pattern (a batch import, a big fan-out) can blow through its container’s memory limit not because the live set is large, but because GOGC’s target scales the heap beyond what the cgroup allows.
Before Go 1.19, the only fixes were lowering GOGC globally (more GC CPU, more latency jitter) or clamping debug.SetMemoryLimit hacks. Go 1.19 added a better tool.
GOMEMLIMIT: The Soft Cap That Changed Container Life
GOMEMLIMIT, introduced in Go 1.19, is a soft memory limit for the total memory managed by the runtime: heap, stacks, and other runtime structures. Set it, and the GC will run more aggressively as the heap approaches the limit — continuously, if necessary — to avoid crossing it. Unlike GOGC, it’s an absolute number: GOMEMLIMIT=4GiB means “aim to keep the Go runtime’s total footprint under 4GiB,” regardless of live-heap size.
The word “soft” matters. The runtime treats the limit as a strong preference, not a hard wall: if live memory genuinely exceeds the limit — you actually need 5GB of live data with a 4GiB cap — the GC will thrash, running nearly continuously in an attempt to free uncollectable memory, before the process is eventually OOM-killed anyway. The limit can’t make garbage collectable; it just trades CPU for delay. For this reason, the recommended practice is to set GOMEMLIMIT to about 90-95% of the container’s memory limit, leaving headroom for non-Go memory in the process (cgo allocations, the kernel’s memory for the process, and the fact that Go’s own accounting doesn’t cover every byte the process maps).
The canonical production configuration for a containerized Go service is now:
# Container memory limit is 2GiB (set in the K8s pod spec)
GOGC=100
GOMEMLIMIT=1800MiB
With this pairing, GOGC=100 governs normal operation (GC runs when the heap doubles, keeping CPU overhead amortized), while GOMEMLIMIT acts as a safety net: if a burst pushes the heap toward 1.8GB, the GC ramps up frequency to keep the process under the cgroup limit rather than getting OOM-killed. The two dials compose rather than conflict — one optimizes steady state, the other bounds the worst case.
Setting it in code rather than the environment uses the runtime/debug package:
import "runtime/debug"
func init() {
debug.SetGCPercent(100) // equivalent to GOGC=100
debug.SetMemoryLimit(1800 << 20) // 1800 MiB, in bytes
}
Both values can be changed at runtime, which enables a pattern worth knowing: services with strong diurnal patterns (low traffic at night, heavy batch processing during the day) can tighten the memory limit or lower GOGC during batch windows, then relax them when the burst ends — all without a restart.
Practical Guidance: Reading GC Profiles and Fixing Real Problems
Most GC tuning questions reduce to one of four scenarios. Each has a distinct diagnosis and fix.
Scenario 1: OOM-killed in a container despite a modest live heap. This is the GOMEMLIMIT poster child. The live heap is fine; the heap goal (live × GOGC factor) exceeds the container limit during bursts. Fix: set GOMEMLIMIT to ~90% of the container limit. Verify with GODEBUG=gctrace=1, which prints one line per cycle showing heap sizes before and after:
GODEBUG=gctrace=1 ./server
# gc 42 @60.104s 2%: 0.062+2.1+0.041 ms clock, ... 448->392->201 MB, 187 MB goal, 8 P
The three numbers (448->392->201 MB) are heap at GC start, heap at GC end, and live heap after collection. The goal is the target the cycle was aiming for. If you see the goal routinely pushing past your container limit before a kill, that’s your smoking gun.
Scenario 2: GC CPU overhead is high (>10-15% in profiles). The program is allocation-churn-heavy — lots of short-lived objects. The fix is usually in application code, not the runtime: profile with pprof‘s alloc_objects and alloc_space views to find the hot allocation sites. Common wins include pre-allocating slices with known capacity (make([]T, 0, n)), reusing buffers with sync.Pool for genuinely hot paths, and restructuring code so escape analysis can keep values on the stack. Note that sync.Pool is a per-P cache that gets cleared partially every GC cycle — it’s for hot-path churn reduction, not a general-purpose object cache.
Scenario 3: Latency spikes correlated with GC cycles. Modern Go’s STW pauses are typically under a millisecond, so a 50ms p99 spike blamed on “GC pauses” is usually something else: write-barrier overhead during marking (more allocations during the cycle means more marking work — but that’s spread across cores, not a pause), or — far more commonly — assists. When a goroutine allocates during the mark phase, the runtime makes it “assist” the collector, doing mark work in proportion to what it allocates. An allocation-heavy request that happens to run during marking pays the assist tax, which shows up as tail latency. The fixes are the same as scenario 2 (reduce allocation churn) plus, if needed, lowering GOGC to shorten the mark phase itself.
Scenario 4: A “small” object graph that’s actually huge. Go’s GC is precise and per-object; it does not generational short-circuiting. A single live object pinning a pointer chain into millions of objects (an unbounded cache without eviction, a global slice that only grows, a long-lived goroutine capturing a big struct by reference) forces the collector to mark all of it, every cycle. The classic culprit is an unbounded map used as a cache — hashicorp/golang-lru or ristretto bound it properly. Heap profiles (go tool pprof -sample_index=inuse_space) reveal who’s holding the live set, and the fix is eviction policy, not GC tuning.
Wrapping Up
Go’s runtime makes a deliberate trade: a non-generational, non-compacting, concurrent collector that sacrifices some theoretical elegance (no generations, no compaction) for practical wins — sub-millisecond pauses, no moving targets for cgo, and an allocator whose size-class design makes small allocations nearly free. On top of that, escape analysis means the heap pressure you think you have is often half of what the compiler already eliminated.
The two dials to remember: GOGC is your steady-state throughput-memory tradeoff, proportional to live heap. GOMEMLIMIT is your absolute safety net, and it belongs in every containerized Go deployment, set just under the cgroup limit. When something’s still wrong after that, the answer is almost never “tune the GC harder” — it’s “profile the allocations and fix the code that’s generating the garbage.”
If you want to go deeper, the official GC guide covers GOGC and GOMEMLIMIT interaction in more depth, the 2018 ISMM keynote remains the best narrative explanation of the collector’s design goals, and GODEBUG=gctrace=1 on your own service will tell you more in ten minutes than any blog post can.