Two generations
the hypothesis
Almost all objects die almost immediately, so the heap is split: a small new space where everything is born, and a large old space for whatever survives. Allocation in new space is a pointer bump and a bounds check. The bet is that the nursery is mostly garbage by the time anyone looks.
new space → (survives twice) → old space
Scavenger
the young collector
New space is two semi-spaces. A collection copies the live objects out of one and into the other, then declares the whole first half free. Cost is proportional to what survived, not to what was allocated — the dead are never visited at all, which is why a nursery full of garbage is nearly free to collect.
from-space → to-space (copy the living)
Mark-Compact
the old collector
Copying does not scale to a large heap, so old space is marked from the roots, swept into free lists, and compacted by evacuating the most fragmented pages and fixing up every pointer into them. This is the major GC, and the pause people actually notice.
mark → sweep → evacuate fragmented pages
Concurrent marking
the pause budget
Most of the marking runs on background threads while JavaScript keeps executing. Tri-colour marking makes that safe: white unreached, grey queued, black finished. The mutator is not allowed to hide a white object behind a black one without telling the collector.
white → grey → black (never black → white)
Every store of a pointer into an object runs a small piece of extra code. It records old-to-new references into a remembered set, so a young collection never has to scan the whole old heap, and it re-greys black objects during concurrent marking. Reference assignment is not free anywhere in a managed language.
obj.field = other // → remembered set
Handles & roots
the C++ contract
A moving collector invalidates raw pointers, so embedder and runtime C++ never holds one across an allocation. Values live behind Local<T> inside a HandleScope that the GC scans as a root, or behind Persistent when they must outlive the scope. Get this wrong and the bug surfaces a thousand allocations later.
HandleScope scope(isolate);
Local<Object> obj = …; // safe to hold