String immutability in Java
-
Last Updated: December 8, 2024
-
By: javahandson
-
Series
String immutability in Java means one simple thing: once you create a String object, nothing can change the characters inside it. Not you, not another thread, not a method you pass it to. Every “change” you make actually hands you a brand new String.
That single rule explains a surprising amount of Java. It explains the String Constant Pool, it explains why HashMap loves String keys, and it explains why building text inside a loop can quietly wreck your performance. In this guide we will unpack string immutability in Java from the ground up, with small programs you can run yourself.
Beginners meet String on day one. It looks like the friendliest type in the language. You write text between double quotes, you glue pieces together with a plus sign, and everything works.
Then a strange thing happens. You call name.toUpperCase() and print name, and the text has not changed at all. Nothing threw an exception. Nothing warned you. The value simply stayed the same.
That moment is where immutability stops being trivia and starts being a rule you must know. Once you understand it, a whole family of Java behaviours suddenly makes sense.
new, and intern()Immutable means “cannot change after creation”. A String object holds a sequence of characters, and that sequence is fixed for the entire life of the object.
Here is the part beginners misread. Immutability protects the object, not the variable. Your variable is just a reference. You can point it somewhere else whenever you like.
Picture a sticky note on your desk with the word Java written on it in permanent ink. You cannot erase it. You cannot add letters to it.
Now you want the text Java HandsOn. What do you do? You grab a fresh sticky note, copy the old text, add the new word, and look at that note instead.
The old note still sits on the desk, unchanged. Somebody else might still be reading it. That is exactly how Java treats Strings.
String str = "Java"; str = str + " HandsOn"; System.out.println(str); // Output: Java HandsOn
Line 2 looks like an edit. It is not. Java builds a completely new String holding Java HandsOn, then repoints str at it.
The original Java object never moved and never changed. It simply lost one reference. Any other variable still pointing at it sees the old value, exactly as before.
This next snippet makes the point painfully clear:
String a = "Java"; String b = a; // b points at the same object a = a + " HandsOn"; // a now points at a NEW object System.out.println(a); // Output: Java HandsOn System.out.println(b); // Output: Java
If String were mutable, b would print Java HandsOn too. That silent action at a distance is precisely the bug class immutability removes.
Immutability here is not a polite convention. The JDK locks it down with four deliberate design choices:
String class carries the final modifier, so nobody can subclass it and sneak in a setterfinal and private, so no outside code can swap it outString writes to that array; every text method returns a fresh objectOne detail changed under the hood in Java 9. Older releases stored the characters in a char[]. Modern releases use a byte[] plus a small coder flag, so plain ASCII text takes half the memory. That optimisation goes by the name Compact Strings, and it changes nothing about the rules you write against.
Text repeats constantly in real programs. Think of the word ACTIVE appearing as a status value across ten thousand records.
Java refuses to waste memory on ten thousand identical objects. The String Constant Pool solves that.
The pool is a cache of String objects that the JVM maintains, keyed by their text. People also call it the String Pool or the intern pool. All three names mean the same thing.
Whenever your code uses a string literal, the JVM checks the pool first:
String s1 = "Java"; // pool is empty, so the JVM adds "Java" String s2 = "Java"; // "Java" already sits in the pool, so s1 and s2 share it String s3 = "HandsOn"; // new text, so the JVM adds a second entry System.out.println(s1 == s2); // Output: true System.out.println(s1 == s3); // Output: false
Two variables, one object. Ten thousand ACTIVE literals across your codebase collapse into a single object in memory.
Now flip it around. Suppose Strings could change.
Your billing class calls s1.append("Script"). Because s1 and s2 share one object, your reporting class suddenly sees JavaScript where it expected Java. Two unrelated modules corrupt each other through a shared cache.
Sharing only stays safe when nobody can write. Immutability is the price of admission for the pool, and the memory savings are the payoff.
Java 6 and earlier kept the pool in PermGen, a small fixed region. Interning too much text there triggered OutOfMemoryError: PermGen space.
Java 7 moved the pool into the regular heap. Java 8 then deleted PermGen entirely and replaced it with Metaspace.
So on any modern JVM the pool lives on the heap, the garbage collector can reclaim unused entries, and the old PermGen limit no longer applies. Treat the pool as a normal heap-resident cache with a hash table on top.
You can build a String three ways, and each one lands in a different place. Knowing the difference answers most String puzzles you will ever face.
new keyword, like any other objectintern() method, which pushes text into the pool by handA literal is the everyday form. Write text in quotes and assign it. The JVM routes it through the pool automatically.
public class StringLiteralDemo {
public static void main(String[] args) {
String str1 = "Java";
String str2 = "Java";
String str3 = "HandsOn";
System.out.println("str1 : " + System.identityHashCode(str1));
System.out.println("str2 : " + System.identityHashCode(str2));
System.out.println("str3 : " + System.identityHashCode(str3));
}
}
// Output (your numbers will differ, the pattern will not):
// str1 : 23934342
// str2 : 23934342
// str3 : 22307196System.identityHashCode() derives a number from the object identity rather than its text. Two identical numbers mean one shared object.
Notice how str1 and str2 report the same value. One object, two references. That is the pool doing its job.
The new keyword skips the pool lookup completely. It orders a fresh heap object every single time, even when identical text already exists.
public class StringNewDemo {
public static void main(String[] args) {
String str1 = "Java"; // pool
String str2 = new String("Java"); // brand new heap object
String str3 = new String("Java"); // another brand new heap object
System.out.println(str1 == str2); // Output: false
System.out.println(str2 == str3); // Output: false
System.out.println(str1.equals(str2)); // Output: true
}
}Three references, three distinct objects. The text matches, so equals() returns true, but == compares identity and reports false.
There is a hidden cost too. The literal "Java" inside the constructor still enters the pool, so new String("Java") can leave you with two objects instead of one. Skip new unless you have a very specific reason.
The intern() method asks the pool for the canonical object holding this text. Think of it as opting back in after using new.
public class StringInternDemo {
public static void main(String[] args) {
String str1 = "Java";
String str2 = new String("Java").intern();
String str3 = new String("HandsOn");
str3 = str3.intern(); // you MUST reassign
System.out.println(str1 == str2); // Output: true
System.out.println(str3); // Output: HandsOn
}
}Watch line 7 carefully. Calling str3.intern() on its own achieves nothing useful, because the method returns a reference instead of modifying str3. Immutability again: no method ever rewrites the receiver.
One more subtlety worth knowing. Since Java 7 the pool can store a reference to an object that already lives on the heap, rather than copying the text into a separate pooled object:
String built = new StringBuilder("hands").append("on").toString();
// "handson" was not in the pool before, so intern() registers THIS object
System.out.println(built.intern() == built); // Output: trueHere is a result that surprises almost everybody:
String x = "Java" + "HandsOn"; System.out.println(x == "JavaHandsOn"); // Output: true String part = "Java"; String y = part + "HandsOn"; System.out.println(y == "JavaHandsOn"); // Output: false final String fixedPart = "Java"; String z = fixedPart + "HandsOn"; System.out.println(z == "JavaHandsOn"); // Output: true
Why the difference? The compiler can evaluate "Java" + "HandsOn" before your program ever runs, because both halves are compile-time constants. It bakes the joined literal straight into the class file, and the pool takes over from there.
A plain local variable carries no such guarantee, so the join happens at runtime and produces a fresh object. Mark it final and it becomes a constant again, so folding returns.
The lesson is not “memorise the table”. The lesson is to compare text with equals() and stop worrying about any of it.
This was a deliberate call, not an accident of history. Four benefits justify it.
We covered this above, so a one-line recap will do. Caching one object behind many references only works when nobody can rewrite it.
The same logic extends past the pool. You can hand a String to any method without a defensive copy, because that method has no way to damage your value.
A String computes its hash code once and stores the result in a private field. Every later call reads that field instead of walking the characters again.
That trick would collapse instantly if the text could change, since the cached number would go stale. Because the text is frozen, the cache stays correct forever.
This is exactly why String makes such a good HashMap key. Long keys cost you one hash computation, no matter how many lookups follow.
Map<String, Integer> scores = new HashMap<>();
scores.put("alice", 91);
// "alice" hashes once, then every lookup reuses the cached value
System.out.println(scores.get("alice")); // Output: 91Race conditions need two ingredients: shared state and a writer. Immutability removes the writer.
Twenty threads can read one String at the same time with zero locks, zero synchronized blocks, and zero surprises. You never need to defend a String from concurrency.
Compare that to a StringBuilder, which several threads can absolutely corrupt if they share it carelessly.
Java passes Strings around for its most sensitive decisions: file paths, database URLs, class names, and network hosts.
Now imagine a mutable String. Your code validates a path, the security manager approves it, and a background thread rewrites the characters a microsecond later. The check passes while the actual operation targets something else entirely.
Immutability closes that window. Whatever a method validated is whatever it later uses. Class loading depends on the same guarantee, because a class name must not shift between the lookup and the load.
Understanding the mechanics here separates working code from fast code.
Every text method on String follows the same contract: read the original, return something new, touch nothing.
String name = " java handson ";
name.trim(); // result thrown away
name.toUpperCase(); // result thrown away
System.out.println("[" + name + "]"); // Output: [ java handson ]
String cleaned = name.trim().toUpperCase();
System.out.println("[" + cleaned + "]"); // Output: [JAVA HANDSON]Lines 3 and 4 do real work and then discard it. The compiler stays quiet, which is what makes this bug so easy to ship.
Chaining works nicely because each call returns a String, ready for the next call. Just remember to capture the final result.
Now for the performance trap that shows up in real production code:
// Slow: quadratic work
String report = "";
for (int i = 0; i < 100000; i++) {
report = report + i + ","; // copies everything, every single pass
}
// Fast: linear work
StringBuilder sb = new StringBuilder();
for (int i = 0; i < 100000; i++) {
sb.append(i).append(',');
}
String fastReport = sb.toString();Trace the slow version. Pass one copies a few characters. Pass fifty thousand copies fifty thousand characters, only to throw that result away on the next pass.
Total work grows with the square of the loop count. At a hundred iterations nobody notices. At a hundred thousand your method hangs for seconds and the garbage collector thrashes.
The StringBuilder version keeps one resizable buffer and appends into it. Same output, wildly different cost.
A fair question: does the compiler not optimise this away?
Partly. For a single expression like "Hi " + name + "!", javac already generates efficient concatenation code. Java 8 and earlier emitted a StringBuilder behind the scenes. Java 9 and later use an invokedynamic call into StringConcatFactory, which the JVM optimises even harder at runtime.
That help stops at the statement boundary. Inside a loop, each pass starts a fresh concatenation and copies the whole accumulated result again. No compiler can hoist that for you, because your variable genuinely changes on every iteration.
The practical rule is short. One expression: use + and keep it readable. A loop: use StringBuilder.
Java gives you three text types. They exist because immutability is a trade-off, not a free win.
| Feature | String | StringBuilder | StringBuffer |
|---|---|---|---|
| Mutable | No | Yes | Yes |
| Thread-safe | Yes, inherently | No | Yes, via synchronized methods |
| Speed | Slow for repeated edits | Fastest | Slower than StringBuilder |
| Uses the String Pool | Yes, for literals | No | No |
| Safe as a HashMap key | Yes | No | No |
| equals() compares text | Yes | No, identity only | No, identity only |
| Available since | Java 1.0 | Java 5 | Java 1.0 |
| Typical use | Values you store, pass and compare | Building text in a loop | Legacy shared-buffer code |
One row deserves a flag. Neither builder overrides equals(), so sb1.equals(sb2) answers “same object?” and not “same text?”. Call toString() first when you want a text comparison.
StringBuilder per thread usually beats it.Default to StringBuilder over StringBuffer. Paying for locks you do not need is a bad trade.
These five account for most String bugs beginners write.
String email = " User@Example.COM "; email.trim().toLowerCase(); // wrong: nothing captured email = email.trim().toLowerCase(); // right System.out.println(email); // Output: user@example.com
Say it out loud whenever you call a String method: this returns a new String. Assign it or lose it.
The == operator asks whether two references point at one object. Pooled literals often make it accidentally return true, which teaches beginners the wrong lesson.
Then user input arrives from a scanner or an HTTP request. That text never touches the pool, == returns false, and a login check quietly breaks.
String typed = new String("admin");
System.out.println(typed == "admin"); // Output: false
System.out.println(typed.equals("admin")); // Output: true
System.out.println("admin".equals(typed)); // Output: true, and null-safeUse equals() for text, always. Our companion guide on String comparison in Java digs into equalsIgnoreCase, compareTo and null handling.
Writing new String("Java") costs you an extra object and buys you nothing. It also breaks the pooling that would otherwise save memory.
Some tutorials show it to demonstrate ==. Teaching material is the only place it belongs.
Here is one place where immutability works against you. You cannot wipe a String, because wiping means changing it.
A password therefore lingers in memory until the garbage collector gets around to it, and a heap dump taken in between exposes it in plain text.
char[] password = readPassword();
try {
authenticate(password);
} finally {
java.util.Arrays.fill(password, ' '); // overwrite it right now
}That is why JPasswordField.getPassword() returns a char[] and not a String. Follow the same habit for tokens and secret keys.
Interning looks like a free memory win, so people sprinkle it everywhere. It is not free.
Reach for intern() only after a profiler shows you duplicate text eating real memory. Even then, a plain HashMap cache you control is often the better tool.
Time to put every idea into one small program you can paste and run.
package com.java.handson.strings;
public class StringImmutabilityDemo {
public static void main(String[] args) {
// 1. Literals share one pooled object
String s1 = "Java";
String s2 = "Java";
System.out.println("1. s1 == s2 : " + (s1 == s2));
// 2. new always builds a separate object
String s3 = new String("Java");
System.out.println("2. s1 == s3 : " + (s1 == s3));
System.out.println(" s1.equals(s3) : " + s1.equals(s3));
// 3. intern() walks it back to the pool
System.out.println("3. s1 == s3.intern() : " + (s1 == s3.intern()));
// 4. A method cannot change the caller's String
String original = "Java";
shout(original);
System.out.println("4. after shout() : " + original);
// 5. Concatenation returns a new object
String joined = s1 + " HandsOn";
System.out.println("5. joined : " + joined);
System.out.println(" s1 unchanged : " + s1);
// 6. StringBuilder mutates in place
StringBuilder sb = new StringBuilder("Java");
sb.append(" HandsOn");
System.out.println("6. builder : " + sb);
// 7. The cost of + inside a loop
System.out.println("7. concat ms : " + timeConcat(20000));
System.out.println(" builder ms : " + timeBuilder(20000));
}
private static void shout(String text) {
text.toUpperCase(); // result discarded on purpose
text = text + "!!!"; // rebinds the local copy only
}
private static long timeConcat(int n) {
long start = System.currentTimeMillis();
String out = "";
for (int i = 0; i < n; i++) {
out = out + i;
}
return System.currentTimeMillis() - start;
}
private static long timeBuilder(int n) {
long start = System.currentTimeMillis();
StringBuilder out = new StringBuilder();
for (int i = 0; i < n; i++) {
out.append(i);
}
return System.currentTimeMillis() - start;
}
}
1. s1 == s2 : true 2. s1 == s3 : false s1.equals(s3) : true 3. s1 == s3.intern() : true 4. after shout() : Java 5. joined : Java HandsOn s1 unchanged : Java 6. builder : Java HandsOn 7. concat ms : 412 builder ms : 2
Walk through it line by line and every rule from this article shows up:
new creating a separate object with equal textintern() on line 3 hands back the pooled referenceshout() method could not touch the caller’s valueYour millisecond figures will vary by machine, and the gap widens fast as n grows. Try 50,000 and watch the first number climb while the second barely moves.
A: It means the characters inside a String object stay fixed for the object’s whole life. Methods such as toUpperCase or trim never edit the original. Each one returns a brand new String and leaves the old one untouched.
A: Four reasons drove the decision. Immutability lets many references share one pooled object safely, it allows String to cache its hash code, it gives thread safety without locks, and it stops attackers from rewriting a path or class name after a security check approves it.
A: It is a JVM-managed cache of String objects keyed by their text. When your code uses a literal, the JVM returns the pooled object if the text already exists there, and otherwise adds it. That way ten thousand copies of the same literal cost you one object.
A: A literal goes through the pool, so identical literals share one object. The new keyword skips that lookup and allocates a fresh heap object every time. Both hold the same text, so equals returns true, but == returns false.
A: It returns the canonical pooled object for that text. If the pool already holds the text, you get that reference back. Otherwise the JVM registers your string in the pool. Remember to assign the result, because intern never modifies the string you called it on.
A: It lives in the regular heap. Java 6 and earlier kept it in PermGen, which had a small fixed size. Java 7 moved it to the heap, and Java 8 removed PermGen altogether in favour of Metaspace. On the heap the garbage collector can reclaim unused pool entries.
A: Each pass copies the whole accumulated text into a new object and then discards it. Total work therefore grows with the square of the iteration count. A StringBuilder appends into one resizable buffer, which keeps the cost linear.
A: Older JVMs let reflection reach the private backing array, which corrupted every reference sharing that object. Java 9 introduced the module system, and Java 16 turned on strong encapsulation by default, so the JDK now blocks that access. Treat immutability as a guarantee you can rely on.
A: No. You cannot erase a String, so the password sits in memory until the garbage collector reclaims it, and a heap dump can expose it. Use a char array and overwrite it with Arrays.fill as soon as you finish. That is why Swing’s JPasswordField returns a char array.
A: You can reassign it freely. Immutability protects the object, not the reference. Assigning str = str + “x” simply points str at a new object while the old one keeps its original text. Adding final to the variable is what stops reassignment.
Let us wrap up what we covered. String immutability in Java is one rule with a long shadow: once a String exists, its characters never change.
Everything else follows from that. The String Constant Pool can share objects because nobody can rewrite them. The hash code caches cleanly. Threads read without locks. Security checks stay honest.
The trade-off is real, though. Repeated edits allocate a new object every time, which is why StringBuilder exists and why the plus operator belongs outside your loops.
Carry these five habits into your own code:
equals() and leave == for identity checksStringBuilder the moment you build text inside a loopnew String() and let literals flow through the poolchar[] so you can wipe them the instant you finishGet comfortable with these and Strings stop being mysterious. They become one of the most predictable types you will ever work with.
This article belongs to our String series on javahandson.com. Read them in order for the full picture:
You may also enjoy final Keyword in Java, which explains the modifier that keeps the String class locked down, and HashMap in Java, where that cached hash code earns its keep.