V8 Engine · Lesson 8

Orinoco: Garbage Collection

Why this lesson: Your mission asks you to explain how V8's GC keeps the heap moving without corrupting embedder pointers. This is also the punchline to the Handles lessons: you'll finally see why handles exist — because the GC physically moves your objects.

JavaScript has no free(). V8 reclaims memory automatically with a garbage collector. The naive image — "stop everything, scan the whole heap, free the dead" — would freeze your program for tens of milliseconds at a time. V8's GC, codenamed Orinoco, is engineered around one goal: reclaim memory without noticeable pauses. Two ideas make that possible: generations and concurrency.

The generational hypothesis

"Most objects die young." Empirically, the vast majority of objects become garbage almost immediately (temporaries, intermediate results), while a few live a long time (your app's core data). So V8 splits the heap into two generations and collects them with different strategies — cheap and frequent for the young, thorough and rare for the old.

Young generation (the "nursery")

Small. Every new object is born here. Collected very often, very fast.
GC algorithm: Scavenge (Minor GC)

Old generation

Large. Objects that survive a couple of Scavenges get promoted here. Collected rarely, more thoroughly.
GC algorithm: Mark-Compact (Major GC)

Scavenge — the young collector

The nursery is split into two equal halves ("semi-spaces"). New objects fill one half. When it's full, a Scavenge copies the live objects to the other half and the first half is declared empty in one stroke — no need to touch the dead objects at all. Survivors that have lived long enough are copied into the old generation instead. It's fast because it only ever visits living objects, and there are few of those. Source: Orinoco: young generation GC.

Mark-Compact — the old collector

The old generation can't afford to waste half its space, so it uses Mark-Compact: mark every reachable object by tracing from the roots, then compact — slide the survivors together to squeeze out the gaps left by dead objects. Compaction keeps memory from fragmenting, but it means objects change address. Hold that thought. Source: Trash talk: the Orinoco GC.

Watch both happen hands-on

1Make a lot of garbage and trace the GC:

cat > /tmp/gc.js <<'EOF'
let sink = null;
for (let i = 0; i < 3e6; i++) sink = { a: i, b: i+1 };  // dies instantly
const keep = [];
for (let i = 0; i < 50000; i++) keep.push({ id: i });     // long-lived
EOF
node --trace-gc /tmp/gc.js

Streams of young-gen collections — note the heap shrinking each time as garbage is reclaimed:

8 ms: Scavenge 4.1 (5.1) -> 3.2 (6.1) MB, ... 0.38 ms  (current mu = 1.000) allocation failure

Read it: Scavenge = young GC; 4.1 -> 3.2 MB = heap before → after (≈1 MB of garbage gone); 0.38 ms = pause; allocation failure = the trigger (the nursery filled up); mu = 1.000 = mutator utilization (see below).

2Force a major GC to see Mark-Compact (needs --allow-natives-syntax):

const keep = [];
for (let i=0;i<2e5;i++) keep.push({ id:i, data:"x".repeat(20) });
%CollectGarbage(null);   // force a full collection
55 ms: Mark-Compact 36.2 (54.3) -> 34.1 (99.3) MB, ... 23.79 ms  (current mu = 0.567) ...

Contrast it with Scavenge: the Mark-Compact pause is 23.79 ms (vs sub-millisecond), and mu dropped to 0.567 — the GC ate a real slice of the program's time. This is exactly the kind of pause Orinoco works to hide.

Mutator utilization (mu). The "mutator" is your JavaScript (it mutates the heap; the GC collects it). mu is the fraction of time available to the mutator. mu = 1.0 means GC stole ~no time; mu = 0.567 means GC consumed ~43% of the window. Watching mu is how you spot GC pressure.

Orinoco's real trick: don't stop the world

Generations reduce how much work each GC does. Orinoco is the project that changed when and where that work happens, so it barely interrupts your code. Three techniques, layered:

ParallelThe main thread is paused, but multiple GC helper threads share the work, so the pause is far shorter. (Used in the Scavenger.)
IncrementalDo GC in small chunks interleaved with JS execution, instead of one long stop. Spreads the cost out.
ConcurrentGC threads run at the same time as JavaScript, with no main-thread pause for that phase. (Used in concurrent marking.)

Source: Trash talk · Concurrent marking in V8.

The write barrier. If JS keeps running during marking, it might create a new reference from an already-scanned object to an unscanned one — and the GC could miss it. V8 guards every property write with a tiny check called a write barrier that re-flags such objects. It's the small tax that makes concurrent/incremental GC correct.

Why this is the answer to "why handles?" embedding payoff

Here's the thread that ties this lesson back to embedding. Scavenge copies objects to a new address. Mark-Compact slides objects to new addresses. So a raw C++ pointer v8::Object* you grabbed a moment ago could point at stale memory after the next GC — the object moved.

That is the entire reason Local and Global handles exist. A handle is an entry in a table that the GC knows about. When the collector moves an object, it updates every handle that points to it. Your handle keeps working; a raw pointer would dangle. This is why the embedder API never hands you raw object pointers — it hands you handles. As the Embedder's Guide puts it: the GC "updates all handles that refer to the object with the object's new location." (v8.dev/docs/embed)
CollectorGenerationStrategyMoves objects?Cost
ScavengeYoungCopy live to other semi-spaceYes (copy)Cheap, frequent
Mark-CompactOldMark reachable, compact survivorsYes (slide)Costly, rare

Check yourself feedback loop

1. Why does V8 split the heap into young and old generations?
2. A Scavenge reclaims garbage without ever visiting the dead objects. How?
3. You're embedding V8 in C++ and store a raw v8::Object*, then run some JS. It later points to garbage. Why?
4. What does "concurrent" marking buy you over a plain stop-the-world mark?
Ask me anything. Want to tune the nursery size and watch Scavenge frequency change (--max-semi-space-size)? Curious how --trace-gc-verbose breaks down each phase? Want to see a deliberate "GC thrash" that tanks mu? Just ask — e.g. "Show me how to measure GC overhead in a real workload," or "What's a major GC trigger besides allocation failure?"

Where this lands you — and the whole arc

You can now explain Orinoco end to end: generational split, Scavenge vs Mark-Compact, the parallel/incremental/concurrent techniques that hide the pauses, and — crucially for your mission — why a moving GC forces the handle abstraction on every embedder. That closes the loop between the internals track (Lessons 1, 2, 7, 8) and the embedding track (Lessons 3–6): the C++ API's shape is a direct consequence of how the engine manages memory.

Reference: V8 Glossary (updated) · Related: Lesson 5 (Handles)

V8 Engine learning track · Lesson 8 · The internals capstone — and the bridge to the embedding lessons.