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 ===.
- 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
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.
let a = "hi";let b = "h" + "i";let c = a;print a == b;
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.
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).
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
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 };
}
}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===. copyStringhashes the characters and asks the intern table first. It allocates only for a string it has never seen.findStringis 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.
Why does
print "ab" == "a" + "b";printtrueon the VM, whenOP_EQUALis just===? - 2.
When does
copyStringallocate a new string object? - 3.
In
findString, why isk.hash === hashchecked beforek.chars === chars? - 4.
What does interning make more expensive?
- 5.
What would go wrong if the intern table held its strings strongly?
- 6.
A program mentions the global
a1300 times. With interning, how manyObjStringobjects for"a1"exist, and how many constant-pool entries hold it?
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] 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] 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)