String immutability in Java

  • Last Updated: December 8, 2024
  • By: javahandson
  • Series
img

String immutability in Java

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.

1. Introduction

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.

1.1 What This Article Covers

  • What immutability really means, and what it does not mean
  • The String Constant Pool, and why it only works because Strings never change
  • Three ways to build a String: literals, new, and intern()
  • Four solid reasons the Java designers chose this rule
  • The real cost of concatenating text inside a loop
  • How String, StringBuilder and StringBuffer compare
  • Five mistakes that trip up almost every beginner
  • A runnable program that proves each point, plus common interview questions

2. What Immutable Actually Means

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.

2.1 The Sticky Note Analogy

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.

2.2 The Variable Moves, the Object Stays

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.

2.3 How the JDK Enforces It

Immutability here is not a polite convention. The JDK locks it down with four deliberate design choices:

  • The String class carries the final modifier, so nobody can subclass it and sneak in a setter
  • Its internal array field also carries final and private, so no outside code can swap it out
  • No method on String writes to that array; every text method returns a fresh object
  • Constructors copy the incoming data instead of storing the caller’s array directly

One 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.

3. The String Constant Pool

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.

3.1 What the Pool Is

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:

  • Found a match? The JVM hands back the reference it already holds
  • No match? The JVM stores the new object in the pool, then returns that reference
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.

3.2 Why the Pool Needs Immutability

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.

3.3 Where the Pool Lives

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.

4. Three Ways to Create a String

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.

  • A string literal, written between double quotes
  • The new keyword, like any other object
  • The intern() method, which pushes text into the pool by hand

4.1 String Literal

A 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 : 22307196

System.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.

4.2 Using the new Keyword

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.

4.3 Using intern()

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: true

4.4 Constant Folding: The Compiler Trick

Here 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.

5. Why Java Made Strings Immutable

This was a deliberate call, not an accident of history. Four benefits justify it.

5.1 Safe Sharing in the Pool

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.

5.2 A Cached hashCode

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: 91

5.3 Thread Safety for Free

Race 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.

5.4 Security

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.

6. What Happens When You Change a String

Understanding the mechanics here separates working code from fast code.

6.1 Every Edit Builds a New Object

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.

6.2 Concatenation Inside a Loop

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.

6.3 What the Compiler Does For You

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.

7. String vs StringBuilder vs StringBuffer

Java gives you three text types. They exist because immutability is a trade-off, not a free win.

7.1 The Comparison Table

FeatureStringStringBuilderStringBuffer
MutableNoYesYes
Thread-safeYes, inherentlyNoYes, via synchronized methods
SpeedSlow for repeated editsFastestSlower than StringBuilder
Uses the String PoolYes, for literalsNoNo
Safe as a HashMap keyYesNoNo
equals() compares textYesNo, identity onlyNo, identity only
Available sinceJava 1.0Java 5Java 1.0
Typical useValues you store, pass and compareBuilding text in a loopLegacy 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.

7.2 So Which One Do You Pick?

  • String for anything you store, return, compare or use as a key. This covers the vast majority of your code.
  • StringBuilder whenever you assemble text piece by piece, especially in a loop. Local variables never escape to another thread, so the missing synchronisation costs you nothing.
  • StringBuffer only when several threads genuinely share one buffer. That situation is rare, and a local StringBuilder per thread usually beats it.

Default to StringBuilder over StringBuffer. Paying for locks you do not need is a bad trade.

8. Common Mistakes and Pitfalls

These five account for most String bugs beginners write.

8.1 Throwing Away the Return Value

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.

8.2 Comparing With == Instead of equals

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-safe

Use equals() for text, always. Our companion guide on String comparison in Java digs into equalsIgnoreCase, compareTo and null handling.

8.3 Treating new String() as Harmless

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.

8.4 Keeping Passwords in a String

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.

8.5 Reaching for intern() Too Often

Interning looks like a free memory win, so people sprinkle it everywhere. It is not free.

  • Each call performs a hash lookup in a native table, which costs more than you expect
  • Interning high-cardinality values such as user IDs bloats the pool instead of shrinking it
  • Long-lived pool entries survive minor collections and add pressure to the old generation

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.

9. Hands-On Walkthrough

Time to put every idea into one small program you can paste and run.

9.1 The Program

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;
    }
}



9.2 Reading the Output

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:

  • Line 1 proves that two identical literals share one pooled object
  • Line 2 shows new creating a separate object with equal text
  • Calling intern() on line 3 hands back the pooled reference
  • Result 4 is the big one. The shout() method could not touch the caller’s value
  • Numbers 5 and 6 contrast a new object against an in-place append
  • Timing 7 turns the quadratic cost into something you can actually feel

Your 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.

10. Interview Questions

Q: What does string immutability in Java actually mean?

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.

Q: Why did the Java designers make String immutable?

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.

Q: What is the String Constant Pool?

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.

Q: What is the difference between a String literal and new String()?

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.

Q: What does the intern() method do?

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.

Q: Where does the String pool live in modern Java?

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.

Q: Why is concatenating strings in a loop slow?

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.

Q: Can reflection break String immutability?

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.

Q: Should I store a password in a String?

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.

Q: Does immutability mean I cannot reassign a String variable?

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.

11. Conclusion

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:

  • Capture what a String method returns, because it never edits in place
  • Compare text with equals() and leave == for identity checks
  • Switch to StringBuilder the moment you build text inside a loop
  • Skip new String() and let literals flow through the pool
  • Hold passwords in a char[] so you can wipe them the instant you finish

Get comfortable with these and Strings stop being mysterious. They become one of the most predictable types you will ever work with.

12. Further Reading

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.

Leave a Comment