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.
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.
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.
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.
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.
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.
# JVM:
java -XX:+PrintGCDetails -Xlog:gc* -jar app.jar | head
# Node:
node --trace-gc -e "for (let i=0;i<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.