Watching Go's new garbage collector move through the heap

eatonphil2 pts0 comments

Watching Go's new garbage collector move through the heap - The Consensus

The Consensus Weekly<br>Get new deep dives, plus jobs and funding in software infrastructure, free in your inbox every week.Join the Weeklygo<br>Watching Go's new garbage collector move through the heap<br>Go 1.26 made Green Tea the default GC. We observe its cache-friendliness with perf, visualize the heap to see how Go allocates, and poke at a sparse-page problem a non-moving collector like Go's struggles with.By Phil EatonJuly 19, 2026 Focus<br>You are getting early access to this article as a subscriber. Your support makes articles like this possible. Thank you.Go 1.25, released last year, introduced a new garbage collector: Green Tea. And in Go 1.26, released a few months ago, Green Tea became the default. That linked article is excellent. We’ll recap it and take a look at a few programs that benefit the most. And we’ll look at some programs that don’t benefit, that trigger Go’s residual garbage collector bugaboo: its non-moving collector cannot reclaim sparse pages.Taking a step back, Go manages memory by allocating objects of the same size class (an object’s size is rounded up to the nearest size class) within a contiguous chunk (or span in Go terminology) of one or more 8KiB pages. Size-segregated allocation is common in some malloc implementations (like tcmalloc, which Go’s allocator descends from).Let’s observe this happening in Go, and then compare it with C#. We’ll randomly allocate objects of three different sizes (small, medium and large). Then we’ll check their heap addresses, walk the address space and print out when we hit one of our objects, one character printed out for each 32-bytes we walk.First install Go and C#.sudo apt update -y<br>sudo apt-get install -y dotnet-sdk-10.0<br>curl -fsSL https://go.dev/dl/go1.26.0.linux-amd64.tar.gz | sudo tar -C /usr/local -xz<br>export PATH=$PATH:/usr/local/go/bin

Here’s the pseudocode we’re going for.struct Small { a [32]byte }<br>struct Medium { a [64]byte }<br>struct Large { a [128]byte }

constructors = [Small, Medium, Large]<br>live = [] # stop objects from being collected<br>for i in range(100):<br>live.push(new constructors[rand() % len(constructors)])

for pass in [0, 1]:<br>if pass == 1:<br>runtime.gc() # trigger the GC

records = []<br>for obj in live:<br>records.push((runtime.addressof(obj), runtime.typeof(obj), runtime.sizeof(obj)))<br>records.sort(key = r -> r.address)

cell = 32<br>cursor = records[0].address<br>for (addr, typ, size) in records:<br>while cursor addr: # no object of ours here<br>print("."); cursor += cell<br>head = typ.name[0]<br>print(upper(head) + "-" * (size/cell - 1)) # "S" / "M-" / "L---"<br>cursor += size

Let’s build it in Go.package main

import (<br>"bytes"<br>"cmp"<br>"fmt"<br>"math/rand"<br>"reflect"<br>"runtime"<br>"slices"

type (<br>Small struct{ _ [32]byte } // 32 bytes<br>Medium struct{ _ [64]byte } // 64 bytes<br>Large struct{ _ [128]byte } // 128 bytes

type object struct {<br>addr uintptr<br>size int<br>name byte // 'S' / 'M' / 'L'

func main() {<br>allocs := []func() any{<br>func() any { return new(Small) },<br>func() any { return new(Medium) },<br>func() any { return new(Large) },<br>live := make([]any, 100) // keep refs so GC can't reclaim, and so we know each type<br>for i := range live {<br>live[i] = allocs[rand.Intn(len(allocs))]()

for pass := 0; pass 2; pass++ {<br>if pass == 1 {<br>runtime.GC() // Go never moves objects: pass 1 is identical to pass 0<br>objs := make([]object, len(live))<br>for i, o := range live {<br>t := reflect.TypeOf(o).Elem()<br>objs[i] = object{reflect.ValueOf(o).Pointer(), int(t.Size()), t.Name()[0]}<br>slices.SortFunc(objs, func(a, b object) int { return cmp.Compare(a.addr, b.addr) })

fmt.Printf("\n=== pass %d (base 0x%x) ===\n", pass, objs[0].addr)<br>draw(objs)

func draw(objs []object) {<br>const cell, width = 32, 60

last := objs[len(objs)-1]<br>base := objs[0].addr<br>grid := make([]byte, int((last.addr+uintptr(last.size)-base)/cell))<br>for i := range grid {<br>grid[i] = '.'<br>for _, o := range objs {<br>c0 := int((o.addr - base) / cell)<br>grid[c0] = o.name<br>for k := 1; k o.size/cell; k++ {<br>grid[c0+k] = '-'

prev := -1<br>for off := 0; off len(grid); off += width {<br>row := grid[off:min(off+width, len(grid))]<br>if len(bytes.Trim(row, ".")) == 0 { // row holds none of our objects<br>continue<br>if prev >= 0 && off != prev+width {<br>fmt.Println(" ...")<br>fmt.Printf("0x%09x %s\n", base+uintptr(off)*cell, row)<br>prev = off

heapwalk.goRun it and you’ll see something like this.$ go run heapwalk.go

=== pass 0 (base 0xba4841580c0) ===<br>0xba4841580c0 M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-M-<br>0xba484158840 M-M-M-M-....................................................<br>...<br>0xba48415bcc0 ............................SSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSS<br>...<br>0xba4841add40 ..........................L---L---L---L---L---L---L---L---L-<br>0xba4841ae4c0 --L---L---L---L---L---L---L---L---L---L---L---L---L---L---L-<br>0xba4841aec40 --L---L---L---L---L---L---L---L---L---L---

=== pass 1 (base 0xba4841580c0) ===<br>0xba4841580c0...

pass size objs addr byte live

Related Articles