The Go garbage collector isn’t a background process that periodically pauses your application; it’s an integral part of every goroutine, constantly working to reclaim memory without stopping your program.

Let’s see it in action. Imagine this simple Go program:

package main

import (
	"fmt"
	"runtime"
	"time"
)

type Node struct {
	value int
	next  *Node
}

func main() {
	// Allocate some objects
	head := &Node{value: 1}
	current := head
	for i := 2; i < 100000; i++ {
		current.next = &Node{value: i}
		current = current.next
	}

	// Simulate some work and then create a cycle
	time.Sleep(1 * time.Second)
	fmt.Println("Initial allocation complete.")

	// Create a cycle: the last node points back to the head
	current.next = head
	fmt.Println("Cycle created.")

	// Let the GC run and see memory usage
	runtime.GC()
	var m runtime.MemStats
	runtime.ReadMemStats(&m)
	fmt.Printf("Alloc = %v MiB\n", bToMb(m.Alloc))
	fmt.Printf("TotalAlloc = %v MiB\n", bToMb(m.TotalAlloc))
	fmt.Printf("Sys = %v MiB\n", bToMb(m.Sys))
	fmt.Printf("NumGC = %v\n", m.NumGC)

	// Break the cycle and let GC reclaim
	current.next = nil
	fmt.Println("Cycle broken.")
	runtime.GC()
	runtime.ReadMemStats(&m)
	fmt.Printf("Alloc after breaking cycle = %v MiB\n", bToMb(m.Alloc))
	fmt.Printf("TotalAlloc after breaking cycle = %v MiB\n", bToMb(m.TotalAlloc))
	fmt.Printf("Sys after breaking cycle = %v MiB\n", bToMb(m.Sys))
	fmt.Printf("NumGC after breaking cycle = %v\n", m.NumGC)

	time.Sleep(2 * time.Second) // Keep the program alive to observe
}

func bToMb(b uint64) uint64 {
	return b / 1024 / 1024
}

When you run this, you’ll notice that even after breaking the cycle, the Alloc memory might not immediately drop to zero. This is because the GC operates on reachability, and the tricolor marking algorithm is its brain.

The Go GC uses a concurrent, tri-color marking algorithm. Think of memory objects as being in one of three "colors":

  • White: Objects that are potentially garbage. Initially, all objects are white.
  • Gray: Objects that are known to be reachable but whose referents (the objects they point to) have not yet been scanned. These are on the "to-visit" list.
  • Black: Objects that are known to be reachable, and all of their referents have also been scanned. These are confirmed live.

The process starts with the garbage collector identifying all "roots" – these are global variables, stack variables of active goroutines, and registers. All objects directly reachable from these roots are marked gray. The GC then repeatedly picks a gray object, marks it black, and marks all objects it references as gray. This continues until there are no more gray objects. Any object still marked white at the end is unreachable and can be reclaimed.

The "concurrent" part means this marking happens alongside your application code. To prevent the GC from misinterpreting an object that was just about to be freed as garbage, or an object that was garbage as live, Go uses a write barrier. When your application code modifies a pointer, the write barrier intercepts this. If a black object (meaning it’s confirmed live) tries to point to a white object (meaning it’s potentially garbage), the write barrier intervenes and turns that white object gray. This ensures that no live object is accidentally collected.

The surprising part is how the GC manages to be so efficient and non-intrusive. It doesn’t need to stop the entire world. Instead, it performs its marking phase concurrently. The write barrier is crucial here; it’s a small piece of code that runs every time a pointer is written. This barrier ensures that the GC’s view of object reachability remains consistent, even as your application is actively changing it. Without it, the GC might incorrectly assume a white object is garbage when a black object is about to point to it, or vice-versa.

The runtime.GC() call in the example is a hint to the Go runtime that it might be a good time to run the garbage collector, but the GC also runs automatically based on memory allocation thresholds. You’ll see the NumGC counter increment each time a collection cycle completes. When the cycle is broken (current.next = nil), the previously unreachable objects (the majority of the linked list) become white and are then collected in the subsequent GC cycle, leading to a reduction in Alloc.

The next thing you’ll likely encounter is understanding how to tune the garbage collector for specific performance needs, such as adjusting heap size and garbage collection pacing.

Want structured learning?

Take the full Golang course →