skip to content

How do you confirm Go's garbage collector freed an object your program was still using?

level: seniorimportance: should knowfreq 26%

answer

  1. suspect your program before the runtime
  2. the marker follows pointers, not numbers
  3. make freed memory obviously wrong
  4. a second mark pass with the world stopped
  5. silent verification means your reachability is the bug

basics

~20 s

Assume the program hid the pointer, not that the collector lost it. Reproduce under GODEBUG=clobberfree=1 to make the use-after-free loud, then GODEBUG=gccheckmark=1: if it never panics, marking was complete and your unsafe or cgo code dropped the last Go pointer.

solid answer

~50 s

First, invert the prior: a collector losing a reachable object is a once-a-decade runtime bug, while a program hiding a pointer from the marker is an ordinary Tuesday. Go's marker follows only **real Go pointers in typed memory**, located through each type's pointer map, starting from globals and goroutine stacks. An address kept in a `uintptr`, written into a `[]byte`, or held only in C memory keeps nothing alive. So I reproduce under `GODEBUG=clobberfree=1`, which overwrites freed objects with junk and turns a silent use-after-free into an immediate failure; I run `go vet` for its `unsafeptr` analyzer and build with `-race`, which enables `checkptr`; then I stress under `GODEBUG=gccheckmark=1`, which re-marks with the world stopped and panics if concurrent marking missed anything reachable. A silent run there means the marker is fine and my reachability is the bug.

code

go · 10 lines
go
// BUG: a uintptr is an integer, so it keeps nothing alive.
addrs := make([]uintptr, len(nodes))
for i, n := range nodes {
	addrs[i] = uintptr(unsafe.Pointer(n))
}

// FIX: keep real pointers reachable for as long as addrs is used.
keep := append([]*node(nil), nodes...)

nodes = nil // safe now: keep still holds a real *node for every entry

go deeper

for a junior

Know the rule that decides it: an object is alive while a real Go pointer to it can be reached from a variable or a field. Saving an address as a number does not count, and plain Go code without unsafe or cgo cannot hit this.

for a middle

Explain what the marker can see — roots plus real pointers located through each type's pointer map — and name the places a pointer becomes invisible: a uintptr, a byte slice written through unsafe, or memory held only on the C side.

for a senior

Demonstrate the investigation as an experiment: clobberfree to make the failure immediate, vet and a race build to catch unsafe misuse, gccheckmark to test the marker itself, and a clear reading of what a silent verification pass proves.

for a principal

Own the policy question: where unsafe and cgo are allowed to live in the codebase, who reviews them, and whether the performance they buy justifies a bug class that only shows up as rare corruption in production.

## Frame the question correctly first "The garbage collector freed something I was still using" is almost always the wrong diagnosis, and saying so — and then proving it — is the answer an interviewer is looking for. Go's collector has been exercised by every Go program ever run; a genuine lost-object bug in it is extraordinarily rare. What is common is a program that has, without meaning to, made an object unreachable *by Go's rules* while still using the memory. ## What the marker can actually see Marking starts from the roots: package-level variables and every goroutine's stack. From there it follows pointers, and it knows which words in a block of memory are pointers because the compiler emitted a **pointer map** for each type. That gives you the exact list of places a pointer can hide from the collector: - **A `uintptr`.** It is an integer. The collector does not follow integers, so storing an address as a `uintptr` and dropping every real pointer makes the object garbage. Because today's Go never moves objects, the number keeps *looking* valid, which is precisely why the bug is intermittent instead of instant. - **Memory the collector does not scan for pointers.** A `[]byte` has no pointer map; if you write a pointer into it through `unsafe`, nothing there is followed. - **Memory outside the Go heap.** An address held only in C memory across a cgo boundary, or in a mapped region you manage yourself, is invisible. The cgo pointer rules exist for exactly this reason: C may use a Go pointer for the duration of the call, and must not retain it afterwards. - **Misused `unsafe.Pointer` conversions.** The `unsafe.Pointer` documentation lists the valid conversion patterns; outside them, an "equivalent" rewrite can leave a window where no real pointer to the object exists. ## The diagnostic ladder **1. Make the failure loud and early.** `GODEBUG=clobberfree=1` makes the collector overwrite a freed object's contents with junk. A use-after-free that used to read stale-but-plausible data now reads obvious garbage and fails right at the read, close to the cause. Pair it with an aggressive collection rate so freed memory turns over quickly during the repro. **2. Let the toolchain look for the usual suspects.** `go vet` includes the `unsafeptr` analyzer, which flags invalid conversions of `uintptr` back to `unsafe.Pointer`. Building with `-race` additionally turns on the compiler's `checkptr` instrumentation (also available via `-gcflags=all=-d=checkptr`), which catches unsafe pointer arithmetic and misaligned conversions at runtime. These two together find most of this bug class without any theory about the collector. **3. Test the marker itself, and expect it to be innocent.** `GODEBUG=gccheckmark=1` makes the runtime perform a second marking pass with the world stopped and panic if that pass finds a reachable object that concurrent marking missed. This is the direct experiment for "did the collector lose something". Run your stress workload under it long enough to cover the paths that fail. The interpretation matters more than the command: - **gccheckmark panics** → concurrent marking really did miss a reachable object. That is a runtime-level finding, and the next step is a minimal reproducer and an upstream report, not a workaround in your code. - **gccheckmark stays silent while corruption continues** → marking is complete, so the object genuinely is unreachable by Go's rules at the moment it is collected. The bug is in your program's reachability: something is holding only an address, not a pointer. **4. Remove concurrency from the picture if you still doubt it.** `GODEBUG=gcstoptheworld=1` runs collections stop-the-world. If the corruption survives that, nothing about concurrent marking is involved and you are firmly back in your own code. ## The kind of code that causes it The classic shape is an in-memory graph job that builds a compact side table of node addresses as `uintptr` values and then lets the real `*node` references go. That table keeps nothing alive. As soon as the last real `*node` reference goes away, every entry is a dangling number. The fix is not clever: keep the real pointers. Hold a `[]*node`, or keep the nodes in a reachable map or slice for as long as the side table is used. The same rule applies at the cgo boundary — the Go side must retain a real pointer to any Go memory C is going to touch. ## What good looks like in the answer A strong candidate states the prior ("the collector is probably right"), names precisely what the marker can and cannot see, reaches for `clobberfree` and `gccheckmark` as *experiments that discriminate between two hypotheses* rather than as trivia, and ends on the boring fix: an object is alive in Go exactly when a real Go pointer to it is reachable, and no amount of address arithmetic changes that.

  • Why doesn't a uintptr keep the object it points at alive?
    Because it is an integer. The collector scans typed memory using each type's pointer map and follows only real pointers, so an address stored as a number is invisible to marking and the object becomes garbage. Since current Go never relocates heap objects the number still looks plausible afterwards, which is why the resulting corruption is intermittent rather than immediate.
  • Your stress run under GODEBUG=gccheckmark=1 never panics, but the corruption persists. What does that tell you?
    That concurrent marking found everything reachable, so the collector is not losing the object. The object really is unreachable by Go's rules when it is collected, which means your code dropped its last real Go pointer while still using the memory through an unsafe alias, a `uintptr`, or an address retained on the C side. Investigate there, not in the runtime.
  • Which toolchain checks would you run before blaming the collector at all?
    `go vet` for its `unsafeptr` analyzer, which flags invalid conversions of `uintptr` back to `unsafe.Pointer`, and a `-race` build, which also enables the compiler's `checkptr` instrumentation for unsafe pointer arithmetic and alignment. Between them they catch most of this bug class cheaply, before you start running collector-level experiments.
  • What makes this class of bug so intermittent in production?
    Nothing forces the freed memory to be reused promptly. Sweeping only marks the slot free and does not scrub it, so a dangling alias may read correct-looking data for hours until an allocation lands in that slot. That is exactly what `GODEBUG=clobberfree=1` removes, by overwriting freed objects so the first bad read fails.

saying these in an interview costs you the question

  • Blames the collector before checking unsafe and cgo code
  • Thinks a uintptr keeps its object alive
  • Believes an object is safe because its address is still valid
  • Assumes pointers written into a []byte are followed by the marker
  • Lets C code retain a Go pointer after the cgo call returns