skip to content

After Go's garbage collector finishes marking, when is unreachable memory actually reclaimed?

level: middleimportance: should knowfreq 38%

answer

  1. marking is a verdict, not a release
  2. spans are swept, not objects
  3. mostly by whoever allocates next
  4. mark bits become the new allocation bits
  5. reusable in the heap, not returned to the OS

basics

~20 s

Go sweeps lazily. Marking only decides what is live; spans are swept afterwards, mostly by goroutines that are allocating, so a dead object's slot becomes reusable when its span is swept, and it stays inside the process heap.

solid answer

~50 s

Marking produces a verdict, not free memory. When marking ends, every span in the heap is left needing a sweep, and sweeping happens **incrementally**: a background sweeper goroutine works through spans, and any goroutine that needs a span sweeps it first, paced so all sweeping finishes before the next cycle needs to start. Sweeping a span turns its mark bits into its new allocation bits, so unmarked slots simply become free slots on that span's free list — the memory is not wiped and nothing is compacted. A span that ends up entirely free goes back to the page heap, and only a separate background scavenger may later hand those pages back to the operating system. So the practical rule is: after a collection, dead objects are *reusable soon*, but the process does not shrink and the memory is not scrubbed.

go deeper

for a junior

Remember the order: the collector first decides what is live, and only afterwards makes the dead slots reusable. Freed memory stays inside the Go process for reuse rather than going back to the operating system.

for a middle

Explain the mechanism: spans are swept incrementally, largely by goroutines about to allocate plus a background sweeper, and sweeping just turns the mark bitmap into the new allocation bitmap.

for a senior

Use it to triage a memory complaint: separate 'still reachable' from 'swept but held by the runtime' from 'pages not returned to the OS'. Only the first is a leak in your code, and only the third is what debug.FreeOSMemory addresses.

for a principal

Set expectations for the org: a Go service's resident memory is a high-water mark by design, so capacity planning and container limits should be sized on the steady-state peak rather than on the illusion that memory drains after each collection.

## Marking decides, sweeping frees A Go collection cycle has two jobs. Marking walks the reachable object graph from the roots and leaves a mark bit set for every live object. Sweeping walks the spans and turns "unmarked" into "free". They are separated in time on purpose, and the second half is **lazy**. ## What sweeping actually does Go's heap is a set of **spans**: runs of 8 KiB pages, each dedicated to one size class and divided into equal-sized slots. Each span carries a bitmap of which slots are allocated and a bitmap of which were marked in the last cycle. Sweeping a span is cheap and local: the mark bitmap becomes the new allocation bitmap. Every slot that marking did not touch is now considered free and can be handed to the next allocation of that size class. Notice what sweeping does **not** do: - it does not zero or scrub the dead object's bytes (memory is zeroed when it is handed out again, if the type needs it); - it does not move or compact anything; - it does not, by itself, return anything to the operating system. ## Why it is spread out instead of done at once Sweeping the whole heap the moment marking ends would be a large burst of work at exactly the wrong time. Go instead spreads it: - a background sweeper goroutine grinds through spans when there is CPU to spare; - a goroutine that needs a fresh span of some size class sweeps an unswept one first, so the cost lands on whoever actually needs the memory; - the pace is tied to allocation, which guarantees sweeping is finished before the next cycle needs to begin marking. A new cycle cannot start marking on top of stale mark bits, so any leftover unswept spans are finished off first. A useful consequence: a program that stops allocating right after a collection may leave spans unswept for a while, and that is fine — nothing needs them. ## Where the memory goes Freed slots go back to their span. A span with no allocated slots left goes back to the page heap, where its pages can be reused for a different size class. Whether those pages ever leave the process is a separate, slower decision made by the background scavenger. That is why RSS can sit flat after a collection that freed a lot: the runtime is holding the pages, ready for the next spike, rather than returning them and paying to fault them back in. The API surface reflects the split. `runtime.GC()` blocks the caller until a collection is complete, but it is not a "give memory back" button. `debug.FreeOSMemory()` forces a collection and then tries to return as much memory as possible to the operating system — which is occasionally right for a process that has just finished a one-off memory spike and will idle afterwards, and is usually wrong in a steady-state server, where you have simply thrown away the pages you are about to need again. ## Consequences people trip over **"I freed it, so the memory is gone."** No. The slot is reusable by Go; the process is the same size; and if a benchmark or dashboard is watching the operating system's view, it will look like nothing happened. **"The bytes are cleared when the object dies."** They are not. A dead object's bytes usually sit there untouched until something else is allocated into that slot. This is exactly why a stale alias created through `unsafe` can keep reading plausible-looking data long after the object logically died, and why the runtime offers `GODEBUG=clobberfree=1` to overwrite freed objects with junk so such a bug fails loudly instead of silently. **"Sweeping is a pause."** It is not a phase where the program stops; it is work distributed across allocations and a background goroutine. ## How to reason about it in production When someone reports that memory "was not freed", separate three questions: was the object actually unreachable (a real leak keeps it marked), was its span swept (almost always yes soon after), and did the pages go back to the operating system (usually not, deliberately). Only the first is a bug in your program. The second is a timing detail; the third is the runtime keeping its working set warm.

  • Why does the runtime spread sweeping across allocations instead of doing it all when marking ends?
    Sweeping the whole heap at once would be a big burst of work exactly when the cycle is trying to finish. Spreading it puts the cost on the goroutine that actually needs a span, lets a background sweeper use spare capacity, and paces the work so all spans are swept before the next cycle needs to start marking with clean mark bits.
  • Does runtime.GC() return freed memory to the operating system?
    No. `runtime.GC()` blocks the caller until a collection completes, but freed memory stays in the runtime's heap for reuse. Returning pages is a separate background job, and `debug.FreeOSMemory()` is the explicit forcing function — worth using after a one-off spike in a process about to idle, and usually counterproductive in a steady-state server.
  • Are a dead object's bytes wiped when its span is swept?
    No. Sweeping only flips bookkeeping bits; the bytes stay until something is allocated into that slot, and zeroing happens on allocation when the type needs it. That is why a stale pointer created through `unsafe` can read stale-but-plausible data for a long time, and why `GODEBUG=clobberfree=1` exists to overwrite freed objects with junk during debugging.

saying these in an interview costs you the question

  • Says sweeping is a stop-the-world phase after marking
  • Claims freed memory goes straight back to the operating system
  • Thinks runtime.GC shrinks the process's resident memory
  • Believes freed object bytes are zeroed at sweep time
  • Assumes marking itself frees the memory it did not reach