Skip to content
academia.sh

Lesson 09 / 16

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.

Published

In this lesson

  • 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
Contents

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).

// 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.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ ↵ Esc navigate · open · close