Viscosity
Review
Part IV · A bytecode virtual machine · Day 15

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.

About 17 minutes, including the exercises and quiz
By the end you will be able to
  • 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

Intuition

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.

Definition

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:

Step throughMark from the roots, then sweep
collect() {
vm.markRoots(markValue);
while (gray.length)
blacken(gray.pop());
strings.removeWhite();
sweep();
}
Roots on the left point into a graph of six objects; a grey stack and the object list are shown belowROOTSglobalsliststackslot 1AinstanceBinstanceCclosureFfunctionDinstanceEinstancegrey stack: []OBJECT LISTobjects → E → D → F → C → B → A → null
Before a collection every object is white: isMarked is false. The object list (bottom) links all six, newest first.
1/8

The early return in markObject does two jobs: nothing is traced twice, and a cycle can’t send marking round forever.

Definition

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.

Definition

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.

Pitfall

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.

Under the hood

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

ExerciseA toy mark-sweep collector

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;
}
Extra practiceA weak string table

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 init string 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. 1.

    Why does the companion VM need a collector even though V8 already has one?

  2. 2.

    Which of these is not a root of the companion VM?

  3. 3.

    During marking, blacken pops an object off the grey stack. What does it do?

  4. 4.

    Objects a and b refer only to each other, and nothing else refers to them. After a collection:

  5. 5.

    Why does the collector call strings.removeWhite() between marking and sweeping?

  6. 6.

    A collection just finished with 3 MiB of objects still live. When will the next one start?

References

  1. [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. [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. [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)