The Runtime Theory
RuntimeInternalsmemory

What happens when the garbage collector runs?

A step-by-step walk of a GC cycle: allocation triggers it, the mutator pauses at a safepoint, mark traces live objects, and sweep returns memory.

The Runtime Theory Team1 min read05 steps

layer stack

Runtime

HWHardware
KKernel
RTRuntime
APPApplication
SYSSystem
CLIClient
NETNetwork
TLSCrypto
SRVServer

trace spine

  1. 01 Allocation trips the threshold
  2. 02 Safepoint stops the mutator
  3. 03 Mark traces the object graph
  4. 04 Sweep frees and coalesces
  5. 05 Generations shrink the pause

Unlike the manual malloc/free lifecycle, a garbage-collected heap manages itself — which means occasionally the runtime stops everything to figure out what's still alive. The trace of one GC cycle is the story of that stop: what triggers it, what runs during it, and why the pause is always the headline number.

trace stepRuntime

GC doesn't run on a timer. It runs when allocation pressure demands it: the mutator's new/alloc bumps the heap past the next threshold (e.g. the heap size times the GC growth factor, 2× default in V8 and the JVM's Parallel GC). The allocation that crosses the threshold does not complete inline — instead of returning a pointer, it enters the GC machinery.

trace stepRuntime

The runtime must stop every thread before it can inspect the heap — an inconsistent stack or register set would corrupt the root set. Each thread parks at the next safepoint: a polled location the JIT compiler emits in every loop back-edge and call return (the poll instruction on x86). A thread stuck in a long syscall or a native method delays the pause until it returns. The safe point → all threads parked window is the start of the pause, measured in tens of microseconds on quiet JVMs, seconds on pathological ones.

trace stepRuntime

Mark: starting from the roots (globals, stacks, registers, thread-locals), the collector traverses the object graph — tri-color marking in V8 and HotSpot's G1 — coloring each reachable object gray→black. This is pointer-chasing over the whole live set: for a 1 GB heap with 100 MB live, this is millions of objects walked, at cache-miss latency each (~100 ns per object). Marking is usually the dominant term of the pause. Unreachable objects are never visited — they stay white and are garbage by definition.

trace stepRuntime

Sweep (mark-and-sweep style) walks the heap linearly, freeing every white object and coalescing adjacent frees into larger chunks. Freeing in the GC world isn't free() per object — it's building the allocator's free lists, often bump-allocated into the next cycle. Compacted heaps (G1, ZGC's regions) instead copy survivors and reset the region — the "evacuation" that makes allocation a pure bump again. Either way, memory returns to the allocator, and the heap's used size drops toward the live set.

trace stepRuntime

The collector then returns control and threads resume — the pause ends. But the practical story is generational: nearly all objects die young, so the collector runs a cheap young-generation scavenge (copying survivors from the nursery, often <10 ms in V8, ~tens of ms in the JVM) far more often than it runs a full-heap cycle. The full mark-sweep-compact we just traced is the rare, expensive one. The write barrier each store to an old-gen object pays for in the background is what lets the young gen be collected without scanning the whole old gen.

bash
# JVM:
java -XX:+PrintGCDetails -Xlog:gc* -jar app.jar | head
# Node:
node --trace-gc -e "for (let i=0;i&lt;5e6;i++) new Array(16);"

What the machine actually does is a global traffic stop: freeze all threads at polled checkpoints, walk the reachable graph once, reclaim everything unvisited, resume. The rest of the runtime's engineering — barriers, generations, concurrent phases — is the long war to make this stop shorter, rarer, and finally invisible.