the string pool
literals, intern(), and every == trap in one place.
- chapter
- chapter 17.03
- reading time
- 9 min read
the string pool is a table inside the JVM (the StringTable) that maps text to one canonical String object. every string literal goes through it the first time it runs, and so does every call to intern(). so two equal literals are the same object, while a string built at run time is a new one, unless javac could fold it into a constant first. that one rule explains every == puzzle.
the picture
you know how == on two strings sometimes works and sometimes doesn't? kinda cursed. it's not random though: == compares references (see stack and heap), and whether two strings share a reference depends entirely on whether both came out of the pool.
think of a library's catalogue. a literal is a book request by title: the librarian checks the catalogue and hands you the copy on the shelf, and everyone asking for that title gets the same copy. new String(...) is photocopying the book: same words, your own copy. intern() is walking your photocopy to the desk and asking "do you have this one?". if they do, you get theirs. if they don't, yours becomes the library's copy.
- 1void main() {
- 2 String a = "jle";
- 3 String b = "jle";
- 4 String c = new String("jle");
- 5 String d = c.intern();
- 6 IO.println(a == b);
- 7 IO.println(a == c);
- 8 IO.println(a == d);
- 9}
·main starts. the literal "jle" is still just bytes in the class file.
a, b and d end up as one arrow to the pooled object; c is the odd one out. step 5 is the one people get wrong.when literals enter the pool
not at class loading, and not at compile time. a literal sits in the class file as a CONSTANT_String entry, and the JVM resolves it the first time an ldc instruction that uses it actually runs. resolving it means "look this text up in the pool, and add a new String if it isn't there". after that the result is cached in the class's constant pool, so the lookup happens once per literal, not once per execution.
you can catch that laziness in the act. build a string at run time, intern it before the matching literal has run, and the pool adopts your object:
- 1void main() {
- 2 String x = new String("jle-") + new String("pool");
- 3 IO.println(x.intern() == x);
- 4 IO.println(x == "jle-pool");
- 5}
·the class is loaded. its constant pool has three CONSTANT_String entries, all unresolved: just bytes in the class file. nothing is in the string pool yet.
x is already the pooled copy, so both lines print true.void main() {
String x = new String("jle-") + new String("pool");
IO.println(x.intern() == x);
IO.println(x == "jle-pool");
}true true
try the same trick with "java" and the first line prints false: the JDK's own startup code already used the literal "java", so it's in the pool before your main runs. which strings are pre-pooled depends on the JDK build, which is the first hint that you should never write code whose correctness depends on any of this.
compile-time constants: the folding rule
the other half of the story happens before the JVM sees anything. the JLS defines constant expressions: literals, plus operators applied to them, plus constant variables (a final variable of a primitive type or String, initialized with a constant expression). javac evaluates every constant expression at compile time. so "a" + "b" never runs a concatenation, it becomes the single constant "ab":
$ javap -c Fold.class # String a = "a"; final String fa = "a";
0: ldc #7 // String a String a = "a"
3: ldc #9 // String ab "a" + "b"
6: ldc #9 // String ab fa + "b" (fa is a constant)
9: aload_0
10: invokedynamic #11, 0 // makeConcatWithConstants a + "b" (runtime)(notice fa doesn't even get a local slot. a constant variable is inlined everywhere it's used.) the runtime one goes through invokedynamic, which is a story of its own: string concatenation internals. whatever it does, it produces a new String, never a pooled one.
now you can do all of these in your head:
- 01"a" + "b" == ab
- String a = "a";02a + "b" == ab
- final String a = "a";03a + "b" == ab
- final String a = getA(); // returns "a"04a + "b" == ab
- static final String A = "a";05A + "b" == ab
- final var a = "a";06a + "b" == ab
- 07"a" + 'b' == ab
- 08new String("ab") == ab
- 09new String("ab").intern() == ab
- String a = "a";10(a + "b").intern() == ab
- 11ab.concat("") == ab
- 12"a" + 1 == "a1"
all twelve cases as one program+
same order as the quiz, four per line:
static final String A = "a";
static String getA() { return "a"; }
void main() {
String ab = "ab";
String a = "a";
final String fa = "a";
final String fm = getA();
final var va = "a";
IO.println(("a" + "b" == ab) + " " + (a + "b" == ab) + " " + (fa + "b" == ab) + " " + (fm + "b" == ab));
IO.println((A + "b" == ab) + " " + (va + "b" == ab) + " " + ("a" + 'b' == ab) + " " + (new String("ab") == ab));
IO.println((new String("ab").intern() == ab) + " " + ((a + "b").intern() == ab) + " " + (ab.concat("") == ab) + " " + ("a" + 1 == "a1"));
}true false true false true true true false true true true true
String s = "hel";
String t = s + "lo";
IO.println(t == "hello");where the pool lives
two parts, and people mix them up:
- the strings
- ordinary
Stringobjects on the ordinary heap. before java 7 interned strings lived in permgen, a small fixed-size area that loved to throwOutOfMemoryError: PermGen space. java 7 moved them into the main heap. - the StringTable
- a hash table in the JVM's native memory, mapping content to those objects. its references are weak: an interned string that nothing else points at can be garbage collected, and its entry disappears.
you can watch the table with jcmd <pid> VM.stringtable. here's a program that interns a million distinct strings, then drops them and calls System.gc(), sampled three times on jdk 25:
$ jcmd 56497 VM.stringtable # at startup
Number of buckets : 65536 = 524288 bytes, each 8
Number of entries : 23 = 368 bytes, each 16
Shared String Table statistics:
Number of entries : 8186
$ jcmd 56497 VM.stringtable # after interning 1,000,000 strings
Number of buckets : 131072 = 1048576 bytes, each 8
Number of entries : 1000024 = 16000384 bytes, each 16
Average bucket size : 7.630
$ jcmd 56497 VM.stringtable # after dropping them and a GC
Number of buckets : 262144 = 2097152 bytes, each 8
Number of entries : 24 = 384 bytes, each 16three things to read off that:
- the table starts at
-XX:StringTableSizebuckets (65,536 by default on jdk 25) and grows by itself when chains get long. the old advice to bumpStringTableSizefor intern-heavy apps mostly predates that. - interned strings are collected: a million entries went back to 24. interning is not a leak by itself. holding the strings is.
- the 8,186 "shared" entries are strings baked into the JDK's CDS archive, mapped in at startup before any of your code runs.
string deduplication is a different thing
-XX:+UseStringDeduplication is often confused with the pool. it doesn't touch == at all. during GC it finds long-lived String objects with equal content and makes them share one byte[]. long-lived means they survived a few collections: three by default, set with StringDeduplicationAgeThreshold. it started as a G1-only feature in java 8u20; on jdk 25 it worked with every collector we tried (serial, parallel, G1, ZGC, Shenandoah).
String objects (so a == b is still false), but one shared byte[]. measured with reflection on jdk 25: with the flag off the arrays stay separate; with it on they were the same array after five GCs.$ java -XX:+UseStringDeduplication --add-opens java.base/java.lang=ALL-UNNAMED Dedup.java
same String? false
same byte[]? false
after 5 GCs:
same String? false
same byte[]? trueso: interning gives you one String and changes ==. dedup gives you one byte[], happens in the background, and changes nothing you can observe without reflection. it's the one to turn on if a heap dump shows thousands of copies of the same strings.
==on strings compares objects. equal text only means the same object when both came from the pool.- literals are pooled lazily, the first time their
ldcruns.intern()adds to the same pool. - javac folds constant expressions (literals,
finalconstant variables) at compile time. anything else concatenates at run time into a new String. - the String objects live on the heap (since java 7). the StringTable holds them weakly, grows on its own, and lets unused ones be collected.
- string dedup shares
byte[]s between equal strings during GC. different feature, invisible to==. - use
equals. always.