---
title: 'Garbage Collection Behavior'
source: 'https://academia.sh/en/courses/go-production/garbage-collection-behavior'
course: 'Production Go: Testing, Tooling and Performance'
language: en
updated: '2026-09-14T10:59:33+00:00'
license: 'CC BY-SA 4.0'
objectives:
    - 'Measures across five runs how preallocation, buffer reuse, and the choice between value and pointer affect the allocation count'
    - 'Demonstrates by running that the same allocation count can be reached by two different styles, one readable and one not'
    - 'Distinguishes that lowering the allocation count does not automatically improve the escape analysis decision'
---

# Garbage Collection Behavior

The reduction of allocation pressure is measured with a number: preallocation, buffer reuse, and the choice between value and pointer carry the same work's allocation count from four to one, from four to zero, from one to sixty-five. Limiting measurement: a number going down is not always an improvement — a style that harms readability can reach the same number too.

The previous lesson measured whether a value **moves to the heap or not** as a
one-time decision made at compile time. But a program runs not once but thousands of
times, and every value that goes to the heap must eventually be cleared out of it
too. This lesson questions the load of that clearing: how many values in total do
different styles performing the same work send to the heap, and how does that number
go down with certain techniques?

Garbage collection itself — reachability, the root set, the mark-and-sweep steps,
the fact that a pause is proportional to the live objects — was established as a
concept in the Operating System Concepts course (M01/K05), and is not repeated here.
This lesson measures not the theory but the **result**: sending fewer values to the
heap means less work for the collector. Pause duration, the amount of memory
reclaimed, and generation size are never written in this lesson — all depend on the
environment; the only thing counted is the **allocation count**.

This narrowing's rationale comes from the earlier course: that course established
that the collector's work is proportional to live objects — the more objects sit on
the heap, the longer scanning it takes. This lesson looks at that relationship's
input end: if a program sends **fewer** objects to the heap, there is less to scan.
The connection is indirect — this lesson never measures the pause — but the
direction is clear: if the allocation count goes down, the collector's work tends to
go down too. What this lesson measures is that decrease's input side; how the
collector processes it is the earlier course's subject.

## Allocation Pressure: Three Techniques, One Measurement

"Allocation pressure" describes how many new values a program sends to the heap per
unit of time. If pressure is high, the collector has to run more often; if it is
low, more rarely. This lesson measures pressure not through duration but through
**count**: how many values a single run of the same work allocates. Three
techniques lower this number — preallocation, buffer reuse, value/pointer choice —
and a fourth example shows that this number alone is not enough.

All four share the same measurement pattern: a function is defined, a comparative
measurement function that calls that function is written, and this measurement is
run five times, counting how many distinct values come out. Unlike the previous two
lessons, here it is not a single value but four separate comparisons standing side
by side — each showing a before and after with its own pair (raw/preallocated,
new/reused, value/pointer).

```go
// measurement.go — the effect of three techniques on the allocation count, across five runs
package main

import (
	"fmt"
	"strconv"
	"testing"
)

type Record struct {
	Name   string
	Amount int
}

const recordCount = 64

var sumSink, lengthSink, recordSink int

// --- a) preallocation ---

//go:noinline
func buildBatch(n int) []int {
	var d []int
	for i := 0; i < n; i++ {
		d = append(d, i)
	}
	return d
}

//go:noinline
func buildBatchPrealloc(n int) []int {
	d := make([]int, 0, n)
	for i := 0; i < n; i++ {
		d = append(d, i)
	}
	return d
}

func benchBatchRaw(b *testing.B) {
	for i := 0; i < b.N; i++ {
		sumSink += len(buildBatch(recordCount))
	}
}
func benchBatchPrealloc(b *testing.B) {
	for i := 0; i < b.N; i++ {
		sumSink += len(buildBatchPrealloc(recordCount))
	}
}

// --- b) buffer reuse ---

//go:noinline
func buildLine(n int) []byte {
	var buf []byte
	for i := 0; i < n; i++ {
		buf = append(buf, strconv.Itoa(i)...)
		buf = append(buf, ' ')
	}
	return buf
}

//go:noinline
func buildLineReused(buf []byte, n int) []byte {
	buf = buf[:0]
	for i := 0; i < n; i++ {
		buf = append(buf, strconv.Itoa(i)...)
		buf = append(buf, ' ')
	}
	return buf
}

var sharedBuffer = make([]byte, 0, 8)

func benchBufferNew(b *testing.B) {
	for i := 0; i < b.N; i++ {
		lengthSink += len(buildLine(16))
	}
}
func benchBufferReused(b *testing.B) {
	for i := 0; i < b.N; i++ {
		sharedBuffer = buildLineReused(sharedBuffer, 16)
		lengthSink += len(sharedBuffer)
	}
}

// --- c) value vs pointer choice ---

//go:noinline
func buildValueSlice(n int) []Record {
	d := make([]Record, 0, n)
	for i := 0; i < n; i++ {
		d = append(d, Record{Name: "x", Amount: i})
	}
	return d
}

//go:noinline
func buildPointerSlice(n int) []*Record {
	d := make([]*Record, 0, n)
	for i := 0; i < n; i++ {
		d = append(d, &Record{Name: "x", Amount: i})
	}
	return d
}

func benchValueSlice(b *testing.B) {
	for i := 0; i < b.N; i++ {
		recordSink += len(buildValueSlice(recordCount))
	}
}
func benchPointerSlice(b *testing.B) {
	for i := 0; i < b.N; i++ {
		recordSink += len(buildPointerSlice(recordCount))
	}
}

// --- d) convoluted style: reaching the same number another way ---

//go:noinline
func buildBatchConvoluted(n int) []int {
	capacity := 0
	for counter := 0; counter < n; counter++ {
		capacity++
	}
	d := make([]int, 0, capacity)
	for i := 0; i < n; i++ {
		d = append(d, i)
	}
	return d
}

func benchBatchConvoluted(b *testing.B) {
	for i := 0; i < b.N; i++ {
		sumSink += len(buildBatchConvoluted(recordCount))
	}
}

func fiveRuns(f func(*testing.B)) (int64, int) {
	values := map[int64]bool{}
	var first int64
	for i := 0; i < 5; i++ {
		r := testing.Benchmark(f)
		values[r.AllocsPerOp()] = true
		if i == 0 {
			first = r.AllocsPerOp()
		}
	}
	return first, len(values)
}

func line(name string, f func(*testing.B)) {
	allocs, distinctValues := fiveRuns(f)
	fmt.Printf("%-30s allocs=%-3d distinct values across five runs=%d\n", name, allocs, distinctValues)
}

func main() {
	fmt.Println("-- allocation pressure: how technique changes the allocation count --")
	line("unpreallocated slice", benchBatchRaw)
	line("preallocated slice", benchBatchPrealloc)
	line("new buffer each call", benchBufferNew)
	line("reused buffer", benchBufferReused)
	line("value slice", benchValueSlice)
	line("pointer slice", benchPointerSlice)
	line("convoluted style (same number)", benchBatchConvoluted)
}
```

```
-- allocation pressure: how technique changes the allocation count --
unpreallocated slice           allocs=4   distinct values across five runs=1
preallocated slice             allocs=1   distinct values across five runs=1
new buffer each call           allocs=4   distinct values across five runs=1
reused buffer                  allocs=0   distinct values across five runs=1
value slice                    allocs=1   distinct values across five runs=1
pointer slice                  allocs=65  distinct values across five runs=1
convoluted style (same number) allocs=1   distinct values across five runs=1
```

**PM5.** All seven functions are marked with `//go:noinline` — the language's own
compilation directive, telling the compiler not to inline this function at its call
site. The reason is the same as the previous lesson: if a function is inlined at its
call site, the compiler may see that the caller uses the returned value immediately
and discards it, and this can hide the allocation behavior the comparison wants to
measure. The directive ensures the four techniques' allocation counts are measured
**inside their own body**, without mixing into any caller.

`sumSink`, `lengthSink`, and `recordSink` stand as three package-level variables,
and each benchmark function writes its own returned value into one of
them. The purpose of this is to "use" the result: if a function's return value is
never written anywhere, the compiler may treat the entire call as unnecessary and
remove it, and then what gets measured would not be the function itself but an
empty loop. Writing the result into a package removes this risk — the call
genuinely happens and genuinely allocates.

## Reading the Numbers

**Preallocation (a):** `buildBatch` reaches 64 elements by appending to an empty
slice one at a time; the slice grows every time its capacity fills, and each growth
is a reallocation. This growth triggers four times. `buildBatchPrealloc` appends
the same 64 elements to a slice opened from the start at the right capacity with
`make([]int, 0, 64)` — growth never triggers; the single allocation belongs only to
the first `make` call. The drop from four allocations to one shows that growth is
unnecessary in cases where the capacity is **known in advance**.

**Buffer reuse (b):** `buildLine` opens a `[]byte` starting from zero on every call
and grows it four times by appending 16 pieces to it. `buildLineReused` instead
shrinks a single buffer stored at the package level to zero length with `buf[:0]`
on every call and refills it; since the capacity has already reached a sufficient
size from previous calls, a new allocation is never needed. The drop from four to
zero is sharper than in the previous technique — because here it is not only the
growth count that disappears, but **the first allocation on every call** too.

This zero count has a precondition: the buffer's capacity is not exceeded on any of
the successive calls. `sharedBuffer` opens with an eight-byte capacity but grows on
its own during the first few calls, because `buildLineReused` also uses `append`,
and growth is unavoidable when capacity falls short. Zero coming out in all five
runs shows that this growth happens **once, on the first call**, and never repeats
after that — once capacity reaches a sufficient size, every call of the same size
can reuse it. If the input size varied (sometimes 16, sometimes 1600 pieces),
growth would trigger again and again and the drop to zero would disappear.

**Value/pointer choice (c):** `buildValueSlice` keeps 64 `Record` values in a
**single** contiguous slice — all elements are part of the same allocation, the
total allocation count is one. `buildPointerSlice` instead sends each element to
the heap separately with `&Record{...}`: one allocation for the slice itself,
sixty-four allocations for each of the elements — sixty-five in total. The
difference between the two is much larger than in the earlier techniques, because
what changes here is not the growth pattern but **how many separate objects**
exist.

This difference connects directly to the previous lesson: the `&Record{...}` style
is the same pattern as `returnsAddress`, measured in the previous lesson — taking a
value's address pushes it to the heap. The difference here is scale: the previous
lesson measured a single value's fate; here the same decision repeats sixty-four
times and its effect compounds. Choosing a pointer slice instead of a value slice
is a decision commonly seen in real codebases — usually to avoid copying large
structs. This measurement shows that gain has a cost: avoiding copying raises the
allocation count. Which cost is worth which gain is a question this lesson cannot
measure — it depends on factors like the struct's size, how often it is copied,
and how many references remain, and it varies from case to case.

## What The Three Techniques Have In Common

There is a shared idea behind all three techniques: **allocating large enough once
produces less work than allocating small many times**. Preallocation applies this
when the capacity is known in advance; buffer reuse applies this by learning the
capacity once and reusing it again and again; the value slice applies this by
keeping elements contiguous instead of separate. All three push in the same
direction — reducing the count of separate allocations — but their preconditions
differ: one requires the capacity to be **known in advance**, one requires the
buffer to be **shareable**, one requires the elements to be able to **stay
contiguous**. If one of these preconditions does not hold, the technique cannot be
applied — if the capacity is not known in advance, what to write into `make` stays
uncertain; if the buffer is accessed concurrently, sharing it leads to a race
condition; if elements are held with different lifetimes, a contiguous slice holds
them together unnecessarily.

## Limiting Measurement: A Number Going Down Is Not Automatically An Improvement

The fourth row, `buildBatchConvoluted`, reaches the **same** number as
`buildBatchPrealloc` — one. But the two functions' bodies are not the same:
instead of taking the capacity directly as `n`, `buildBatchConvoluted`
**recomputes** it with a separate counter loop that runs `n` times and writes the
result into a variable named `capacity`. This loop produces no new information —
`capacity` always comes out equal to `n` — but it makes someone reading the source
ask "why is there a separate computation, is `n` not enough."

**PM6.** This is one of the course's most important warnings: two styles reached
the same allocation count, but one is readable and one is not. The allocation
count alone does not say which code is **better** — it only says how many values
went to the heap. The unnecessary loop in `buildBatchConvoluted` added no
allocation, because it only operates on an integer (integers of this size stay on
the stack); but it harmed readability and cost the reader's time. When a number
goes down or stays constant, the question that must be asked is "what did this
change come at the cost of" — not only "what did the number become."

This limit connects to the limit in the escape analysis lesson too: the decision
of whether a value goes to the heap was the compiler's decision, not the code's
intent. A similar distinction holds here — code written to lower the allocation
count can genuinely lower the number, but this does not explain **why** it went
down. Someone reading `buildBatchConvoluted` can only understand that the low
number comes from `make` being called with the right capacity, and that the loop
itself serves no purpose, by tracing the code line by line — the allocation count
itself does not make this distinction.

This example is not made up; it is a scaled-down form of a pattern seen in real
codebases: a developer makes a change to lower the allocation count, the number
genuinely goes down, and the change is counted "successful" — but which **part**
of the change lowered the number is never questioned. `buildBatchConvoluted`
embodies this trap: whether the unnecessary loop stays or is removed, the
allocation count stays at one, because what determines the number is `make`'s
capacity argument, not the loop. The measurement tool does not make this
distinction; the person who does is the one reading the code and tracing which
line corresponds to which number.

## Summary

- Preallocation lowered the allocation count from four to one: if the capacity is
  known in advance, the slice's growth steps are unnecessary.
- Buffer reuse lowered the allocation count from four to zero: zeroing and
  refilling a shared buffer does not require a new allocation on every call.
- The value slice stayed at one allocation, the pointer slice rose to sixty-five:
  opening a separate object for each element is far more expensive than keeping
  them in a single contiguous store.
- The same allocation count (one) was reached two different ways — one a readable
  style that calls `make` directly with the right capacity, the other an
  unreadable style that adds an unnecessary computation.
- A falling or constant allocation count alone is not proof of an improvement;
  which change affects the number must be read from the code itself. The
  allocation count is a **run** fact — all three styles were told apart only by
  running them.
- The three techniques share the same basis: allocating large enough once
  produces less work than allocating small many times; each technique cannot be
  applied without its own precondition (known capacity, a shareable buffer,
  elements that can stay contiguous).

## Next Step

This lesson stayed limited to the information of counting how many values a
function allocates over how many calls — only a program's memory dimension. But a
real program's runtime contains more than this: which goroutine started when, how
many ran concurrently, which event happened in which order — none of this is
captured by an allocation count. The next lesson looks at this broader record —
which events an execution trace counts, and how the event counts produced by two
different setups are compared.
