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 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.
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.
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.
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.
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:
Source: Trash talk · Concurrent marking in V8.
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.
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)
| Collector | Generation | Strategy | Moves objects? | Cost |
|---|---|---|---|---|
| Scavenge | Young | Copy live to other semi-space | Yes (copy) | Cheap, frequent |
| Mark-Compact | Old | Mark reachable, compact survivors | Yes (slide) | Costly, rare |
v8::Object*, then run some JS. It later points to garbage. Why?--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?"
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)