Understanding Go's memory management is crucial for writing efficient programs. Go uses automatic garbage collection, but understanding how it works helps you write better code.
Go uses a concurrent, tri-color mark-and-sweep garbage collector.
Garbage collection exists to provide automatic memory management (no manual memory allocation/deallocation), safety (prevents memory leaks and use-after-free bugs), and productivity (developers focus on logic, not memory management).
Go's GC operates using:
Tri-color marking
Tri-color marking enables concurrent execution (GC runs alongside program execution), incremental collection (doesn't stop the world for long), and efficiency (only marks reachable objects).
GC phases
Concurrent GC matters because it provides low latency (program continues running during GC), better throughput (better overall performance), and responsiveness (applications stay responsive).
GC tuning
# Set GC target percentage (default: 100)
GOGC=200 go run main.go # More aggressive (less frequent GC)
# Disable GC (not recommended)
GOGC=off go run main.go
GC tuning allows you to trade memory for CPU (or vice versa), adjust for latency-sensitive applications, and optimize for throughput.
Go manages memory allocation automatically, but understanding allocation helps optimize.
Allocation works through stack allocation (fast, automatic cleanup), heap allocation (managed by GC, slower), and compiler decisions (compiler decides stack vs heap).
Allocation matters because stack allocation is faster (performance), heap allocations trigger GC (GC pressure), and stack is limited while heap is larger (memory usage).
Allocation patterns
// Stack allocation (likely)
func stackAlloc() {
x := 42 // Allocated on stack
// x automatically freed when function returns
}
// Heap allocation (likely)
func heapAlloc() *int {
x := 42 // May escape to heap
return &x // Returned pointer forces heap allocation
}
Escape analysis determines whether values "escape" the function. The compiler analyzes this, and escaped values must be on the heap.
Understanding stack vs heap allocation is key to performance.
Stack allocation
Stack allocation is fast because it's just incrementing a pointer. No complex memory management is needed.
Heap allocation
Heap allocation is slower because it requires finding free memory, tracking allocations, garbage collection, and dealing with potential fragmentation.
Escape analysis
The compiler determines if a value escapes to the heap:
// Stays on stack
func stackExample() int {
x := 42
return x // Value returned, not pointer
}
// Escapes to heap
func heapExample() *int {
x := 42
return &x // Pointer returned, x escapes
}
Escape analysis matters because stack allocation is much faster (performance), heap allocations increase GC work (GC pressure), and understanding escapes helps optimize code.
Viewing escape analysis
go build -gcflags="-m" main.go
Output shows which values escape:
./main.go:10:6: moved to heap: x
Escape analysis determines whether values can be allocated on the stack or must be on the heap.
When values escape
Returning pointers
func escape1() *int {
x := 42
return &x // x escapes to heap
}
Storing in data structures
func escape2() {
m := make(map[int]*int)
x := 42
m[0] = &x // x escapes to heap
}
Interface values
func escape3() interface{} {
x := 42
return x // x escapes (interface{} can hold any type)
}
Closures
func escape4() func() int {
x := 42
return func() int {
return x // x escapes (captured by closure)
}
}
These values escape because they must outlive the function (lifetime), multiple goroutines or functions need access (shared access), and interface values need heap allocation (dynamic types).
Preventing escapes
// ✅ GOOD: Return value, not pointer
func noEscape() int {
x := 42
return x // Stays on stack
}
// ❌ BAD: Return pointer
func escape() *int {
x := 42
return &x // Escapes to heap
}
Preventing escapes helps with performance (stack allocation is faster), reduces GC work (less work for garbage collector), and improves memory management (stack memory is automatically freed).
1. Reduce allocations
// ❌ BAD: Many allocations
func process(items []Item) {
for _, item := range items {
result := processItem(item) // Allocation each iteration
// ...
}
}
// ✅ GOOD: Reuse buffer
func process(items []Item) {
var buffer bytes.Buffer // Allocate once
for _, item := range items {
buffer.Reset() // Reuse
processItemToBuffer(item, &buffer)
}
}
2. Use sync.Pool for temporary objects
var bufferPool = sync.Pool{
New: func() interface{} {
return new(bytes.Buffer)
},
}
func process() {
buf := bufferPool.Get().(*bytes.Buffer)
defer bufferPool.Put(buf)
buf.Reset()
// Use buf
}
3. Pre-allocate slices
// ❌ BAD: Many reallocations
func buildSlice() []int {
var result []int
for i := 0; i < 1000; i++ {
result = append(result, i) // May reallocate
}
return result
}
// ✅ GOOD: Pre-allocate
func buildSlice() []int {
result := make([]int, 0, 1000) // Pre-allocate capacity
for i := 0; i < 1000; i++ {
result = append(result, i) // No reallocation
}
return result
}
4. Avoid unnecessary pointer returns
// ✅ GOOD: Return value
func getValue() int {
return 42 // Stack allocation
}
// ❌ BAD: Unnecessary pointer
func getValue() *int {
x := 42
return &x // Heap allocation
}
5. Use value receivers for small structs
// ✅ GOOD: Value receiver (small struct)
type Point struct {
X, Y int
}
func (p Point) Distance() float64 {
return math.Sqrt(float64(p.X*p.X + p.Y*p.Y))
}
// ⚠️ CONSIDER: Pointer receiver for large structs
type LargeStruct struct {
// Many fields
}
func (s *LargeStruct) Process() {
// ...
}
6. Profile memory usage
go test -memprofile=mem.prof
go tool pprof mem.prof
7. Monitor GC
import "runtime"
func printGCStats() {
var m runtime.MemStats
runtime.ReadMemStats(&m)
fmt.Printf("Alloc: %d KB\n", m.Alloc/1024)
fmt.Printf("TotalAlloc: %d KB\n", m.TotalAlloc/1024)
fmt.Printf("NumGC: %d\n", m.NumGC)
}
8. Use strings.Builder for string concatenation
// ❌ BAD: Many allocations
func buildString(parts []string) string {
result := ""
for _, part := range parts {
result += part // New string each time
}
return result
}
// ✅ GOOD: Builder
func buildString(parts []string) string {
var builder strings.Builder
builder.Grow(len(parts) * 10) // Pre-allocate
for _, part := range parts {
builder.WriteString(part)
}
return builder.String()
}
Understanding memory management helps you write efficient Go code. Use profiling to identify allocation hotspots, and apply these optimization techniques where they matter most.