On this page
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
finalreference, and predict whatconcat,replaceandtoUpperCasedo to the original string. - Describe the internal layout of
Stringsince JDK 9 (compact strings,coder, cachedhash) 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,StringBuilderandStringBuffer, and know why passwords belong in achar[]. - Explain why reflection hacks against
String.valuefail 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.
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 WORLDThe 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".
Modifying a String creates a new object
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 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.
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 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 changeA 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:
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;
// ...
}Anatomy of a String object (JDK 9+)
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.
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:
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 arrayOne 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:
| Rule | What it prevents | How String applies it |
|---|---|---|
Declare the class final (or make constructors private and use factories) | A subclass adding mutable state or overriding accessors | public final class String |
Make all fields private final | Reassignment, outside access; also gives JMM visibility guarantees | private final byte[] value, private final byte coder |
| Provide no mutators | Changing state after construction | Every "modifying" method returns a new String |
| Defensive copy on the way in | The caller keeping a handle to your internal array or collection | new String(char[]) copies |
| Defensive copy on the way out | The caller receiving a handle to your internal state | toCharArray(), getBytes() return copies |
Don't let this escape during construction | Other threads seeing a half-built object | Constructors 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:
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:
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;
}
}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.)
Literals share one pooled object; new String() does not
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
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.
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); // trueWhy 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.
If pooled strings were mutable
"admin". Immutability is what makes sharing literals safe.// 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
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 wellA mutable key gets stranded in the wrong bucket
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.
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.
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 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:
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.
Time-of-check to time-of-use
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.)
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.)
Sharing one immutable String across threads
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.
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.
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.
| Optimization | What is reused | Why it needs immutability |
|---|---|---|
| String pool | One object per distinct literal | Shared instances must never change |
| Hash cache | hashCode() computed at most once (ignoring benign races) | A cached derived value is only valid if its inputs are fixed |
| Class loading and linking | Class, method and field names resolved once from the constant pool | Names used as lookup keys in loader and symbol tables must stay fixed |
new String(String) (JDK 9+) | The backing array is shared, not copied | Two objects can share one array only if neither writes to it |
| String deduplication (GC) | Identical backing arrays merged into one | The GC can only swap arrays behind the program's back if they are never written |
| JIT constant folding | Contents of constant strings treated as compile-time constants | Folding 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
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.
String += in a loop vs one StringBuilder
+= 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.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
| Aspect | String | StringBuilder | StringBuffer |
|---|---|---|---|
| Mutability | Immutable | Mutable | Mutable |
| Thread safety | Inherently safe to share | Not synchronized; confine to one thread | Methods are synchronized |
| Since | JDK 1.0 | JDK 5 | JDK 1.0 |
equals / hashCode | Content-based | Identity (not overridden) | Identity (not overridden) |
| Pooling | Literals pooled; intern() | Never | Never |
| Use as map key | Ideal | Avoid | Avoid |
| Typical use | Values, keys, API parameters | Building text in a method or loop | Legacy 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:
char[] password = System.console().readPassword("Password: ");
try {
authenticate(user, password);
} finally {
Arrays.fill(password, '\0'); // wipe it; a String cannot be wiped
}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):
// 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" -- anywhereSince 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
HashMapit is a key of. - The JIT may not notice. The
valuefield is annotated@Stableand 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
LATIN1array cannot be represented at all.
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
| JDK | Change relevant to String immutability |
|---|---|
| 1.0 | String is final and immutable; StringBuffer for mutable text |
| 5 | StringBuilder added (unsynchronized); javac uses it for + |
| 7 | Interned strings move from PermGen to the main heap |
| 7u6 | substring copies instead of sharing the parent array; offset/count removed |
| 8u20 | G1 string deduplication (JEP 192) |
| 9 | Compact strings: byte[] value + coder (JEP 254); indy concatenation (JEP 280); module system |
| 13 | hashIsZero flag avoids recomputing zero hashes |
| 16 / 17 | Strong encapsulation by default (JEP 396) / no --illegal-access (JEP 403) |
| 18 | String 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
- An immutable object's state never changes after construction; methods like
concat,replaceandtoUpperCasereturn new objects, and reassignment only moves a reference.finalvariables and immutable objects are different things. Stringenforces immutability with afinalclass,private finalstorage (byte[]+codersince JDK 9,char[]before), no mutators and defensive copies — the same recipe works for your own classes.- The string pool, cached hash codes and safe
HashMapkeys all depend on strings never changing; a mutable key can strand an entry in the wrong bucket. - Immutability makes check-then-use security logic sound and lets any number of threads share a
Stringwithout locks, backed by final-field semantics in JLS §17.5. - The cost is allocation: never build strings with
+=in a loop — useStringBuilder,String.joinorCollectors.joining; preferStringBuilderoverStringBuffer. - Keep secrets in
char[]and wipe them, because aStringcannot be erased. - Reflection on
String.valueis blocked by default since JDK 16/17 and would corrupt the JVM anyway — immutability is a contract the whole platform is built on.