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

52. String interning

Two strings built in completely different ways turn out to be the same object. That one rule lets the VM compare strings, and look up names, with a single ===.

About 15 minutes, including the exercises and quiz
By the end you will be able to
  • Explain interning, and why it lets equal strings be compared by identity.
  • Trace copyString and findString through the VM’s string table.
  • Weigh what interning costs against what it saves.
  • Build an interner, and the lookup that compares hashes before characters.

Two strings, one object

Chapter 51 left a loose thread. The table compares keys with k === key, and OP_EQUAL in chapter 49 was a === b. For objects, === asks “the same object?”, not “the same characters?” (chapter 4). Yet:

let a = "ab";
let b = "a" + "b";
print a == b; // true

a is a literal, made by the compiler. b is built at runtime by OP_ADD. Chapter 50 said every concatenation allocates a new object. So how can they be the same object?

They can because chapter 50 told a small lie. copyString doesn’t always allocate.

Interning

Intuition

A conference desk handing out name badges. Before printing a badge, the clerk checks the box of badges already printed. If there’s one that says “Ada Lovelace”, you get that one. Every person called Ada Lovelace wears the same badge, so checking whether two people have the same name is as quick as checking whether they’re wearing the same badge.

Here is the real copyString, in vm/heap.ts:

copyString(chars: string): ObjString {
  const hash = hashString(chars);
  const interned =
    this.strings.findString(chars, hash);
  if (interned) return interned;
  const s = this.allocate<ObjString>({
    type: 'string', chars, hash,
    isMarked: false, next: null,
  });
  this.push(s);
  this.strings.set(s, null);
  this.pop();
  return s;
}

heap.strings is a Table from chapter 51, used as a set: the keys are every string the VM has made, and the values are all null. Before making a string, copyString asks the set for one with the same characters. Only if there is none does it allocate, and then the new string goes into the set, so the next request finds it. (The push and pop keep s on the stack, safe from the collector, while it is being added.)

Every string in the VM is made by copyString: literals, names, concatenations. So there is never more than one object with the same characters.

Step throughMany references, one string
let a = "hi";
let b = "h" + "i";
let c = a;
print a == b;
Global variables on the left, the VM's string table on the right; the variables all point at the single hi objectGLOBALSSTRING TABLE"init""clock""a""hi""b""h""i""c"
After compiling, the string table holds eight strings: two the VM made at start-up, the names a, b and c, and the literals "hi", "h" and "i". Every one went through copyString, so each exists once.
1/6
Definition

Interning keeps exactly one copy of each distinct string. Every request for a string first looks in a table of existing strings, the intern table (here heap.strings), and returns the existing object if there is one. Because equal strings are then always the same object, string equality becomes identity: a single === on references.

Finding a string by its characters

There is a catch. Table.find compares keys by identity, but that’s exactly what you can’t do yet: you are holding characters, not an object. So the table has a second lookup, the only place in the whole VM that compares a string’s characters:

findString(chars: string, hash: number) {
  if (this.count === 0) return null;
  const mask = this.keys.length - 1;
  let index = hash & mask;
  for (;;) {
    const k = this.keys[index];
    if (k === null) {
      if (this.values[index] === null) {
        return null;
      }
    } else if (k.hash === hash &&
        k.chars === chars) {
      return k;
    }
    index = (index + 1) & mask;
  }
}

The probing is the same as chapter 51’s. The difference is the test. k.hash === hash is a cheap number comparison that rules out almost every wrong string. Only when the hashes match are the characters compared, and that will nearly always be the right string. A tombstone (key null, value not null) is stepped over, as before.

Try itBuilt strings meet literals
let a = "ab";
let b = "a" + "b";
print a == b;

let s = "";
for (let i = 0; i < 3; i = i + 1) {
  s = s + "na";
}
print s;
print s == "nanana";
print "na" + "na" == "nana";

What it costs, what it buys

Interning moves work around. It doesn’t remove it.

Making a string costs more. Every new string, including every concatenation, is hashed in full and looked up before it exists. A loop that builds a long string piece by piece pays that on every step.

Using a string costs less. == on strings is one comparison, however long they are. Every global variable lookup (chapter 53), field access and method call looks a name up in a table, and those compare keys with === too. Programs make a string once and compare or look it up many times, so this is usually a good trade. It also saves memory: a thousand copies of "name" are one object.

The intern table has one more duty. If it kept every string alive, no string could ever be freed. So it holds strings weakly: during a collection, removeWhite turns every entry whose string nothing else reached into a tombstone, just before the sweep frees it (chapter 58).

Under the hood

Interning is everywhere. Python’s sys.intern enters a string in a table of interned strings; its documentation says names in Python programs are interned automatically, and that with interned keys a dictionary’s key comparisons, after hashing, can be a pointer compare instead of a string compare[2]. Java interns every string literal, and s.intern() == t.intern() holds exactly when s and t have equal contents[3]. The companion VM follows clox, which interns every string it makes[1].

Interning shares objects, not constant-pool slots. The compiler still adds a new constant every time a name appears, all pointing at the same object. That is why chapter 44’s string_equality benchmark couldn’t run on the VM: it mentions its eight variables hundreds of times, and the chunk ran out of its 256 constants.

Your turn

ExerciseAn interner

Write intern(chars). It returns a string object { chars }, and it must return the same object every time it is asked for the same characters. Count each object you actually create in this.made. Use this.table, a Map from characters to the object.

class Interner {
  constructor() {
    this.table = new Map();
    this.made = 0;
  }
  intern(chars) {
    this.made++;
    return { chars };
  }
}
Extra practicefindString: hash first, then characters

An 8-slot intern table is two arrays: ks holds string objects { chars, hash } or null, and vs holds null, or TOMB for a tombstone. Write findString(ks, vs, chars, h): probe from h & 7, step over tombstones, stop with null at a truly empty slot, and return the object whose hash equals h and whose chars equal chars.

const TOMB = true;

function findString(ks, vs, chars, h) {
  return null;
}

What you learned

  • Interning keeps one object per distinct string, so equal strings are identical and == is a single ===.
  • copyString hashes the characters and asks the intern table first. It allocates only for a string it has never seen.
  • findString is the one place characters are compared, and only after the hashes match.
  • The trade: every new string pays a hash and a lookup, so every later comparison and name lookup is a pointer check.
  • The intern table holds strings weakly: unreachable strings leave it before they are freed.

That’s Day 13. Next, the VM puts its tables to work: global variables, found by name.

Check your understanding

0/6
  1. 1.

    Why does print "ab" == "a" + "b"; print true on the VM, when OP_EQUAL is just ===?

  2. 2.

    When does copyString allocate a new string object?

  3. 3.

    In findString, why is k.hash === hash checked before k.chars === chars?

  4. 4.

    What does interning make more expensive?

  5. 5.

    What would go wrong if the intern table held its strings strongly?

  6. 6.

    A program mentions the global a1 300 times. With interning, how many ObjString objects for "a1" exist, and how many constant-pool entries hold it?

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] Python Software Foundation. sys.intern(string), in: sys — System-specific parameters and functions. The Python Standard Library documentation. docs.python.org/3/library/sys.html#sys.intern (checked 2026-10-31)
  3. [3] OpenJDK contributors. java.lang.String#intern() (Javadoc in String.java). OpenJDK source repository, github.com/openjdk/jdk, master branch. github.com/openjdk/jdk/blob/master/src/java.base/share/classes/java/lang/String.java (checked 2026-10-31)