Java / Core internals

Why Strings Are Immutable in Java

Inside java.lang.String: the final byte[], compact strings and the cached hash — and the five reasons immutability was chosen: the string pool, hash-based collections, security, thread safety and class loading.

On this page
  1. What immutability means
  2. Inside java.lang.String
  3. How immutability is enforced
  4. Reason 1: the string pool
  5. Reason 2: stable hash keys
  6. Reason 3: security
  7. Reason 4: thread safety
  8. Reason 5: caching and performance
  9. The costs of immutability
  10. Can you break it?
  11. Interview questions

Every Java developer learns early that String is immutable, yet few can explain why the language designers paid for that decision with extra allocations, a separate StringBuilder class and a whole category of interview questions. The answer is not one reason but five that reinforce each other: the string pool, stable hash codes, security checks, lock-free sharing between threads and aggressive caching inside the JVM. This guide opens up java.lang.String, shows exactly how immutability is enforced, demonstrates what would break without it, and is honest about what it costs.

After reading this article you will be able to

  • Explain the difference between an immutable object and a final reference, and predict what concat, replace and toUpperCase do to the original string.
  • Describe the internal layout of String since JDK 9 (compact strings, coder, cached hash) and how it differed before.
  • Write your own immutable class using the same four techniques the JDK uses.
  • Argue the five design reasons for immutability — pooling, hash keys, security, thread safety and caching — with concrete failure scenarios.
  • Choose correctly between String, StringBuilder and StringBuffer, and know why passwords belong in a char[].
  • Explain why reflection hacks against String.value fail on modern JDKs and why they are dangerous anyway.

01What immutability means

Immutable object Definition

An object whose observable state cannot change after its constructor finishes. Every operation that looks like a modification instead returns a new object and leaves the original exactly as it was.

For String, the observable state is its sequence of characters. Once "hello" exists on the heap, those five characters are fixed for the lifetime of that object. Methods such as concat, replace, toUpperCase, trim, strip and substring all have the same contract: compute the answer, put it into a new String (or return this when nothing would change), and never touch the receiver.

Modification returns a new object
String s = "hello";
String t = s;                 // t and s point at the same object

s = s.concat(" world");       // concat builds a NEW String
System.out.println(t);        // hello        (original untouched)
System.out.println(s);        // hello world  (s now points elsewhere)

s.toUpperCase();              // result is computed... and thrown away
System.out.println(s);        // hello world  (still lower case)

s = s.toUpperCase();          // keep the result by reassigning
System.out.println(s);        // HELLO WORLD

The line that confuses beginners is s = s.concat(" world"). It looks like s was modified, but what really happened is two separate steps: concat created a brand-new object, and the assignment moved the reference held in the local variable s to point at it. The old object is still there, unchanged, and anything else that referenced it — like t — still sees "hello".

Fig. 1

Modifying a String creates a new object

Modifying a String creates a new objectVariables s and t on the stack both start pointing at the heap String "hello". After s = s.concat(" world"), s points at a brand-new String "hello world" while t still points at the original "hello", which was never changed.Stack · localsHeapbeforeaftertString reference"hello"original object · never modifiedsString reference"hello world"new object from concat()The codeString s = "hello";String t = s;s = s.concat(" world");What printst → hellos → hello worldonly s moved
The reference moves; the object does not change. After s = s.concat(" world") only the arrow from s is redirected. The original "hello" is untouched, which is why t still prints it. If nothing referenced the old object it would simply become eligible for garbage collection.

A final variable is not an immutable object

The keyword final on a variable and the property of immutability on an object are independent ideas, and interviewers love to probe the difference.

final reference, mutable object

final StringBuilder sb = new StringBuilder("hi"); — you cannot reassign sb, but sb.append("!") happily changes the object it points to. final froze the arrow, not the box.

non-final reference, immutable object

String s = "hi"; — you can reassign s as often as you like, but no code anywhere can change the characters of the object "hi". The box is frozen; the arrow is free.

final vs immutable
final StringBuilder sb = new StringBuilder("hi");
sb.append("!");               // OK: the object changes
// sb = new StringBuilder();  // compile error: sb is final

String s = "hi";
s = s + "!";                  // OK: s now refers to a new String

final String f = "hi";        // both: neither the reference nor the
                              // object can ever change

A final String field therefore gives you the strongest guarantee: the field always refers to the same object, and that object always contains the same characters.

02Inside java.lang.String

Immutability is not a convention or a promise in the Javadoc — it is a direct consequence of how the class is written. Here are the relevant declarations from the JDK 17 source, trimmed to the fields that matter:

java.lang.String (JDK 17, simplified)
public final class String
        implements java.io.Serializable, Comparable<String>, CharSequence,
                   Constable, ConstantDesc {

    @Stable
    private final byte[] value;   // the characters, encoded per coder

    private final byte coder;     // LATIN1 (0) or UTF16 (1)

    private int hash;             // cached hashCode(), 0 = not computed

    private boolean hashIsZero;   // JDK 13+: hash was computed and is 0

    static final byte LATIN1 = 0;
    static final byte UTF16  = 1;
    // ...
}
Fig. 2

Anatomy of a String object (JDK 9+)

Anatomy of a String object (JDK 9+)A String object holds a header, a final reference to a byte array, a final coder byte, a cached hash int and a hashIsZero flag. With coder LATIN1 each character takes one byte; with coder UTF16 each character takes two bytes.java.lang.StringLATIN1 · coder = 0 · 1 byte per charUTF16 · coder = 1 · 2 bytes per charorobject headermark word + class pointerfinal byte[] valuereference to the charactersfinal byte coderLATIN1 = 0 · UTF16 = 1int hashcached hashCode(), 0 = not yetboolean hashIsZeroJDK 13+: hash really is 04A617661bytebytebytebytebytebyteJavabyte[4] for "Java"H 0x0048i 0x0069€ 0x20ACbyte[6] for "Hi€" (platform byte order)Before JDK 9: private final char[] value — always 2 bytes per char
One object, two encodings. The value reference and coder are final; only the two hash-cache fields are written after construction. Strings whose characters all fit in ISO-8859-1 use one byte per char; anything else (here the euro sign) switches the whole string to two bytes per char.

final class: nobody can subclass it

Because String is declared final, you cannot write class EvilString extends String and add a setter or override charAt to return different values over time. If subclassing were allowed, every method that accepts a String parameter would have to assume it might receive a mutable impostor, and every guarantee in the rest of this article would evaporate.

private final byte[] value and compact strings

Since JDK 9, characters are stored in a byte[] plus a coder flag, a change introduced by JEP 254 (Compact Strings). If every character fits in Latin-1, the string is stored with one byte per character (coder = LATIN1); otherwise it is stored as UTF-16 code units, two bytes each (coder = UTF16). Most strings in typical applications — identifiers, JSON keys, log messages in English — are Latin-1, so this roughly halves their character storage. The feature is on by default and can be disabled with -XX:-CompactStrings.

Before JDK 9 the field was private final char[] value, always two bytes per character. Either way, the field is private (no outside code can reach the array) and final (the reference can never be pointed at a different array). The array contents are technically writable — Java has no read-only arrays — so the remaining guarantee comes from the class simply never writing to them after construction.

The hash cache and hashIsZero

The only fields that change after construction are the hash cache. hashCode() is computed lazily, the first time someone asks for it, using the formula the Javadoc specifies: s[0]*31^(n-1) + s[1]*31^(n-2) + … + s[n-1]. The result is stored in hash so that every later call is a single field read.

String.hashCode() (JDK 13+, simplified)
public int hashCode() {
    int h = hash;                       // read the field ONCE
    if (h == 0 && !hashIsZero) {
        h = isLatin1() ? StringLatin1.hashCode(value)
                       : StringUTF16.hashCode(value);
        if (h == 0) {
            hashIsZero = true;          // remember "really zero"
        } else {
            hash = h;                   // cache for next time
        }
    }
    return h;
}

Before JDK 13 there was no hashIsZero flag, so a string whose hash genuinely evaluates to 0 (the empty string is the obvious one) was recomputed on every call, because 0 doubled as the "not yet computed" marker. The flag fixes that edge case.

Notice there is no lock. Two threads may both see hash == 0 and both compute it. That is a deliberate benign data race: the computation is deterministic, so both threads produce the same int, and writes to an int field are atomic in Java. The worst outcome is duplicated work, never a wrong answer. Reading the field into the local h once is essential — it prevents a second, possibly stale read from being returned.

Defensive copies in and out

Any constructor that receives an array copies it, and any method that hands out characters hands out a copy:

The array never escapes
char[] chars = {'c', 'a', 't'};
String s = new String(chars);   // constructor copies the array
chars[0] = 'b';                 // mutate the caller's array...
System.out.println(s);          // cat  (String has its own copy)

char[] out = s.toCharArray();   // returns a fresh copy
out[0] = 'r';
System.out.println(s);          // cat  (still unchanged)

byte[] b = s.getBytes(StandardCharsets.UTF_8); // also a new array

One interesting exception proves the rule: since JDK 9, new String(String original) does not copy. It creates a new String object that shares the original's value array. That is only safe because neither object will ever write to it.

03How immutability is enforced

String is a textbook application of a general recipe that works for any class you write. Joshua Bloch's Effective Java describes essentially the same rules; the short version is:

RuleWhat it preventsHow String applies it
Declare the class final (or make constructors private and use factories)A subclass adding mutable state or overriding accessorspublic final class String
Make all fields private finalReassignment, outside access; also gives JMM visibility guaranteesprivate final byte[] value, private final byte coder
Provide no mutatorsChanging state after constructionEvery "modifying" method returns a new String
Defensive copy on the way inThe caller keeping a handle to your internal array or collectionnew String(char[]) copies
Defensive copy on the way outThe caller receiving a handle to your internal statetoCharArray(), getBytes() return copies
Don't let this escape during constructionOther threads seeing a half-built objectConstructors only assign fields

A broken "immutable" class

Here is a class that looks immutable — it has no setters — but leaks its internal array in both directions:

Leaky Token
public class Token {                     // not final: can be subclassed
    private final char[] chars;

    public Token(char[] chars) {
        this.chars = chars;              // stores the caller's array
    }

    public char[] chars() {
        return chars;                    // hands out the internal array
    }
}

char[] src = {'a', 'b', 'c'};
Token t = new Token(src);
src[0] = 'X';                            // t now contains "Xbc"
t.chars()[1] = 'Y';                      // t now contains "XYc"

final on the field only stops reassignment; it does nothing about the contents of the array the field points to. The fixed version copies at every boundary, and every "change" returns a new instance:

Immutable Token
public final class Token {
    private final char[] chars;
    private int hash;                         // lazily cached, like String

    public Token(char[] chars) {
        this.chars = chars.clone();           // copy in
    }

    public char[] chars() {
        return chars.clone();                 // copy out
    }

    public Token append(char c) {             // "modification" = new object
        char[] next = Arrays.copyOf(chars, chars.length + 1);
        next[chars.length] = c;
        return new Token(next);
    }

    @Override public boolean equals(Object o) {
        return o instanceof Token other
            && Arrays.equals(chars, other.chars);
    }

    @Override public int hashCode() {
        int h = hash;
        if (h == 0) {
            h = Arrays.hashCode(chars);
            hash = h;                         // benign race, like String
        }
        return h;
    }
}
Records help, but only shallowly

A Java record (JDK 16+) is final with private final fields and no setters, so a record of strings and numbers is immutable for free. But a record component of type List or char[] still exposes a mutable object; you need a compact constructor with List.copyOf(...) or clone() to finish the job.

04Reason 1: the string pool

String pool (intern pool) Definition

A JVM-wide table of canonical String instances. All string literals and compile-time constant strings are placed in it, so every occurrence of the same literal across all classes resolves to one shared object.

Programs repeat the same strings constantly: field names, HTTP header names, enum-like codes, log prefixes. The pool means "Content-Type" exists once no matter how many classes mention it. Since JDK 7 the pool lives in the regular Java heap; before that, interned strings were kept in the PermGen space, which was small, fixed-size and a well-known source of OutOfMemoryError: PermGen space when applications interned aggressively. (PermGen was removed altogether in JDK 8.)

Fig. 3

Literals share one pooled object; new String() does not

Literals share one pooled object; new String() does nots1 and s2 are both assigned the literal "java" and point at the same object in the string pool inside the heap. s3 = new String("java") points at a separate heap object; s3.intern() returns the pooled instance.StackHeapString pools3.intern()s1 = "java"s2 = "java""java"one shared instances3 = newString("java")"java"separate heap objects1 == s2 → trues1 == s3 → falses1.equals(s3) → trues1 == s3.intern() → truepool lives here since JDK 7Comparisons
Literals are shared; new is not. s1 and s2 resolve to the same pooled object, so == is true. new String("java") always creates a distinct object, so == is false even though equals is true. intern() returns the canonical pooled instance.

== vs equals, literals vs new

Pool behaviour
String s1 = "java";
String s2 = "java";
String s3 = new String("java");

System.out.println(s1 == s2);            // true:  same pooled object
System.out.println(s1 == s3);            // false: s3 is a separate object
System.out.println(s1.equals(s3));       // true:  same characters
System.out.println(s1 == s3.intern());   // true:  intern() -> pooled one

== compares references; equals compares characters. The pool makes == happen to work for literals, which is exactly why it is a trap: the moment one of the strings is computed at runtime, the comparison silently becomes false. Always compare string content with equals (or Objects.equals when either side may be null).

Compile-time constants vs runtime concatenation

The compiler folds constant expressions into a single literal, which then gets pooled. Concatenation that depends on a non-constant value happens at runtime and produces a fresh, non-pooled object.

Constant folding
String ab = "ab";

String c1 = "a" + "b";            // folded by javac into "ab"
System.out.println(c1 == ab);     // true

final String a = "a";             // constant variable
String c2 = a + "b";              // still a constant expression
System.out.println(c2 == ab);     // true

String x = "a";                   // NOT final: not a constant
String c3 = x + "b";              // computed at runtime
System.out.println(c3 == ab);     // false
System.out.println(c3.intern() == ab); // true

Why the pool requires immutability

Sharing one object between unrelated pieces of code is only safe if none of them can change it. Picture the opposite: a hypothetical String with a mutator. Two modules that have never heard of each other both write "guest" in their source. The JVM hands both the same pooled instance. One module "edits its string" and the other module's value changes underneath it.

Fig. 4

If pooled strings were mutable

If pooled strings were mutableHypothetical: three modules share the pooled literal "guest". Module A mutates it to "admin" in place, and modules B and C, which never touched it, now see "admin" too.writesModule Arole = "guest"1Module Arole.mutate("admin")2"guest" → "admin"the ONE pooled instancechanged in placeModule Bwrote "guest"now reads "admin"Module Cwrote "guest"now reads "admin"Hypothetical — real Java Strings cannot do thisevery holder of the literal is affected
Shared + mutable = spooky action at a distance. In this imaginary JVM, module A's in-place edit rewrites the single pooled instance, so modules B and C — which never touched it — suddenly read "admin". Immutability is what makes sharing literals safe.
Imaginary mutable String
// Pseudo-Java: this API does not exist.
String roleA = "guest";        // module A
String roleB = "guest";        // module B: SAME pooled object

roleA.setCharsInPlace("admin");

if (roleB.equals("admin")) {   // module B is now an admin...
    grantEverything();         // ...and so is every literal "guest"
}

Without immutability the JVM would have to give every literal occurrence its own copy, losing the memory savings and the cheap reference equality the pool provides. The same reasoning lets the JDK return this from concat("") or from replace when nothing matches — handing the same object back is harmless when nobody can mutate it.

05Reason 2: stable hash keys

String is by far the most common key type in HashMap, HashSet and ConcurrentHashMap. Hash-based collections rely on one assumption that is easy to forget: a key's hashCode() must not change while it is in the collection. The map uses the hash to pick a bucket when you put, and uses it again to pick the bucket when you get. If the hash changes in between, the second lookup goes to the wrong bucket.

Immutable keys satisfy that assumption automatically, and immutability is also what makes caching the hash inside the String object legal: a value derived from state that never changes can never go stale.

What happens with a mutable key

Mutable key lost in a HashMap
final class MutableKey {
    private int id;
    MutableKey(int id) { this.id = id; }
    void setId(int id) { this.id = id; }

    @Override public boolean equals(Object o) {
        return o instanceof MutableKey k && k.id == id;
    }
    @Override public int hashCode() { return Integer.hashCode(id); }
}

Map<MutableKey, String> map = new HashMap<>();
MutableKey k = new MutableKey(42);
map.put(k, "answer");

k.setId(99);                                  // mutate AFTER insertion

System.out.println(map.get(k));               // null
System.out.println(map.containsKey(k));       // false
System.out.println(map.size());               // 1  (entry still there)
System.out.println(map.get(new MutableKey(42))); // null as well
Fig. 5

A mutable key gets stranded in the wrong bucket

A mutable key gets stranded in the wrong bucketA mutable key is put into a HashMap and lands in bucket 3. Its id is then changed, so its hash now maps to bucket 7. get(key) searches bucket 7, finds nothing and returns null, while the entry stays stranded in bucket 3.stored herelooks hereemptymap.put(k, v)hash(id = 42) → bucket 31k.setId(99)key state changed2map.get(k)hash(id = 99) → bucket 7301234567Entry k → vstranded · size() still 1nulllookup missesString keys never change → hashCode() is stable → every lookup lands in the bucket the entry was stored intable[]
The entry is still in the map — you just can't find it. The key was stored under its old hash; after mutation, lookups compute a new hash and search a different bucket. Bucket numbers are illustrative. Even a fresh key with id 42 fails, because it reaches the right bucket but equals now compares 42 with 99.

The entry becomes unreachable through normal lookups yet still occupies memory and counts toward size(). Iterating the map would show it; remove(k) would not find it. In long-running services this pattern quietly leaks memory and produces "impossible" cache misses.

Mutable key

Hash depends on mutable state. Any code holding a reference to the key can move it to a different bucket at any time. You must remember never to mutate keys — a rule the compiler cannot enforce.

String key

Hash depends only on characters that can never change. It is computed once, cached in the object, and is guaranteed to send every get to the bucket the put used.

StringBuilder makes a poor key for a different reason

StringBuilder and StringBuffer do not override equals or hashCode; they use object identity. A map keyed by a StringBuilder will never find an entry using a different builder with the same text. Convert with toString() before using text as a key.

06Reason 3: security

Strings carry the most security-sensitive values in a Java program: file paths, URLs and host names, database connection strings, user names, class names passed to Class.forName, and member names used with reflection. Typical code validates such a value and then uses it:

Check, then use
static String readUpload(String path) throws IOException {
    Path p = Path.of(path).normalize();
    if (!p.startsWith("/srv/uploads/")) {        // time of check
        throw new SecurityException("outside upload dir: " + path);
    }
    return Files.readString(p);                   // time of use
}

TOCTOU (time-of-check to time-of-use) Definition

A class of bug where a value is validated at one moment and used at a later moment, and something changes it in between, so the use operates on data that was never actually checked.

If String were mutable, the caller — or another thread holding the same reference — could change the characters after the startsWith check passes but before the file is opened. The check would have been performed on one path and the read performed on another. Defending against that would require every security-sensitive API to copy its string arguments before validating them, and a single forgotten copy anywhere in the JDK or a library would be an exploitable hole.

Fig. 6

Time-of-check to time-of-use

Time-of-check to time-of-useTop: if String were mutable, a path could pass validation, be changed by another thread, and then the changed path would be opened. Bottom: with an immutable String the object that was checked is exactly the object that is used.If String were mutableReal Java: String is immutableno window: nothing can changecheck(path)/srv/uploads/a.txtallowed ✓1Mutation windowanother thread rewritespath → /etc/passwd2open(path)/etc/passwdopened ✗3check(path)validate the String object1open(path)uses the very same bytes2
Immutability closes the mutation window. With a mutable string, the value can change between validation and use. With String, the object that passed the check is guaranteed to be byte-for-byte the object that gets used.

The same argument applies to the JDK itself. Historically the Security Manager checked permissions on file names, host names and class names passed as strings; the class loader checks a class name before defining the class. These checks are only meaningful if the name cannot change after inspection. (The Security Manager was deprecated for removal in JDK 17 by JEP 411 and permanently disabled in JDK 24 by JEP 486, but the check-then-use pattern in your own code and in libraries is as relevant as ever.)

Immutability is necessary, not sufficient

An immutable string guarantees that what you checked is what you use. It does not make the check correct. In the example above, removing normalize() would let /srv/uploads/../../etc/passwd pass a naive startsWith test. And file-system TOCTOU (the file itself being swapped between check and open) is a separate problem that string immutability cannot solve.

07Reason 4: thread safety

Most concurrency bugs need two ingredients: shared state and at least one writer. Immutable objects remove the writer. Any number of threads can read the same String simultaneously with no synchronized, no volatile and no locks, because there is nothing to race on. (For the broader background on visibility and data races, see Java Concurrency & Multithreading.)

Fig. 7

Sharing one immutable String across threads

Sharing one immutable String across threadsFour threads read the same String object concurrently with no lock. Final-field semantics guarantee each thread sees the fully constructed value, and because nothing can write to it, there is nothing to race on.ThreadsThread 1reads onlyThread 2reads onlyThread 3reads onlyThread 4reads only"jdbc:pg://db/app"one shared Stringno lock, no volatileWhy it is safefinal value + coderfrozen at end of <init>(JLS §17.5)No writersno method changes value[]so no data race on ithash cacheracy but benign: worst casetwo threads compute it
Many readers, zero locks. All four threads read one String concurrently. The final-field rules of the Java Memory Model guarantee each sees the fully constructed characters, and since no method writes to them, no synchronization is needed. The only racy field, the hash cache, is benign.

Final-field semantics (JLS §17.5)

"No writers" is only half the story. A reader on another core must also be guaranteed to see the characters that were written during construction, not a zero-filled array left over from allocation. The Java Language Specification provides this through final-field semantics: if an object is properly constructed — meaning this does not escape from the constructor — then any thread that obtains a reference to it is guaranteed to see the correctly initialized values of its final fields, and to see the objects and arrays those fields reference at least as up to date as the final fields themselves.

For String that means: because value and coder are final, a thread that receives a String through a data race (for example, via a non-volatile field) still sees the right characters. This is a stronger guarantee than ordinary objects get: an object with non-final fields published through a race can legally be observed half-initialized.

Safe even with racy publication
class Config {
    String url;                       // deliberately NOT volatile

    void reload() {                   // writer thread
        url = "jdbc:postgresql://db/app";
    }

    void connect() {                  // reader thread
        String u = url;               // may see null or the new String,
        if (u != null) {              // but never a String with
            open(u);                  // missing or garbage characters
        }
    }
}

Note what is not guaranteed: the reader may still see null or an older value of url, because the field url is not volatile. Immutability protects the object, not the variable that points to it. If you need the reader to see the latest assignment promptly, make the field volatile or use an AtomicReference.

Immutable objects compose

Because strings are safe to share, so are immutable objects built from them: a final class whose fields are final strings and primitives is itself thread-safe without any locking. This is why value objects, configuration snapshots and message payloads are so often designed as immutable.

08Reason 5: caching and performance

The final reason is that immutability lets the JVM and libraries cache and share string-derived data aggressively, because nothing can invalidate the cache.

OptimizationWhat is reusedWhy it needs immutability
String poolOne object per distinct literalShared instances must never change
Hash cachehashCode() computed at most once (ignoring benign races)A cached derived value is only valid if its inputs are fixed
Class loading and linkingClass, method and field names resolved once from the constant poolNames used as lookup keys in loader and symbol tables must stay fixed
new String(String) (JDK 9+)The backing array is shared, not copiedTwo objects can share one array only if neither writes to it
String deduplication (GC)Identical backing arrays merged into oneThe GC can only swap arrays behind the program's back if they are never written
JIT constant foldingContents of constant strings treated as compile-time constantsFolding is only valid if the contents cannot change at runtime

Class loading is a good example of all the reasons converging. When the JVM resolves a symbolic reference such as java/util/HashMap, it looks up the class by name in loader tables and caches the result. User code passes class names to Class.forName(String) and ClassLoader.loadClass(String). If those names could change after the loader checked or cached them, a class could be defined under one name and found under another — a correctness and security problem at the very core of the runtime.

String deduplication in brief

JEP 192 added string deduplication to the G1 collector in JDK 8u20, enabled with -XX:+UseStringDeduplication. During collection, G1 finds String objects whose backing arrays have identical contents and points them at a single shared array, freeing the duplicates. It deduplicates the arrays, not the String objects, so == results never change. JDK 18 extended the feature to the other collectors (Serial, Parallel and ZGC; Shenandoah already had its own support). None of this would be possible if application code could write into a String's array.

09The costs of immutability

Immutability is not free. Every "modification" allocates a new object, and some patterns turn that into a lot of garbage.

Concatenation in a loop

Quadratic concatenation
String csv = "";
for (String item : items) {
    csv += item + ",";      // allocates a NEW String every iteration
}

Each iteration creates a new string containing everything so far, copying all previous characters. For n items that is O(n²) character copying and roughly n intermediate objects that immediately become garbage.

Fig. 8

String += in a loop vs one StringBuilder

String += in a loop vs one StringBuilderTop: s += i in a loop creates a new String every iteration and the previous ones become garbage. Bottom: a StringBuilder appends into one growable buffer and toString() creates a single final String.Concatenation in a loopOne StringBuildercopycopycopytoString()"0"garbage"01"garbage"012"garbage"0123"result0123"0123"one String, created oncefor (int i = 0; i < 4; i++) s += i; // new String each passeach step copies every character so far → O(n²) copying, n − 1 dead objectsfor (int i = 0; i < 4; i++) sb.append(i); // one bufferinternal array, re-allocated only when full
Copy everything, every time — or append into one buffer. With += in a loop each intermediate string is a separate allocation that dies on the next iteration. StringBuilder appends into a single growable buffer and creates one String at the end.
One builder, one result
StringBuilder sb = new StringBuilder(items.size() * 16);  // optional hint
for (String item : items) {
    sb.append(item).append(',');
}
String csv = sb.toString();

// Often clearer still:
String joined = String.join(",", items);
String viaStream = items.stream().collect(Collectors.joining(","));

What javac does with +

A single expression like a + b + c is already efficient. Before JDK 9, javac compiled it into new StringBuilder().append(a).append(b).append(c).toString(). Since JDK 9, JEP 280 compiles it into an invokedynamic call bootstrapped by java.lang.invoke.StringConcatFactory, which lets the runtime choose an optimized strategy (for example, sizing the result exactly up front) without recompiling your code.

Neither strategy helps a loop. The compiler optimizes each += statement separately, so every iteration still ends by producing a new String. The fix is structural: keep one mutable builder alive across iterations.

String vs StringBuilder vs StringBuffer

AspectStringStringBuilderStringBuffer
MutabilityImmutableMutableMutable
Thread safetyInherently safe to shareNot synchronized; confine to one threadMethods are synchronized
SinceJDK 1.0JDK 5JDK 1.0
equals / hashCodeContent-basedIdentity (not overridden)Identity (not overridden)
PoolingLiterals pooled; intern()NeverNever
Use as map keyIdealAvoidAvoid
Typical useValues, keys, API parametersBuilding text in a method or loopLegacy APIs; rarely the right choice today

In practice a builder is almost always a local variable used by one thread, so the locking in StringBuffer buys nothing. If several threads must contribute text, it is usually cleaner for each to build its own piece and combine the immutable results.

substring and the old memory leak

Up to JDK 7u5, String had offset and count fields, and substring returned a new String that shared the parent's char[] — fast and allocation-light, and safe precisely because neither string could modify the array. The downside was a memory leak pattern: taking a 10-character substring of a multi-megabyte string kept the whole multi-megabyte array alive for as long as the small string lived. The usual workaround was new String(big.substring(...)) to force a copy.

Since JDK 7u6, substring copies the requested range into a new array, the offset/count fields are gone, and the leak is gone with them. The trade-off is that substring is now O(length of the result) instead of O(1).

Passwords belong in char[]

Immutability has a security downside too: you cannot erase a String. Once a password is in a String, its characters stay in memory until the garbage collector reclaims the object and the memory is eventually overwritten — and you control neither. If the string was interned, or copied into log messages, exception text or other strings along the way, more copies exist. A heap dump taken in that window contains the password in plain text.

That is why the long-standing guidance, reflected in JDK APIs such as Console.readPassword(), JPasswordField.getPassword() and PBEKeySpec, is to hold secrets in a char[] and overwrite it as soon as you are done:

Zero the secret after use
char[] password = System.console().readPassword("Password: ");
try {
    authenticate(user, password);
} finally {
    Arrays.fill(password, '\0');   // wipe it; a String cannot be wiped
}
Defence in depth, not a guarantee

A char[] shortens the window in which the secret is in memory; it does not eliminate it. The GC may have copied the array before you cleared it, and if you call new String(password) anywhere — or a framework does — you are back to an unerasable copy. Treat it as one sensible layer, not a complete protection.

10Can you break it?

Immutability in Java is enforced by access control, not by hardware. Reflection was historically able to bypass access control, so for many years the following worked (with an illegal-access warning on JDK 9–15):

Don't do this
// JDK 16+: throws InaccessibleObjectException unless the JVM is
// started with --add-opens java.base/java.lang=ALL-UNNAMED
Field f = String.class.getDeclaredField("value");
f.setAccessible(true);

byte[] v = (byte[]) f.get("hello");   // the POOLED literal's array
v[0] = (byte) 'j';                    // JDK 9+ Latin-1: bytes, not chars

System.out.println("hello");          // may print "jello" -- anywhere

Since JDK 9, the module system strongly encapsulates the JDK's internals. JDK 9 through 15 still permitted this kind of deep reflection into java.lang by default, printing a warning. JEP 396 made strong encapsulation the default in JDK 16, and JEP 403 removed the --illegal-access escape hatch in JDK 17. On current JDKs setAccessible(true) on String.value throws InaccessibleObjectException unless you explicitly open the package with --add-opens java.base/java.lang=ALL-UNNAMED.

Even if you do open it, the result is not "a mutable string" but a corrupted JVM:

  • Every user of the literal changes. The array belongs to the pooled instance, so every class in the JVM that wrote "hello" now sees "jello" — exactly the scenario from the pool section.
  • Cached hashes go stale. If the hash had already been computed, the string now sits in the wrong bucket of every HashMap it is a key of.
  • The JIT may not notice. The value field is annotated @Stable and constant strings can be folded into compiled code, so some code paths may see the old contents and others the new. Behaviour becomes effectively unpredictable.
  • Coder mismatch. Writing a non-Latin-1 value into a LATIN1 array cannot be represented at all.
If you think you need this

You almost certainly need a StringBuilder, a char[], or a CharBuffer. Libraries that relied on poking into JDK internals are a major reason JEP 396 and 403 exist; avoid adding --add-opens flags to work around immutability.

Version cheat sheet

JDKChange relevant to String immutability
1.0String is final and immutable; StringBuffer for mutable text
5StringBuilder added (unsynchronized); javac uses it for +
7Interned strings move from PermGen to the main heap
7u6substring copies instead of sharing the parent array; offset/count removed
8u20G1 string deduplication (JEP 192)
9Compact strings: byte[] value + coder (JEP 254); indy concatenation (JEP 280); module system
13hashIsZero flag avoids recomputing zero hashes
16 / 17Strong encapsulation by default (JEP 396) / no --illegal-access (JEP 403)
18String deduplication available in Serial, Parallel and ZGC

11Interview questions

Why is String immutable in Java?

Because several core features depend on strings never changing: the string pool shares one instance per literal; HashMap/HashSet rely on stable hash codes, which String also caches; security checks on paths, hosts and class names are only valid if the checked value can't change before use; immutable objects can be shared across threads without locking; and the JVM can cache and share string data (class names, deduplicated arrays, constant folding) freely. The class enforces it by being final, keeping value private final, exposing no mutators and copying arrays in and out.

Is a final String variable the same thing as an immutable String?

No. final applies to the variable: it cannot be reassigned. Immutability applies to the object: its state cannot change. A final StringBuilder can still be appended to; a non-final String variable can be reassigned, but the String object it pointed to never changes. A final String field has both properties.

How many objects does new String("hello") create?

The expression itself always creates one new String object on the heap. The literal "hello" is a separate, pooled object that is created (if it doesn't already exist) when the class's constant is first resolved — so the classic answer is "one or two, depending on whether the literal is already in the pool". Since JDK 9 the new object shares the literal's backing byte[] rather than copying it, which is safe because neither can write to it.

If String is immutable, how does s += "x" work?

s += "x" means s = s + "x": a new String is built (via StringBuilder before JDK 9, via invokedynamic/StringConcatFactory since JDK 9) and the variable s is reassigned to it. The old object is unchanged. In a loop that creates a new string per iteration, which is why you should use one StringBuilder instead.

Isn't the unsynchronized hash field in String a data race?

Yes, technically, and deliberately. Two threads may both compute the hash and both write it. Because the computation is deterministic and int writes are atomic, both write the same value and any reader gets either 0 (triggering recomputation) or the correct hash. The method reads the field once into a local variable so it never returns a different value than it checked. It's a benign race that trades occasional duplicated work for no locking.

Why should passwords be stored in char[] instead of String?

A char[] can be overwritten with Arrays.fill as soon as the password has been used; a String cannot be cleared and lingers until garbage collection, and copies may exist in the pool, logs or other strings. JDK APIs like Console.readPassword() return char[] for this reason. It reduces exposure (e.g. in heap dumps) but is not an absolute guarantee.

When would you use StringBuffer over StringBuilder?

Rarely: only when one builder instance genuinely must be mutated by multiple threads, or when a legacy API requires it. StringBuffer's methods are synchronized, which costs something even uncontended, while StringBuilder is almost always a thread-confined local variable. Even for multi-threaded assembly, building separate pieces and joining immutable results is usually cleaner.

Can String's immutability be broken?

Only by bypassing access control with reflection on the private value field (or with Unsafe). Since JDK 16 (JEP 396) and 17 (JEP 403) this is blocked by default and requires --add-opens java.base/java.lang=ALL-UNNAMED. Doing it corrupts shared pooled literals across the whole JVM, leaves stale cached hashes and can be hidden by JIT constant folding — so it's a curiosity, never a technique.

Key takeaways

  1. An immutable object's state never changes after construction; methods like concat, replace and toUpperCase return new objects, and reassignment only moves a reference. final variables and immutable objects are different things.
  2. String enforces immutability with a final class, private final storage (byte[] + coder since JDK 9, char[] before), no mutators and defensive copies — the same recipe works for your own classes.
  3. The string pool, cached hash codes and safe HashMap keys all depend on strings never changing; a mutable key can strand an entry in the wrong bucket.
  4. Immutability makes check-then-use security logic sound and lets any number of threads share a String without locks, backed by final-field semantics in JLS §17.5.
  5. The cost is allocation: never build strings with += in a loop — use StringBuilder, String.join or Collectors.joining; prefer StringBuilder over StringBuffer.
  6. Keep secrets in char[] and wipe them, because a String cannot be erased.
  7. Reflection on String.value is blocked by default since JDK 16/17 and would corrupt the JVM anyway — immutability is a contract the whole platform is built on.