open atlas
↑ Back to track
JavaScript Engine internals JSE · 06 · 03

Major GC: mark, sweep, compact

Old space is collected by mark-sweep-compact. Marking uses the tri-color abstraction (white/grey/black) and runs incrementally and concurrently to shrink the stop-the-world window; sweeping reclaims white objects into free lists

JSE Senior ◷ 15 min
Level
FoundationsJuniorMiddleSenior

The Scavenger is sub-millisecond because young space is tiny. Old space is the opposite: it can be a gigabyte of long-lived objects, and you cannot copy your way out of it the way the Scavenger does — there is no spare gigabyte to copy into. So the major GC takes a different shape, and its pause is the one your users actually feel as a dropped frame or a stalled request. The whole engineering story of the modern major GC is one sentence: do as much of the work as possible off the main thread, so the part that must stop the world shrinks toward nothing.

Why old space needs a different collector

A copying Scavenger needs a second region the size of the live set to copy into; that is fine for a few megabytes of young objects but impossible for a gigabyte-sized old generation. So old space uses mark-sweep-compact (MSC): find the live objects in place (mark), reclaim the dead in place (sweep), and only occasionally relocate survivors to fight fragmentation (compact). A major GC traces the entire reachable graph, not just a small region, which is why its cost is fundamentally larger — and why V8 invests so heavily in hiding that cost.

Marking: the tri-color abstraction

Marking is graph traversal with a twist that makes it pausable. Every object is conceptually one of three colors (stored as two mark bits):

  • white — not yet reached (presumed dead);
  • grey — reached, but its outgoing references have not been scanned yet (on the work queue / “marking worklist”);
  • black — reached and fully scanned (all its references have been greyed/processed).

The algorithm:

  1. Color everything white. Color the roots’ direct referents grey (push them on the worklist).
  2. Pop a grey object, scan its references — color each white referent it points to grey — then color the object itself black.
  3. Repeat until no grey objects remain.
  4. Every object still white is unreachable → garbage.

The grey set is the frontier of the wavefront sweeping through the graph; when it drains, the trace is complete. The crucial invariant this maintains: a black object must never point directly to a white object (the “tri-color invariant”). If that held only for a stop-the-world trace it would be trivial — but V8 does not stop the world to mark, which is where it gets interesting.

Incremental and concurrent marking

A naive MSC stops all JavaScript, marks the whole heap, then resumes — a stop-the-world pause of hundreds of milliseconds on a large heap. V8 attacks this on two fronts:

  • Incremental marking breaks the trace into small steps interleaved with JS execution. The engine marks a little, runs JS, marks a little more — amortising the work so no single pause is long. Steps are scheduled into idle time and during allocation.
  • Concurrent marking goes further: background helper threads walk the object graph while the main thread keeps running JavaScript. The bulk of the marking work happens off the main thread entirely.

Both create the same hazard: the main thread can mutate the object graph mid-trace — store a pointer that the marker has not seen — and break the tri-color invariant by making a black object point to a white one. The marker would then finish, see that white object as unreached, and wrongly collect a live object. The fix is the write barrier, the entire subject of the next lesson: it intercepts pointer stores during marking and re-greys the affected object so the invariant holds. Hold that thread; here, just register that incremental/concurrent marking is only correct because of write barriers.

After concurrent marking, a short final atomic pause (“finalize”) re-scans the roots and any objects the barriers flagged, draining the last grey set. This final pause is single-digit milliseconds even on large heaps — that is the win.

Sweeping: reclaim white into free lists

Once marking finishes, every white object is dead. Sweeping walks the old-space pages and adds the memory occupied by white objects to per-size-class free lists — the bookkeeping that lets future old-space allocations find a hole of the right size. Sweeping does not move anything; it just records free regions. V8 sweeps concurrently and lazily: free lists are rebuilt on background threads, and a page can be swept on demand the first time an allocation needs space from it. Because sweeping touches dead objects (which are not referenced) it is naturally parallelizable and largely off the critical path.

Compaction: evacuate to defragment

Mark-sweep alone leaves fragmentation: live and dead objects are interleaved, so over time old space becomes Swiss cheese — plenty of free bytes, but no contiguous hole big enough for a large allocation. Compaction fixes this by evacuation: pick the most fragmented pages, copy their live objects to a fresh page packed tightly, and update every pointer that referenced the moved objects. This is the same copy-and-forward machinery as the Scavenger, applied selectively. Compaction is expensive (it moves objects and rewrites pointers), so V8 does it selectively — only on pages whose fragmentation justifies it, not the whole heap every cycle.

Major GC anatomy
Old space size
100s of MB to GBs
Pre-incremental STW pause
100s of ms
Final marking pause (modern)
single-digit ms
Marking colors
white / grey / black
Marking runs
incremental + concurrent
Sweep / compact
concurrent / selective
Quiz

Marking has finished and an object is still white. What does that mean and what happens to it?

Quiz

Concurrent marking does most of the work on background threads while JS runs. Why is there still a short stop-the-world final pause?

Order the steps

Order a modern major GC cycle in V8.

  1. 1 Colour the roots' referents grey, everything else white
  2. 2 Concurrently/incrementally pop grey objects, grey their white referents, blacken them
  3. 3 Short atomic pause: re-scan roots and barrier-flagged objects, drain the last grey
  4. 4 Sweep: record white (dead) objects into free lists, concurrently and lazily
  5. 5 Compact: selectively evacuate live objects off fragmented pages, updating pointers
Why this works

Why bother compacting at all instead of just sweeping forever? Because a long-running server allocates and frees objects of many sizes, and free-list allocation cannot always reuse a freed hole — a 200-byte object cannot go into a 64-byte gap. Over days, old space accumulates unusable gaps; resident memory stays high even though live data is small, and large allocations start failing or forcing growth. Selective compaction reclaims that lost contiguity. It is the GC analogue of defragmenting a disk.

Recall before you leave
  1. 01
    Explain the tri-color marking abstraction and what 'marking is done' means.
  2. 02
    How does V8 keep major-GC pauses short, and what is the residual stop-the-world cost?
  3. 03
    What is the difference between sweeping and compaction, and when does V8 compact?
Recap

Old space cannot be copy-collected like young space (there is no spare gigabyte to copy into), so V8 uses mark-sweep-compact. Marking uses the tri-color abstraction: objects are white (unreached), grey (reached but unscanned, on the worklist), or black (reached and fully scanned); the trace greys the roots’ referents, repeatedly blackens grey objects while greying their white referents, and finishes when no grey remain — leaving every still-white object as garbage. To keep pauses short V8 marks incrementally (small steps interleaved with JS) and concurrently (background threads mark while JS runs), reducing the stop-the-world portion to a single-digit-millisecond final pause that reconciles mutations via the write barrier and drains the last grey set. Sweeping then records dead objects into free lists without moving anything, concurrently and lazily. Compaction is a separate, selective phase that evacuates live objects off the most fragmented pages and rewrites their pointers to reclaim contiguous space. Now when you see a major GC pause in a trace, you can read it as a single-digit-ms finalize step — not the hundreds-of-ms stop-the-world of a naive MSC — and know that the bulk of the work already happened on a background thread while your code ran.

Practice

Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.

recallapplystretch0 of 7 done
Connected lessons
appears again in208

Something unclear?

Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.

shortcuts expand
search
K
prev piece
k
next piece
j
cycle tier
t
this menu
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.