The Garbage Collector Nobody Talks About
I’ve spent the better part of a decade watching Go’s garbage collector evolve from something that would routinely pause your application for 100+ milliseconds to the remarkably predictable system we have today. The transformation has been quiet, methodical, and honestly doesn’t get the recognition it deserves from most developers who just expect their memory to be managed without thinking about it.

Go’s current tricolor concurrent mark-and-sweep collector is the result of years of careful engineering. The old stop-the-world collectors from early Go? Gone. Today’s implementation runs alongside your application threads and keeps pause times under 1 millisecond for most workloads. This isn’t marketing speak. I’ve measured it across production systems handling millions of requests daily.
The tricolor algorithm works by marking objects as white (potentially unreachable), grey (reachable but not yet scanned), or black (reachable and scanned). The collector starts from root objects and propagates through the object graph, eventually freeing white objects that remain unmarked. What makes Go’s implementation special is how it handles the write barrier during concurrent marking, making sure that newly allocated objects during collection cycles don’t get incorrectly freed.

Stack vs Heap: Where Go Makes Smart Decisions
Go’s escape analysis deserves way more credit than it gets. The compiler does sophisticated analysis to figure out whether variables can live on the stack or must be allocated on the heap. This decision completely changes both performance and garbage collection pressure, but most developers have no idea how it works.
Variables that escape to the heap usually do so for predictable reasons: they’re returned from functions, assigned to interface values, or their addresses are taken and stored in heap-allocated structures. The compiler plays it safe, sometimes allocating to the heap when stack allocation might have worked. I’ve seen cases where refactoring code to avoid unnecessary escapes reduced GC pressure by 40%.
Stack allocation in Go is incredibly efficient because stacks are segmented and growable. When a goroutine’s stack runs out of space, the runtime allocates a new, larger stack and copies the existing data. This happens behind the scenes, but understanding it helps explain why Go can spawn millions of goroutines without exhausting memory. Each goroutine starts with a 2KB stack that grows as needed, a design choice that balances memory efficiency with performance.
The GOGC Tuning Game Everyone Plays Wrong
The GOGC environment variable controls when garbage collection triggers, but I consistently see teams either ignoring it completely or setting it to random values without understanding what happens next. GOGC is the percentage of heap growth that triggers the next collection cycle. The default value of 100 means the collector runs when the heap doubles in size since the last collection.
Setting GOGC higher reduces collection frequency but increases memory usage and potentially pause times. Setting it lower does the opposite. I’ve found that most long-running services work better with values between 50 and 200, depending on their allocation patterns and memory constraints. Services with steady-state memory usage often perform better with higher GOGC values, while applications with bursty allocation patterns need more frequent collections.
Here’s the thing: GOGC tuning should be driven by actual measurements, not gut feelings. Go’s runtime gives you detailed GC statistics through debug.ReadGCStats() and the GODEBUG environment variable. I’ve seen teams achieve 20% performance improvements just by monitoring their allocation patterns and adjusting GOGC accordingly.
Memory ballasting is controversial but can be effective for applications with predictable memory usage. By allocating a large chunk of memory that never gets used, you can effectively increase the heap size baseline, reducing collection frequency. This technique works because the GC triggers based on heap growth percentage, not absolute size.
Finalizers and Weak References: The Sharp Edges
Go’s runtime.SetFinalizer gives you a mechanism for cleanup when objects become unreachable, but it’s a tool that demands respect. Finalizers run in a separate goroutine after garbage collection, with no guarantees about timing or order. They can resurrect objects, create reference cycles, and seriously complicate the collector’s job.
I’ve debugged applications where excessive finalizer usage caused memory leaks because finalizers themselves prevented objects from being collected. The finalizer queue can become a bottleneck, particularly in applications that allocate many objects requiring cleanup. The runtime has to track finalized objects separately, adding overhead to both allocation and collection.
Go doesn’t have weak references, and that’s a deliberate design choice. It occasionally frustrates developers coming from other languages. Weak references would complicate the garbage collector and the memory model significantly. Instead, Go pushes you toward explicit lifecycle management through context cancellation and cleanup functions.
Memory Allocator Internals: Beyond the Basics
Go’s memory allocator builds on TCMalloc concepts but adapts them for garbage-collected environments. The allocator uses size classes to reduce fragmentation, with separate handling for small objects (less than 32KB), large objects, and tiny objects (less than 16 bytes). Understanding these categories helps explain allocation performance characteristics.
Small object allocation uses a hierarchical approach: thread-local caches (mcache) for lock-free allocation, central caches (mcentral) for refilling thread caches, and a global heap (mheap) as the ultimate source. This design minimizes lock contention while maintaining reasonable memory efficiency. Each thread cache contains spans of memory pages organized by size class.
Large object allocation bypasses the size class system entirely, allocating directly from the global heap. These allocations cost more but happen less frequently in well-designed applications. The runtime tracks large objects separately for garbage collection, scanning them directly rather than through size class spans.
The relationship between Go’s allocator and the operating system involves careful management of virtual memory. Go requests memory from the OS in large chunks and manages it internally, releasing memory back to the OS during garbage collection when possible. The GOMEMLIMIT environment variable, introduced in Go 1.19, gives applications a soft memory limit, helping the garbage collector make better decisions about when to return memory to the OS.
After years of working with Go’s memory management across all kinds of production environments, I’m convinced that understanding these internals pays off. The abstractions work well for most use cases, but when performance matters or you’re debugging memory issues, knowing how the machinery operates makes all the difference. If you’ve had different experiences with Go’s memory management or found effective tuning strategies, I’d be interested to hear about them.