58. Garbage collection: mark and sweep
The VM allocates strings, closures, upvalues and instances, and has never freed one. A collector needs only two ideas: start from what the program can touch, and throw away everything it can’t reach.
- Explain why a VM written in JavaScript still needs its own garbage collector.
- List the VM’s roots and say why each one is needed.
- Trace tri-colour marking with a grey stack, then a sweep over the object list.
- Explain the weak string table, the growth factor, and how stress mode catches a missing root.
A heap that only grows
Since chapter 50, every object the VM allocates goes onto one list, the object list. Nothing ever comes off it. Run this:
let s = "";
for (let i = 0; i < 100000; i = i + 1) {
s = s + "x";
}
Each pass makes a new, longer string. After the next pass, nothing can reach the old one. But the object list still links it, so the heap keeps every string the program ever built.
You may wonder why this matters, since the VM is written in JavaScript and JavaScript has a garbage collector. The catch is that V8 frees an object only when nothing refers to it, and the object list refers to everything. Only the VM knows which objects Drip code can still use. So the VM needs its own collector, over its own heap.
Reachable from the roots
Picture the objects as balloons tied to each other with strings, and a few balloons tied to your wrist. Cut every string that isn’t connected, however indirectly, to your wrist, and those balloons drift away. Your wrist is the roots. Two balloons tied only to each other still drift.
Drip code can only reach an object by starting from something it holds directly: a value on the stack, a global, a closure that is running. Those are the roots. Anything reachable from them, through fields, upvalues, constants and so on, might be used again. Anything else can’t be, ever, so it is safe to free. Here are the VM’s roots:
markRoots(mark) {
for (let i = 0; i < this.sp; i++) {
mark(this.stack[i]);
}
for (const f of this.frames) {
mark(f.closure);
}
for (let u = this.openUpvalues; u;
u = u.nextOpen) mark(u);
this.globals.forEach((k, v) => {
mark(k);
mark(v);
});
mark(this.initString);
}
The stack holds locals and temporaries. Each frame’s closure is running,
so its constants must stay. Open upvalues sit on the VM’s own list,
which later captures reuse, so they must stay valid even if no closure
holds them any more. Globals are reachable by name. initString is the
interned "init", kept for chapter 59. The heap adds two more: the
functions the compiler is still building, which no VM structure points
at yet, and a small extraRoots stack for values that are briefly
reachable from nowhere.
Garbage collection reclaims memory that the program can never use
again. A tracing collector decides that by reachability: the roots
are the references the program holds directly (in this VM, the value
stack, frames’ closures, open upvalues, globals, the init string and
the compiler’s unfinished functions), and an object is live if some
chain of references leads to it from a root. Everything else is garbage.
Marking: white, grey, black
Marking an object sets its isMarked flag. But an object’s own
references must be marked too, and theirs, and so on. Doing that by
recursion would follow a long linked list a hundred thousand calls deep
and overflow the host stack (chapter 9). So the collector keeps an
explicit stack of objects still to trace:
markObject(o) {
if (!o || o.isMarked) return;
o.isMarked = true;
this.gray.push(o); // reached, not traced
}
Then it drains that stack. “Blackening” an object marks everything it refers to, and what that is depends on its type:
blacken(o) {
switch (o.type) {
case 'string': case 'native':
return;
case 'upvalue':
this.markValue(o.closed);
return;
case 'closure':
this.markObject(o.fn);
for (const u of o.upvalues)
this.markObject(u);
return;
case 'instance':
this.markObject(o.klass);
this.markTable(o.fields);
return;
// function, class, bound: the same idea
}
}
Step through a collection:
collect() {vm.markRoots(markValue);while (gray.length)blacken(gray.pop());strings.removeWhite();sweep();}
The early return in markObject does two jobs: nothing is traced
twice, and a cycle can’t send marking round forever.
Tri-colour marking sorts objects into three sets. White: not
reached yet. Grey: reached (marked) but its references not yet
traced; in the companion, the objects on the gray stack. Black:
reached and fully traced. Marking starts with the roots grey, then turns
grey objects black one at a time, greying every white object they
refer to. It ends when nothing is grey. A black object never points at
a white one, so whatever is still white is unreachable.
Sweeping, and a weak table
Sweeping walks the object list once. Marked objects stay and are reset to white for next time. Unmarked ones are unlinked:
let prev = null;
let o = this.objects;
while (o) {
if (o.isMarked) {
o.isMarked = false;
prev = o;
o = o.next;
} else {
const dead = o;
o = o.next;
if (prev) prev.next = o;
else this.objects = o;
this.bytesAllocated -= SIZE[dead.type];
}
}
Once unlinked, nothing in the VM refers to the dead object, and V8’s own collector can reclaim the memory.
One table needs care: the interning table of chapter 52. It refers to
every string, so if it were a root, no string would ever die. And if it
kept entries for strings that sweep frees, copyString could hand out a
dead string. So the table is weak: between marking and sweeping,
strings.removeWhite() deletes every entry whose string is unmarked.
Mark-sweep collection has two phases. Mark finds every object reachable from the roots. Sweep visits every allocated object (here, by walking the object list) and frees the unmarked ones. A weak table, like the string table, doesn’t keep its entries alive: entries for unmarked keys are removed before the sweep.
When to collect, and catching mistakes
Collecting costs time, so the VM doesn’t do it on every allocation. It
counts rough object sizes in bytesAllocated and collects when that
passes nextGC. After each collection, nextGC becomes twice what
survived (at least 1 MiB). A program with a large live heap therefore
collects less often, instead of re-marking the same big heap over and
over.
Any object the VM is holding only in a TypeScript variable is
invisible to the collector. defineNative makes the name string, then
allocates the native, and that allocation can trigger a collection. The
name is reachable from nowhere yet, so it would be freed and dropped
from the string table, and "clock" would later intern as a different
object. That’s why defineNative pushes both onto the value stack until
they are safely in the globals.
Bugs like that hide, because collections are rare. So the heap has a
stress mode that collects on every allocation. In JavaScript, a
freed object doesn’t actually vanish while something still refers to
it, so the heap also poisons freed objects: it deletes their fields
and makes reading type throw use after free. The test suite runs
corpus programs under stress mode, so a missing root fails loudly.
Poisoning is debug-only, and chapter 60 shows why.
V8’s major collector also finds live objects by marking from a root set that includes the execution stack and the global object, then sweeps the gaps left by dead objects into free-lists, and compacts some fragmented pages[2]. Its marking is tri-colour too, with two mark bits per object and a marking worklist[3]. V8 marks while JavaScript runs, so it needs a write barrier to keep a black object from gaining a pointer to a white one. Drip’s collector stops the program while it works, so it needs none. The collector’s design follows clox[1].
Your turn
Objects are { name, refs, marked }, where refs is an array of other objects. Write collect(heap, roots): mark every object reachable from roots using an explicit grey stack (no recursion), then return a new array of the marked objects, in heap order, with their marked flags reset to false.
function collect(heap, roots) {
const gray = [];
// 1. mark the roots (grey)
// 2. trace until gray is empty
// 3. sweep: keep marked, reset
return heap;
}The interning table is a Map from characters to string objects { chars, marked }. Write removeWhite(table): after marking, delete every entry whose string is not marked, and leave the marked ones (and their flags) alone.
function removeWhite(table) {
// delete entries whose string
// was not marked
}What you learned
- Garbage collection frees objects nothing can reach. The VM needs its own, because its object list keeps every object alive as far as V8 can tell.
- The roots are the stack, frames’ closures, open upvalues, globals,
the
initstring and the compiler’s functions. - Tri-colour marking: white, grey (on the grey stack), black. An explicit stack avoids deep recursion, and the mark check stops cycles.
- Mark-sweep: mark from the roots, then sweep the object list and unlink white objects. The string table is weak.
- Collections run when allocation passes
nextGC. Stress mode and poisoning turn a missing root into an immediate error.
Check your understanding
0/6- 1.
Why does the companion VM need a collector even though V8 already has one?
- 2.
Which of these is not a root of the companion VM?
- 3.
During marking,
blackenpops an object off the grey stack. What does it do? - 4.
Objects
aandbrefer only to each other, and nothing else refers to them. After a collection: - 5.
Why does the collector call
strings.removeWhite()between marking and sweeping? - 6.
A collection just finished with 3 MiB of objects still live. When will the next one start?
References
- [1] Robert Nystrom (2021). Crafting Interpreters. Free web edition at craftinginterpreters.com (© 2015–2021); source, text and test suite at github.com/munificent/craftinginterpreters. github.com/munificent/craftinginterpreters (checked 2026-10-31)
- [2] Peter Marshall (2019). Trash talk: the Orinoco garbage collector. V8 blog (v8.dev), 3 January 2019. v8.dev/blog/trash-talk (checked 2026-10-31)
- [3] Ulan Degenbaev, Michael Lippautz, Hannes Payer (2018). Concurrent marking in V8. V8 blog (v8.dev), 11 June 2018. v8.dev/blog/concurrent-marking (checked 2026-10-31)