skip to content

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.

Main.java next to run
  1. 1void main() {
  2. 2 String a = "jle";
  3. 3 String b = "jle";
  4. 4 String c = new String("jle");
  5. 5 String d = c.intern();
  6. 6 IO.println(a == b);
  7. 7 IO.println(a == c);
  8. 8 IO.println(a == d);
  9. 9}
stdout
·
stack
main
no locals yet
heap
nothing allocated yet.

main starts. the literal "jle" is still just bytes in the class file.

step 1/8
a real run, traced on jdk 25. 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:

Main.java
  1. 1void main() {
  2. 2 String x = new String("jle-") + new String("pool");
  3. 3 IO.println(x.intern() == x);
  4. 4 IO.println(x == "jle-pool");
  5. 5}
stdout
·
Main's constant poolCONSTANT_String entries
"jle-"unresolved
"pool"unresolved
"jle-pool"unresolved
StringTablethe pool: a hash table inside the JVM
empty (for these strings)
heapx not assigned yet
nothing yet

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.

step 1/4
step through it. the literal on line 4 is in the class file the whole time, but it isn't resolved until line 4 runs. by then x is already the pooled copy, so both lines print true.
java
void main() {
    String x = new String("jle-") + new String("pool");
    IO.println(x.intern() == x);
    IO.println(x == "jle-pool");
}
output
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":

terminal
$ 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:

in every row, String ab = "ab";pick true or false
  1. 01"a" + "b" == ab
  2. String a = "a";
    02a + "b" == ab
  3. final String a = "a";
    03a + "b" == ab
  4. final String a = getA(); // returns "a"
    04a + "b" == ab
  5. static final String A = "a";
    05A + "b" == ab
  6. final var a = "a";
    06a + "b" == ab
  7. 07"a" + 'b' == ab
  8. 08new String("ab") == ab
  9. 09new String("ab").intern() == ab
  10. String a = "a";
    10(a + "b").intern() == ab
  11. 11ab.concat("") == ab
  12. 12"a" + 1 == "a1"
twelve cases, every one run on jdk 25. answer them in order; each answer comes with the reason. the trick is always the same question: could javac compute this?
one level deeper:all twelve cases as one program+

same order as the quiz, four per line:

java
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"));
}
output
true false true false
true true true false
true true true true
predict the output
java
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 String objects on the ordinary heap. before java 7 interned strings lived in permgen, a small fixed-size area that loved to throw OutOfMemoryError: 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:

terminal
$ 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 16

three things to read off that:

  • the table starts at -XX:StringTableSize buckets (65,536 by default on jdk 25) and grows by itself when chains get long. the old advice to bump StringTableSize for 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).

stack
main
a
b
heap
String
value
coder0
hash0
String
value
coder0
hash0
byte[]"dedup-me", shared after dedup
1000
1011
1002
1173
1124
455
1096
1017
after dedup: still two 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.
terminal
$ 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[]?   true

so: 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.

keylines
  • == 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 ldc runs. intern() adds to the same pool.
  • javac folds constant expressions (literals, final constant 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.