The Runtime Theory
Runtime & Execution

String Interning and Memory: Why Some == Comparisons Are Instant

How compilers and runtimes intern strings into symbol tables and pools, why identity comparison works on interned strings, and how Java, C#, and Python each behave.

The Runtime Theory Team3 min read#strings#interning#memory#java#csharp#python
On this page

"hello" == "hello" — is that an O(1) pointer comparison or an O(n) character-by-character scan? The answer is a runtime decision called string interning: giving every distinct string value exactly one canonical object, so that value equality collapses into pointer equality. Interning is not an optimization you opt into — it's happening silently inside your compiler's symbol table, your runtime's literal pool, and your language's operator semantics. Here's what the machine actually does.

What interning is

A string is interned when the runtime guarantees that a given value exists in only one place. Compare "abc" and "abc" in a world where every occurrence is a separate buffer: equality requires scanning both buffers, byte by byte — and the scan is O(n) even when the strings are unequal in the first byte, because you must check the first byte first. In an interned world, both references point at the same object, so equality is a single pointer comparison. Interning trades a one-time cost (hashing the string, storing a canonical copy) for perpetual O(1) comparisons. The catch: you must know when the runtime interns, because the same literal can behave both ways in one program.

Symbol tables: where compilers intern

Compilers were the first heavy interners. Every identifier — variable names, function names, keywords — lands in a symbol table: a hash map from the name to a canonical node. Every occurrence of counter in your source resolves to the same table entry, so the compiler compares identifiers by pointer, not by string. This is why switch on keywords and hash-based lookups are fast in compilers: the expensive strcmp happens once, at insertion, and every subsequent comparison is an 8-byte pointer check. Your compiler's entire name-resolution speed rests on this trick.

String pools in runtimes

Runtimes apply the same idea to program strings:

  • Java: literals are interned into a pool on the heap (in modern JDKs, a StringTable — a hash table of weak references). String.intern() manually interns any runtime-created string.
  • C#: literals are interned in the process-wide intern pool; string.Intern() does the same by hand, and the CLR guarantees literals with identical values share one instance.
  • CPython: the interpreter interns small integers, but for strings it caches literals, short strings, and identifiers in an internal dict, and reuses them for common patterns. There is no public intern() — interning happens by policy, invisibly.
  • Ruby/Elixir/Erlang: symbols and atoms are interned strings that live forever and are never garbage collected — the pool is a permanent memory commit.

The "abc" literal in your Java source is not the same object as the "abc" you read from a file — the first is interned, the second is a fresh allocation with equal bytes.

Why == works on interned strings

This is where language semantics make interning observable. Equality operators fall into two camps:

java
// Java: == is REFERENCE equality. Works on interned strings, lies on file input.
String a = "abc", b = "abc";        // interned literals -> same object
a == b;                             // true, pointer comparison
String c = new String("abc");       // fresh heap copy
a == c;                             // false! Same bytes, different object
python
# Python: == is VALUE equality, always. `is` is identity.
a = "abc"; b = "abc"                # CPython may reuse the literal object
a == b  # True  (value comparison — always correct)
a is b  # True  here, but NOT guaranteed for dynamically built strings

C# is the hybrid: == on string is overloaded to value equality (safe), while ReferenceEquals exposes identity — and Object.ReferenceEquals("abc", "abc") is true because literals are interned. The danger pattern is Java: == on strings from a file fails in production even though it worked in your REPL, purely because literals are interned and file input isn't.

The memory accounting

Interning pays a permanent price for deduplication: an interned string is (almost) never freed. Java's pool and C#'s intern pool keep their entries alive for the process lifetime; Ruby symbols and Erlang atoms are immortal by design. That's fine for literals and identifiers — bounded, repeated data — and catastrophic for user input. Interning every ORDER_ID your API sees is a memory leak with a hash table's face on it.

Interning is why one == costs one pointer compare and another costs a scan, why your compiler survives name-heavy code, and why "just use ==" is a correctness trap in Java. Know your runtime's policy: it decides whether equality is instant or linear.