Why is String immutable, and what is the string pool?
String is immutable because nothing in its API exposes a mutator — not because its byte[] field is final. The pool is a JVM-wide cache of literals that makes == accidentally work on literals and fail everywhere else.
The Answer
Say this in the room. 45 seconds.
Stringis immutable because its API never exposes a mutator. Every method that looks like it changes a string returns a new one.- The
private final byte[] valuefield is part of that, butfinalonly stops the reference being reassigned — it does nothing to protect the array's contents. Encapsulation does that. - Immutability is what makes strings safe to share, safe across threads, safe as map keys, and lets the hash code be cached.
- The string pool is a JVM-wide table of
Stringinstances. Compile-time constants are interned automatically, so identical literals are the same object. - That's why
==accidentally works on literals and fails on anything built at runtime. Compare withequals(), always. - Since Java 9 the array is a
byte[], one byte per character for Latin-1 text.
Understand It
final is not why it's immutable
The claim you'll read everywhere is that String is immutable "because the field is final". Reach for the field with reflection and the claim falls apart:
Field value = String.class.getDeclaredField("value");
value.setAccessible(true);
System.out.println("declaration: " + Modifier.toString(value.getModifiers())
+ " " + value.getType().getSimpleName());
String s = new String("hello".toCharArray());
System.out.println("before: " + s);
byte[] raw = (byte[]) value.get(s);
raw[0] = 'j';
System.out.println("after: " + s);declaration: private final byte[]
before: hello
after: jelloThe field is final, and the string's content changed anyway. final on a reference field means the reference cannot be reassigned — it says nothing about the object the reference points to. A final byte[] is a permanently-pointed-at, entirely writable array.
So what actually makes String immutable is narrower and more interesting: the array is private, and no method hands it out or writes to it. toCharArray() returns a copy. getBytes() returns a copy. substring(), replace(), trim(), toUpperCase() all return new strings. There is no setCharAt. The guarantee is a property of the API surface, not of a keyword.
That distinction is the answer to "could you write your own immutable class?" — you can't get there by sprinkling final on fields. You get there by never leaking a reference to internal mutable state.
The consequence nobody mentions: the cached hash
String caches its hash code in a field, computing it once. Mutate the array behind its back and the cache goes stale:
String h = new String("hello".toCharArray());
System.out.println("hash before = " + h.hashCode());
Field value = String.class.getDeclaredField("value");
value.setAccessible(true);
((byte[]) value.get(h))[0] = 'j';
System.out.println("value now = " + h);
System.out.println("hash after = " + h.hashCode());
System.out.println("real \"jello\" hash = " + "jello".hashCode());hash before = 99162322
value now = jello
hash after = 99162322
real "jello" hash = 101009364The string reads jello and reports hello's hash forever. It is now a key that cannot be found in any HashMap — the same failure as a mutable key, reached from the other direction. Caching the hash is only sound because the value can't change, which is a concrete example of immutability buying performance rather than costing it.
Nobody does this in production. It's worth doing once, because it converts "String is immutable" from a slogan into a mechanism you can explain.
The pool, and why == lies
The pool is one JVM-wide table of String objects. The compiler interns every string literal and every compile-time constant expression into it, so two identical literals are one object:
String s1 = "hello";
String s2 = "hello";
String s3 = new String("hello");
System.out.println("literal == literal : " + (s1 == s2));
System.out.println("literal == new : " + (s1 == s3));
System.out.println("literal == intern : " + (s1 == s3.intern()));
String folded = "hel" + "lo"; // both halves are constants
System.out.println("compile-time fold : " + (s1 == folded));
String prefix = "hel";
String runtime = prefix + "lo"; // prefix is a variable
System.out.println("runtime concat : " + (s1 == runtime));literal == literal : true
literal == new : false
literal == intern : true
compile-time fold : true
runtime concat : falseRead the fourth and fifth lines together, because that pair is the whole trap. "hel" + "lo" is folded by javac into the literal "hello" and interned. Change one half to a variable and the concatenation happens at runtime, producing a fresh object that was never interned — so == flips to false on code that looks identical.
new String("hello") explicitly allocates outside the pool: it's the one reliable way to get a string that is equals but not ==. intern() asks the pool for the canonical instance, returning the pooled one if it's there and adding this one if not.
This is why == on strings is a bug that passes its own tests. Literals in a unit test compare true; the same code fed a value from a database, a request parameter, or a file returns false.
There's a sharper version of the folding rule that almost nobody knows. A final local initialised with a literal is a constant variable in the language spec's sense, so expressions using it are folded too:
String a = "java";
String plain = "ja";
final String constant = "ja";
System.out.println("plain + \"va\" == \"java\" : " + (a == (plain + "va")));
System.out.println("constant + \"va\" == \"java\" : " + (a == (constant + "va")));plain + "va" == "java" : false
constant + "va" == "java" : trueTwo lines that differ only by the keyword final, and == gives opposite answers. Nothing about mutability changed — what changed is when the value is known. Add final and the concatenation is resolved at compile time and interned; remove it and the same expression is computed at runtime into a fresh object.
If you needed one example to justify never using == on strings, it's this one: the correctness of the comparison depends on a modifier on an unrelated local variable.
Compact strings: byte[], not char[]
Since Java 9 the backing array holds one byte per character when every character fits in Latin-1, and falls back to two bytes per character otherwise:
Field value = String.class.getDeclaredField("value");
value.setAccessible(true);
for (String s : new String[] { "abc", "ééé", "日本語" }) {
System.out.println(s.length() + " chars -> "
+ ((byte[]) value.get(s)).length + " bytes");
}3 chars -> 3 bytes
3 chars -> 3 bytes
3 chars -> 6 bytesé is U+00E9, inside Latin-1, so it still costs one byte. The CJK string can't be encoded that way, so the whole string falls back to two bytes per character — the coder is per-string, not per-character. One non-Latin-1 character doubles the cost of the entire string.
Note that length() keeps returning the number of UTF-16 code units regardless. The representation changed; the API contract didn't. That's why this was shippable in a minor release of the most-used class in the platform.
+ has not compiled to StringBuilder since Java 9
A claim worth retiring: "the compiler rewrites + into StringBuilder." It did, up to Java 8. Compile "x=" + a + b on JDK 21 and javap -c shows:
invokedynamic #7, 0 // InvokeDynamic #0:makeConcatWithConstants:(Ljava/lang/String;I)Ljava/lang/String;
One invokedynamic call site, no StringBuilder in the bytecode at all. The JVM links it on first execution to a method handle that sizes the result once and fills it in a single pass.
What has not changed is the advice about loops. Concatenating inside a loop still allocates a new string per iteration, because each + produces a complete new immutable object regardless of how it's implemented. StringBuilder is still the right answer in a loop — for the allocation, not for the bytecode shape.
Interviewer's Next Move
The question after the question. This is what the round is actually testing.
1. "Is String immutable because the value field is final?"
No. final prevents reassigning the reference, not writing through it — reflect into that final byte[] and you can change a string's contents in place. Immutability comes from the array being private and no method exposing or mutating it: toCharArray() and getBytes() both return copies.
2. "Why does == sometimes work on strings?"
Because literals and compile-time constants are interned into a JVM-wide pool, so identical literals are the same object. Anything built at runtime is a fresh object. "hel" + "lo" == "hello" is true because javac folds it; make one half a variable and it becomes false.
3. "Where does the pool live, and has that changed?" It moved from PermGen to the heap in Java 7, which is why interning user input stopped being a way to blow up PermGen. PermGen itself was removed in Java 8 in favour of Metaspace.
4. "What does substring() cost?" O(n) — it copies. Before 7u6 it was O(1) and shared the parent's array with an offset, which meant a small substring pinned the entire original array in memory. They traded a constant-time operation for not leaking, which is almost always the right trade.
5. "What is a String actually made of on Java 21?"
A private final byte[] plus a one-byte coder field. Latin-1 text is one byte per character; anything outside it is UTF-16 at two bytes per character, decided per string. Before Java 9 it was always char[].
6. "Does + still compile to StringBuilder?"
Not since Java 9. It compiles to an invokedynamic call to makeConcatWithConstants, and the JVM builds the result in one pass. StringBuilder is still correct advice in a loop, because each + still produces a whole new string.
7. "Give me a real reason immutability matters beyond thread safety."
The hash code is cached after first computation, which is only sound because the value can't change — that's what makes String cheap as a HashMap key. Interning is only possible for the same reason: you can't share instances of something anyone might mutate.
Code traps
Trap A — predict before you run:
String a = "java";
String b = "ja" + "va";
String c = new String("java");
String d = c.intern();
System.out.println((a == b) + " " + (a == c) + " " + (a == d) + " " + a.equals(c));
Answer
true false true true. b is folded at compile time into the same literal, so a == b. c is a fresh allocation outside the pool. d is the pooled instance, which is a. And equals is true for all of them — which is the only comparison you should have written.
Trap B:
String s = "hello";
s.toUpperCase();
s.concat(" world");
s.replace('h', 'j');
System.out.println(s);
Answer
hello. Three method calls, three new strings, all discarded. Every String method returns a new instance and none of them can modify s. This is the immutability rule stated as a bug: forgetting to assign the result is the most common mistake beginners make with strings, and the compiler cannot warn about it.
Common wrong answers
| Said in interviews | Reality |
|---|---|
| "It's immutable because the field is final." | final protects the reference, not the array. Encapsulation does the work. |
| "The pool is in PermGen." | Heap since Java 7; PermGen has not existed since Java 8. |
| "substring() is O(1) because it shares the array." | True before 7u6. It copies now, and that removed a real memory leak. |
| "A String is a char[]." | byte[] plus a coder since Java 9. |
| "The compiler turns + into StringBuilder." | invokedynamic makeConcatWithConstants since Java 9. |
| "== is fine, I always use literals." | Until the value comes from a request, a file, or a database. |
Check Yourself
Q1. "hel" + "lo" == "hello" is true, but with String p = "hel"; p + "lo" == "hello" it's false. What single word explains the difference?
Answer
Constant. Both halves of the first are compile-time constants, so javac folds and interns the result. p is a variable, so the second concatenation happens at runtime and produces an object that was never interned.
Q2. You reflect into a String and overwrite its backing array. hashCode() afterwards returns the old value. Why, and why does that matter?
Answer
The hash is computed once and cached in a field. It matters because it shows the cache is only sound while the value cannot change — and a string with a stale hash is a map key that can never be found again.
Q3. Why did compact strings ship as a JVM-internal change rather than an API change?
Answer
Because nothing observable changed: length(), charAt() and every other method still work in UTF-16 code units. Only the internal representation changed, so the most-used class in Java could be rewritten without breaking a single caller.
Practice
| Tier | Exercise | Time |
|---|---|---|
| Warm-up | Make == lie to you | 5 min |
| Challenge | Break a String with reflection | 20 min |
| Production | The token cache that leaked the heap | 45 min |
| Interview | Full round replay | 10 min |
What changed, and when
Version boundaries are where follow-ups live. Know the change and the behaviour it replaced.
- Java 7
The string pool moved out of PermGen and onto the normal heap, so interned strings became collectable and the pool could grow with the heap.
Before Java 7 (now gone): The pool lived in PermGen at a fixed maximum size. Interning user-supplied input was a documented way to produce OutOfMemoryError: PermGen space, and interned strings were effectively never collected.
- Java 7
substring() copies the characters it needs. Arrived in 7u6, along with the removal of String's offset and count fields.
Before Java 7 (now gone): substring() shared the parent's char[] and stored an offset and length. It was O(1), but a three-character substring of a 10 MB string kept all 10 MB alive — a memory leak that looked like correct code.
- Java 8LTS
PermGen removed outright, replaced by Metaspace, which grows into native memory.
Before Java 8 (now gone): -XX:MaxPermSize was a tuning parameter every Java web application eventually had to learn.
- Java 9
Compact Strings: the backing array became a byte[] plus a one-byte coder, storing Latin-1 text at one byte per character.
Before Java 9 (now gone): The backing array was always char[] — two bytes per character, so ASCII text cost double for nothing. Strings are typically the largest single category of live objects in a heap, so this was among the largest free wins in JVM history.
- Java 9
String concatenation with + compiles to invokedynamic makeConcatWithConstants, letting the JVM build the result in one pass at runtime (JEP 280).
Before Java 9 (now gone): javac emitted an explicit StringBuilder chain: new StringBuilder().append(..).append(..).toString(). That is why 'the compiler rewrites + into StringBuilder' is now a decade out of date.
Practice ladder
Reading this page is not knowing it. Four tiers, ending in a real incident.
- Warm-up5 min
Make == lie to you
One concept, guided. Near-impossible to fail.
- Challenge20 min
Break a String with reflection
Edge cases. You have to reason, and two valid fixes differ.
- Production incident45 min
The auth check that passed every test and failed every user
A real incident: symptom first, cause hidden, tradeoff at the end.
- Interview replay10 min
Full round replay — String immutability and the pool
Timed verbal replay with pass/fail criteria per follow-up.
Where this question goes next
- string vs stringbuilder — not written yet
- What is the contract between hashCode() and equals()?
- string switch — not written yet
- metaspace vs permgen — not written yet
Every runnable example above was compiled and executed against openjdk 21.0.11 on this build, and its output diffed against what this page claims. Last updated 2026-08-24.