Garbage Collection
Garbage Collection
Go’s garbage collector frees heap memory you no longer reference. It is not malloc/free with a different name. The boring default is: allocate normally, drop references when you are done, and reuse buffers only after a measurement says allocation is the hot path.
Mental model
You allocate by creating values the compiler cannot keep on the stack: &Ticket{...}, make([]T, n), growing append, converting to any, and so on. You do not free. When no pointer can reach a value, the GC may reclaim it.
GOGC is the target heap growth, as a percent. 100 means “run a collection after the heap grows about 100% since the last one.” Higher GOGC: fewer collections, more memory. Lower GOGC: more collections, a smaller heap. GOGC=off turns the collector off — a debug trick, not a production plan.
runtime.ReadMemStats snapshots counters. They move a little between runs. Use them to see direction (ten thousand tickets appeared; then they left), not to assert an exact byte count in a book.
Worked examples
Case 1: Live pointers keep objects; dropping them lets GC run
Save as tickets_gc.go. Ten thousand tickets stay alive because held points at them. Setting held = nil drops the only reference. runtime.GC() asks for a collection now so the numbers are readable. You rarely call that in a service.
// tickets_gc.go
package main
import (
"fmt"
"runtime"
)
type Ticket struct {
ID int
Table int
Note string
}
func main() {
runtime.GC()
var before runtime.MemStats
runtime.ReadMemStats(&before)
held := make([]*Ticket, 0, 10_000)
for i := range 10_000 {
held = append(held, &Ticket{ID: i, Table: i % 20, Note: "soup"})
}
var after runtime.MemStats
runtime.ReadMemStats(&after)
fmt.Printf("tickets: %d\n", len(held))
fmt.Printf("heap objects gained: %d\n", after.HeapObjects-before.HeapObjects)
held = nil
runtime.GC()
var done runtime.MemStats
runtime.ReadMemStats(&done)
fmt.Printf("gc runs while held: %d\n", after.NumGC-before.NumGC)
fmt.Printf("gc runs after drop: %d\n", done.NumGC-after.NumGC)
}Run:
go run tickets_gc.goOutput (object counts can shift by a few on another machine; the 10000 tickets are the point):
tickets: 10000
heap objects gained: 10001
gc runs while held: 0
gc runs after drop: 1
While held was live, the collector did not need to run. After the drop, one GC pass ran and the extra objects were eligible. You did not call free.
Case 2: GOGC is a dial, not a rewrite
Save as gogc.go. debug.SetGCPercent returns the previous percent. The environment variable GOGC sets the starting value (100 if unset).
// gogc.go
package main
import (
"fmt"
"runtime/debug"
)
func main() {
prev := debug.SetGCPercent(200)
fmt.Println("previous GOGC:", prev)
now := debug.SetGCPercent(200)
fmt.Println("current GOGC:", now)
}Run:
go run gogc.goOutput:
previous GOGC: 100
current GOGC: 200
Run with a tighter target:
GOGC=50 go run gogc.goOutput:
previous GOGC: 50
current GOGC: 200
Leave GOGC at the default until a memory limit or a latency graph says otherwise. GOMEMLIMIT (not shown here) is the modern partner for “do not use more than this.” Do not sprinkle SetGCPercent inside request handlers.
Case 3: Reuse a slice when a profile says so
Save as reuse.go. Filling a fresh slice 50 times allocates. Reusing one backing array with buf = buf[:0] does not. The numbers below are malloc counts from MemStats, not a promise for every Go version.
// reuse.go
package main
import (
"fmt"
"runtime"
)
func fill(buf []int, n int) []int {
buf = buf[:0]
for i := range n {
buf = append(buf, i)
}
return buf
}
func allocEach(n, times int) uint64 {
runtime.GC()
var before runtime.MemStats
runtime.ReadMemStats(&before)
for range times {
_ = fill(nil, n)
}
var after runtime.MemStats
runtime.ReadMemStats(&after)
return after.Mallocs - before.Mallocs
}
func reuse(n, times int) uint64 {
runtime.GC()
var before runtime.MemStats
runtime.ReadMemStats(&before)
buf := make([]int, 0, n)
for range times {
buf = fill(buf, n)
}
var after runtime.MemStats
runtime.ReadMemStats(&after)
_ = buf
return after.Mallocs - before.Mallocs
}
func main() {
fmt.Println("fresh slices mallocs:", allocEach(1000, 50))
fmt.Println("reused slice mallocs:", reuse(1000, 50))
}Run:
go run reuse.goOutput:
fresh slices mallocs: 450
reused slice mallocs: 1
Reuse won because we measured a tight loop. A desk program that builds one order list per request does not need this. sync.Pool is the same idea with concurrency; skip it until a benchmark names allocation as the cost.
Case 4: Setting a memory ceiling with GOMEMLIMIT
Save as memlimit.go. In containerized environments, Go services historically risked getting killed by the operating system OOM killer because GOGC only knows about the relative growth of the live heap, not the container’s hard memory boundary. GOMEMLIMIT sets a soft ceiling: when total memory approaches this limit, the Go runtime triggers garbage collection proactively.
// memlimit.go
package main
import (
"fmt"
"runtime/debug"
)
func main() {
// Query current limit (-1 queries without changing)
prev := debug.SetMemoryLimit(-1)
fmt.Println("initial limit no-limit:", prev < 0 || prev > 1<<60)
// Set soft memory limit to 128 MiB
limit128MiB := int64(128 * 1024 * 1024)
debug.SetMemoryLimit(limit128MiB)
current := debug.SetMemoryLimit(-1)
fmt.Printf("current limit: %d MiB\n", current/(1024*1024))
}Run:
go run memlimit.goOutput:
initial limit no-limit: true
current limit: 128 MiB
You can set this in code with debug.SetMemoryLimit, or externally via the environment variable GOMEMLIMIT=128MiB. Set it to ~90% of your container’s memory limit to provide headroom for the runtime and OS.
The trap
Trap 1: Subslice memory retention
When you slice a small header from a large buffer or payload, the subslice retains a pointer to the entire original backing array. The garbage collector cannot free that array as long as your subslice is reachable.
Save as subslice_leak.go:
// subslice_leak.go
package main
import (
"fmt"
"slices"
)
func main() {
big := make([]byte, 10*1024*1024) // 10 MiB payload
leaky := big[:4]
clean := slices.Clone(big[:4])
fmt.Printf("leaky len=%d cap=%d bytes pinned\n", len(leaky), cap(leaky))
fmt.Printf("clean len=%d cap=%d bytes pinned\n", len(clean), cap(clean))
}Run:
go run subslice_leak.goOutput:
leaky len=4 cap=10485760 bytes pinned
clean len=4 cap=8 bytes pinned
leaky pinned all 10 megabytes of memory for a 4-byte slice. slices.Clone (or copy) copied the 4 bytes into an independent backing array, allowing the 10 MiB payload to be freed.
Trap 2: Treating GC like manual malloc
Treating GC like a poorly hidden malloc leads to two bad desks: either you never allocate (unreadable code, buffer pools for a 20-line tool) or you runtime.GC() after every ticket “to keep memory tidy.” The second makes the process stall for no reason. The first makes every change a puzzle.
The collector is part of the language. Write clear code. Keep references only while you need the values. Measure. Then reuse.
The boring rule
- Drop the last pointer when you are done (
held = nil, end of function, clear a map). That is the free. - Do not call
runtime.GC()in normal request paths. - Leave
GOGCalone until you have a memory or pause graph. - Set
GOMEMLIMITin container environments to prevent unexpected OOM kills. - Use
slices.Cloneorcopywhen keeping a tiny subslice from a large buffer, so the large buffer can be reclaimed. - Reuse slices (
s = s[:0]) and buffers after a benchmark, not after a blog post. MemStatsis a snapshot. Log it in a debug endpoint if you must; do not unit-test exactAllocvalues.
Try this
- In
tickets_gc.go, comment outheld = niland keepruntime.GC(). How doesgc runs after dropchange? The tickets are still reachable. - In
subslice_leak.go, replaceslices.Clonewith manualcopyinto amake([]byte, 4)slice. Verifycapis still tiny. - Run
GOMEMLIMIT=64MiB go run memlimit.goand inspect the initial limit reported before modification. - Run
GOGC=off go run tickets_gc.go. Read aboutGOGC=offingo doc runtime. Do not ship that. - In
reuse.go, changefill(nil, n)to reuse a slice the same wayreusedoes. The two printed numbers should get close.