TRT
Sign in
Operating Systems

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 Team8 min read#strings#interning#memory#java#csharp#python

"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 — O(n) in the worst case, and always O(n) for values that are actually equal. 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. Automatic interning happens at compile time — sys.intern() opts a runtime-built string in.
  • Ruby/Elixir/Erlang: symbols and atoms are interned. Erlang's atom table is never garbage collected — untrusted input converted with to_atom is the classic unbounded leak. Ruby has garbage-collected dynamically created symbols since 2.2, so the pool only looks permanent for the literals the compiler itself interns.

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.

Production cost model and latency numbers

To apply String Interning and Memory: Why Some == Comparisons Are Instant effectively in production systems, engineers must move beyond qualitative diagrams and quantify the exact hardware and network costs at each boundary:

Operation / BoundaryTypical LatencyDominant BottleneckProduction Mitigation
L1 / L2 Cache Access1 ns – 4 nsRegister dependency chains & pointer chasingContiguous memory layouts, cache-line alignment (64 B), and batch iteration
Main Memory (DRAM) Fetch60 ns – 100 nsTLB misses, random heap indirection, NUMA hopsHugePages (2 MB), arena/slab allocators, and pre-allocated ring buffers
Kernel Syscall / Context Switch1 µs – 5 µsUser-to-kernel mode switch, page-table flush, lock contentionNon-blocking readiness loops (epoll / io_uring), batching, and zero-copy I/O
NVMe SSD Random Read / fsync50 µs – 500 µsBlock I/O queue depth, write amplification, WAL flushesGroup commit, sequential append logs, and bounded buffer-pool pinning
Intra-Region RPC Roundtrip0.5 ms – 2.5 msSerialization, TCP/TLS framing, tail queueing ($L = \lambda W$)Connection pooling, deadline propagation, and p95 hedged requests

Failure modes, debugging, and operational checklist

In real incidents, systems rarely fail at average (p50) load—they fail when a slow dependency, skewed hot key, or resource leak pushes concurrency across a non-linear saturation cliff. When reviewing code or architecture that relies on String Interning and Memory: Why Some == Comparisons Are Instant, walk through three verification questions:

  1. What is the invariant under partial failure or contention? If a worker crashes mid-operation, a network packet is delayed and reordered, or two threads race on the same state transition, prove which atomic primitive, version number, or idempotency key prevents silent corruption.
  2. How is backpressure enforced when demand exceeds capacity? Unbounded in-memory queues, unthrottled retries, and blocking calls without explicit deadlines turn a localized 100 ms slowdown into a fleet-wide Out-Of-Memory (OOM) cascade. Always bound queue depths, propagate caller deadlines, and shed excess load early.
  3. Which metrics and traces isolate the bottleneck in under 60 seconds? Instrument saturation, queue wait time vs service execution time, cache hit/miss ratios, and error rates by failure reason so on-call engineers can immediately distinguish a compute bottleneck from an I/O or lock-contention stall.

Spotted a mistake or something broken? Report a problem

Not started

Sign in to save your learning progress.

Sign in to save