Understanding the String Pool in Java
Every Java program makes the same choice millions of times: which object a string variable points at. Most of the time it is a literal like "GET", and it feels like no choice at all. But the JVM quietly routes identical string values to one shared object — a string pool — so that all the "GET"s in the process can share a single backing array instead of each one allocating its own. Get this model wrong and you will write == comparisons that break, wonder what intern() is for, or find yourself puzzled why "a" + "b" behaves differently than a + b.
This post builds the model from the observable behavior up. Three short programs, each run on OpenJDK 21: the identity/equality contract, the compile-time folding traps, and a hand-rolled pool that makes the deduplication tangible.
Identity vs equality: the pool’s observable contract
== and .equals() ask two different questions. equals() asks “do these hold the same characters?”; == asks “are these literally the same object in memory?” The pool’s whole job is to make those two agree for any two strings that share a value. Here is the minimal case, watching it happen:
public class PoolBasics {
public static void main(String[] args) {
// Both literals have the same content.
String a = "hello";
String b = "hello";
// A fresh object on the heap, bypassing the pool.
String c = new String("hello");
System.out.println("a == b : " + (a == b));
System.out.println("a == c : " + (a == c));
System.out.println("a.equals(b) : " + a.equals(b));
System.out.println("a.equals(c) : " + a.equals(c));
// intern(): if a string equal to this one is already in the pool,
// return the pooled instance; otherwise add it.
String d = new String("hello");
System.out.println("d == a : " + (d == a)); // heap, not pooled yet
d = d.intern();
System.out.println("d == a after : " + (d == a)); // now the pooled one
}
}
The run prints:
a == b : true
a == c : false
a.equals(b) : true
a.equals(c) : true
d == a : false
d == a after : true
Read it down the page and the contract assembles itself. a and b are both the literal "hello", so they land on the one pooled object and a == b is true. c is built with new String("hello") — same characters, but a fresh object with a different identity — so a == c is false. And equals is true in both rows, because it compares content, not identity.
That pairing is the trap worth internalizing: identical content does not imply identity. A literal and a new’d string can hold the same characters and still be different objects.
The last two rows show how to reach the pool at runtime. d starts as yet another new String, so d == a is false. The moment you call d.intern(), the JVM hands back the pool’s "hello" — which is exactly a — so d == a becomes true. d did not gain any characters; it switched to pointing at the already-pooled instance. This is the exact guarantee OpenJDK 21’s String.intern() Javadoc states: “for any two strings s and t, s.intern() == t.intern() is true if and only if s.equals(t) is true.” The pool is keyed on value, and that is the contract everything else hangs on.
Where constant folding sneaks in
So far the pool has one entry point: the compiler puts literals in. But + on strings has two very different fates depending on whether the compiler can know the answer ahead of time. The Javadoc is explicit about the line it draws: “All literal strings and string-valued constant expressions are interned.” The question to ask of any concatenation is therefore simply — is this a compile-time constant?
public class ConcatFinal {
public static void main(String[] args) {
// (1) Compile-time concatenation of literals.
// javac folds "foo" + "bar" into the single literal "foobar"
// and stores it in the pool, so this is the SAME object as "foobar".
String s1 = "foo" + "bar";
System.out.println("\"foo\" + \"bar\" == \"foobar\" : " + (s1 == "foobar"));
// (2) Runtime concatenation. At least one operand is a variable,
// so the compiler must build the result at runtime (StringBuilder),
// producing a fresh heap object that is NOT in the pool.
String a = "foo";
String b = "bar";
String s2 = a + b;
System.out.println("a + b == \"foobar\" : " + (s2 == "foobar"));
System.out.println("s2.equals(\"foobar\") : " + s2.equals("foobar"));
// (3) A final local variable is a compile-time constant, so the
// expression is folded just like case (1).
final String prefix = "foo";
String s3 = prefix + "bar";
System.out.println("final prefix + \"bar\" == \"foobar\" : " + (s3 == "foobar"));
}
}
The run prints:
"foo" + "bar" == "foobar" : true
a + b == "foobar" : false
s2.equals("foobar") : true
final prefix + "bar" == "foobar" : true
Three concatenations, three different answers to the same == "foobar" test:
"foo" + "bar"→true. Both operands are literals, so this is a string-valued constant expression. javac folds it to the single constant"foobar", which is interned, so it is the same object as the literal on the right.a + b→false. Here at least one operand is a runtime value, so the result is assembled at runtime into a fresh object that is not in the pool. Notices2.equals("foobar")is stilltrue— same content, different object, the exact trap from the previous section, this time produced by a compiler rule instead ofnew.final prefix + "bar"→true. This is the one that usually surprises.prefixis a constant variable —final, assigned a literal (JLS §4.12.4) — so the whole expression is again a compile-time constant, gets folded, and lands on the pooled"foobar".
Adding the single keyword final to a local variable flips a == result, even though not a single character changes. That is the moment the naive model — “concatenation is runtime work, that’s it” — breaks: whether your expression is pooled depends on whether the compiler can reduce it to a constant, and final is what buys that reduction.
What a pool actually buys you
You could stop at “the JVM dedups strings.” But the clearest way to understand why that matters is to build a pool yourself. OpenJDK’s String keeps, in the Javadoc’s words, “a pool of strings … maintained privately by the class String”; deduplication is the entire point. A tiny hand-rolled version makes the mechanism visible.
import java.util.HashMap;
import java.util.Map;
// A hand-rolled string pool: interning hands you back a single canonical
// object for each distinct value, so duplicates share one backing array
// instead of each owning a copy.
class StringPool {
private final Map<String, String> pool = new HashMap<>();
private long totalInsertions = 0;
String intern(String s) {
totalInsertions++;
return pool.computeIfAbsent(s, k -> s);
}
long size() { return pool.size(); }
long insertions() { return totalInsertions; }
}
public class CustomPool {
public static void main(String[] args) {
StringPool pool = new StringPool();
// Two DISTINCT heap objects with the same content, going in separately.
// A good interner must hand both callers back the very same instance.
String g1 = pool.intern(new String("GET"));
String g2 = pool.intern(new String("GET"));
String p1 = pool.intern(new String("POST"));
System.out.println("two GET interns share one object : " + (g1 == g2));
System.out.println("GET vs POST share one object : " + (g1 == p1));
// A workload where most tokens repeat.
String[] tokens = {
"GET", "GET", "GET", "POST", "POST",
"GET", "PUT", "DELETE",
"GET", "GET", "POST", "GET"
};
for (String t : tokens) {
pool.intern(t);
}
System.out.println("total intern() calls : " + pool.insertions());
System.out.println("distinct strings stored : " + pool.size());
System.out.println("duplicate copies avoided : "
+ (pool.insertions() - pool.size()));
}
}
The run prints:
two GET interns share one object : true
GET vs POST share one object : false
total intern() calls : 15
distinct strings stored : 4
duplicate copies avoided : 11
The first two lines prove the interner does its job on genuinely distinct objects: two separate new String("GET") instances go in, and both callers get back the very same instance (true), while GET and POST stay distinct (false). The interner is collapsing things by value, exactly like the JDK’s pool.
The three numbers show the payoff. Fifteen intern() calls, but only four distinct values — so eleven insertions reused an object the pool already had instead of storing a new one. On a workload where values repeat — HTTP verbs, header names, enum labels, SQL column identifiers — the pool is what keeps you from allocating a brand-new backing array for every one of those repetitions. This run is a demonstration of the counting, not a benchmark; what it establishes is the mechanism, and that mechanism is the reason the pool exists.
Takeaway
The string pool is a value-to-object registry: identical contents share one object, and there are exactly two doors in — the compiler (for literals and constant expressions, including final locals) and intern() (for heap strings). The one rule that follows from that: compare strings with equals(), because == only works when you already know the two references happened to come from the same pool entry — and that is a property of the value’s history, not something the language promises.