Java String Immutability Stops TOCTOU Exploits
Heavy-load security flaws from mutable StringBuilder? Immutability prevents TOCTOU.
20+ years shipping production Java in banking & fintech. Everything here is grounded in real deployments.
- String objects are immutable: content never changes after creation
- Variables are mutable: you can re-point them to a new String object
- String Pool caches literals safely only because Strings can't change
- Concatenation in a loop creates O(n) objects — use StringBuilder for loop assembly
- Immutability enables thread safety without locks and cached hash codes
- Biggest mistake: using == instead of .equals() for value comparison
Imagine you carve your name into a wooden block. If someone wants to write your nickname instead, they can't erase it — they have to carve a brand new block. That's exactly how Java Strings work. Once a String is created, it's locked. Any 'change' you make actually produces a brand new String object behind the scenes, leaving the original untouched. The old block still exists in memory until no one needs it anymore.
Every Java developer has written String code from day one, but most don't realise they're working with one of the most carefully designed classes in the entire language. String immutability isn't an accident or a limitation — it's a deliberate architectural decision that affects performance, security, thread safety, and memory management all at once. Ignoring WHY it works this way is how developers write code that silently destroys performance in loops or introduces subtle security holes in web apps.
The problem immutability solves is surprisingly deep. Without it, a String passed into a security-sensitive method — say, a database connection URL or a file path — could be mutated by another thread or a misbehaving library between the moment you validated it and the moment you used it. The JVM's String Pool, which caches and reuses String literals to save memory, would also be completely unsafe if two pieces of code could change the same shared object. Immutability is the foundation that makes all of this safe.
By the end of this article you'll understand exactly what happens in memory when you concatenate Strings in a loop, why StringBuilder exists and when you must reach for it, how the String Pool and the intern() method work under the hood, and what the tricky interview questions about String identity (==) versus equality (.equals()) are really testing. You'll write faster, safer, more confident Java from here on.
What Immutability Actually Means in Memory
When you write String name = "Alice", the JVM creates a String object in a special region of the heap called the String Pool (more on that shortly) and points the variable name at it. Now when you write name = name + " Smith", it looks like you changed name. You didn't. The JVM created a brand new String object "Alice Smith" and re-pointed the variable at it. The original "Alice" is still sitting in memory, unchanged.
This is the single most important thing to internalise: the variable is mutable (you can re-point it), but the object it points to is immutable (the content never changes).
Under the hood, a String is backed by a private final char[] (or byte[] in modern Java). The field is final so it can't be re-assigned, and it's private so no external code can reach in and change the array's contents. The class itself is also declared final, so nobody can subclass it and override that protection. These three decisions — private, final field, final class — work together to make immutability airtight.
The String Pool — How Java Recycles String Objects
The String Pool (officially the 'string intern pool') is a cache inside the heap. When the JVM sees a string literal like "hello", it checks the pool first. If "hello" is already there, it hands back the existing reference rather than creating a new object. This is safe only because Strings are immutable — if they weren't, sharing references would be a disaster.
Strings created with new String("hello") deliberately bypass the pool and always create a fresh heap object. This is almost never what you want, and it's the source of the classic == trap.
The intern() method lets you manually push a String into the pool. This is useful when you're building a huge dataset with many repeated strings (like parsing CSV column names thousands of times) and you want to collapse duplicates. From Java 7 onwards, the pool lives on the regular heap (not PermGen), so it's subject to normal garbage collection — a big improvement.
== to compare String values in production code. It compares memory addresses, not content. Two Strings with identical text will return false if one came from new String() or from an external source like a database or network. Always use .equals() or .equalsIgnoreCase().String() creates a separate object.intern() sparingly: it's a tool for large datasets with many duplicate strings.When Immutability Hurts — String vs StringBuilder vs StringBuffer
Immutability is a gift for safety but a curse for heavy modification. If you're building a String piece by piece — assembling SQL fragments, constructing a CSV row, building HTML — creating a new object on every step is wasteful. This is exactly why StringBuilder was introduced.
StringBuilder is a mutable sequence of characters. It pre-allocates a buffer and appends into it in-place. No new object per operation. When you're done, you call .toString() once to produce the final immutable String.
StringBuffer is the older, thread-safe sibling. Every method is synchronized, which makes it safe across threads but slower in single-threaded code. The rule of thumb: use StringBuilder in single-threaded code (the vast majority of cases), reach for StringBuffer only when multiple threads share the same builder — though in modern Java you'd more likely restructure the code to avoid that pattern altogether.
The compiler actually helps you here: simple one-liner concatenations like "Hello, " + name + "!" are auto-compiled to use StringBuilder internally. But the moment that concatenation is inside a loop, the compiler creates a new StringBuilder per iteration, defeating the purpose.
new StringBuilder(estimatedLength). It avoids internal buffer re-allocations as the builder grows, squeezing out even more performance in tight loops.Why Immutability Is a Security and Thread-Safety Feature
Here's a scenario that breaks insecure systems: imagine a class that validates a file path and then opens it. If Strings were mutable, a malicious thread could modify the path between the validation check and the file open call — a classic Time-of-Check to Time-of-Use (TOCTOU) attack. Because Strings are immutable, the path you validated is guaranteed to be the exact same bytes when you use it a millisecond later. No synchronisation needed.
This same guarantee is what makes Strings safe as HashMap keys. The hash code is computed once and cached inside the String object (the hash field). Because the content can never change, the cached hash never goes stale. If Strings were mutable, you could insert a key, mutate it, and the HashMap would never find it again because the bucket would be wrong.
Immutability also makes Strings inherently thread-safe. You can share one String reference across hundreds of threads with zero locking. This is enormous for performance in concurrent systems like web servers where the same configuration strings, URL patterns, and SQL templates are read constantly.
String Equality: The One Rule That Prevents Hours of Debugging
The most common production bug involving Strings is using == to compare values. This mistake is so prevalent that interviewers ask about it constantly, yet it still makes it into production code every day. The rule is simple: == checks if two references point to the same memory location; .equals() checks if the content of two strings is the same.
When does == work correctly? Only when both strings are guaranteed to be from the String Pool, i.e., both are literals like "hello". The moment one string comes from a method call, database, network, or new , String()== will return false even for identical text. That's why login checks, authorization logic, and configuration comparisons break silently.
There are two common workarounds that also fail: calling .intern() on every string before comparing, or using == with . The first is expensive (interning has a global lock in some JVM implementations), the second is unreliable. The fix is always intern().equals(). For case-insensitive comparisons, use .equalsIgnoreCase().
Another subtle trap: null handling. Calling .equals() on a null reference throws NullPointerException. Use Objects.equals(str1, str2) which is null-safe, or invert the call: "knownValue".equals(unknownValue).
== to compare String values that originate from external sources (DB, API, files). Always use .equals(). This single rule prevents more production outages than any other String-related advice.Objects.equals() or inverted equals is a defensive habit that prevents NPE at 3 AM.String.valueOf(Object) or Objects.toString() before comparison.intern() as a workaround for == — it's expensive and still not a substitute for semantic equality.Why String Is Immutable in Java — The Three Non‑Negotiables
Most devs recite “String is immutable” like a mantra they never questioned. Here’s why it’s not optional, it’s structural.
First: the String Pool. Java interns string literals so two variables can point to the same memory. If one variable could mutate that object, every reference to the literal would see the change. Your password variable just became your neighbor’s password. Pool is dead without immutability.
Second: security. Class loaders use Strings for class names. JDBC uses them for connection URLs. File paths are Strings. If a hacker could modify a String after creation, they could redirect a database call, load a rogue class, or rewrite a file path. Immutability means the reference you validated at the start stays valid until GC.
Third: thread safety. Strings can be shared across threads with zero synchronization overhead. No locks, no volatile, no data races. A String that never changes is the cheapest concurrency primitive you’ll ever use.
This isn’t a language quirk. It’s a deliberate constraint that enables the JVM’s most aggressive optimizations.
String with a mutable subclass (impossible — it’s final), you’d break every security manager, class loader, and hash map in the JVM. The JVM trusts Strings. Don’t try to outsmart it.Two Ways to Create a String — One You Should Almost Never Use
Every Java dev thinks they know this. Then they hit a memory pressure incident and realize new String("hello") created two objects for no reason.
First: string literals. String name = "Alice"; This checks the String Pool. If the literal exists, you get the existing reference. Zero allocation overhead. This is the only way you should create Strings in normal code.
Second: new String("Alice"); This forces the JVM to create a new heap object even if the literal is already interned. The literal itself still goes into the pool — you just get a separate, redundant copy. Used in legacy code where you wanted to guarantee a unique identity for == comparisons (never do this). The constructor exists for serialization round‑tripping and explicit char[] cloning. That’s it.
Rule of thumb: always use literals. If you need a mutable sequence, reach for StringBuilder. If you need explicit interning, call on a literal. Otherwise you’re burning heap for zero benefit.intern()
-XX:+PrintStringTableStatistics at startup. It shows the number of pool entries and bucket density. If you see high collision rates, bump -XX:StringTableSize=N (default 60013). Don’t guess — measure.new String("literal"). If you see it in a code review, flag it — it’s a memory leak in slow motion.Guava ImmutableList vs Collections.unmodifiableList — What Actually Prevents Mutation
Here’s the dirty secret most Java devs miss: Collections.unmodifiableList() doesn’t make your list immutable. It wraps the original in a read-only view, but if someone still holds a reference to the backing list, they can mutate it through that backdoor. Your “immutable” object silently changes, and you’ll spend hours debugging ghost bugs. Guava’s ImmutableList kills that attack vector by copying the data into its own internal array and creating a fresh object. No wrapper, no reference to the source—the backing data is truly yours. The performance trade-off is the copy cost at construction time, but you get ironclad guarantees. If you’re returning defensive copies from a public API, ImmutableList is your shield. unmodifiableList is a polite suggestion; Guava is a contract enforced by the JVM. Choose your weapon wisely.
Vavr Persistent Data Structures — Immutability Without the String Overhead
String immutability is free in Java because the JVM pools literals. But try that with your own objects? Every defensive copy burns CPU and GC time. Vavr crushes this problem with persistent data structures—immutable collections that share structure across versions. Instead of copying the entire list, Vavr’s List or Vector reuses most nodes when you “add” an element, returning a new reference that shares unchanged parts. The memory overhead? Logarithmic, not linear. For scenarios where you mutate collections frequently (undo stacks, event sourcing), Vavr removes the performance guilt from immutability. It’s not just “thread-safe” in theory—it’s structurally sharing guarantees. No defensive copies, no mutable ghosts. Think of it as git for your objects: every “change” is a new commit, but unchanged blobs are shared. If you’ve been avoiding immutability because of cost, Vavr is your get-out-of-jail card.
The TOCTOU Exploit That Immutability Prevented
.toString() call.- Do not validate a mutable object and then use it as if it were immutable — convert to String first.
- Immutability is a guarantee only if the reference you hold is to an immutable object.
- Always finalize string construction (e.g.,
StringBuilder.toString()) before performing security-sensitive checks. - Immutable objects remove an entire class of TOCTOU vulnerabilities — use them intentionally.
String or StringBuffer instead of StringBuilder. If StringBuffer is used, evaluate if synchronization is truly needed. Pre-size the StringBuilder with an estimated capacity to avoid internal array resizing.get() returns null for a key that was inserted with seemingly the same stringString(), the hash code is still correct — check that the actual string content matches (spaces, encoding). Use .equals() for key lookup, not ==.grep -rn '== .*"' src/main/java/System.out.println(str1.equals(str2)); // add for verificationKey takeaways
.toString() once at the end.== to compare String values in production.equals(). For null-safe comparison, use Objects.equals() or invert the call.Common mistakes to avoid
5 patternsUsing `==` to compare String values
false, causing silent logic bugs (e.g., a login check that always fails for passwords loaded from a database).equals() for value comparison. Save == for intentional reference/identity checks only.Concatenating Strings inside a loop with `+=`
StringBuilder.append() and call .toString() exactly once after the loop completes.Calling `new String("literal")` thinking it creates a distinct safer copy
== comparisons with pooled literals silently return false, causing confusionString s = "value") in virtually all cases; only use new String(charArray) when you genuinely need to construct a String from a char array or byte array.Using `StringBuffer` in single-threaded code because 'it's safer'
StringBuilder for all single-threaded string construction. Reserve StringBuffer only for cases where the builder object is shared across threads (rare).Not handling null safely when comparing strings
.equals() on itObjects.equals(str1, str2) or invert the call: "known".equals(unknown). Avoid calling methods on potentially null references.Interview Questions on This Topic
Why is the String class declared `final` in Java, and what would break if it weren't?
charAt() or hashCode() to return different values, violating the contract that strings are immutable. This would break String Pool safety, HashMap key behavior, and security mechanisms like path validation where the integrity of the string is assumed. In short: immutability is only as strong as the class hierarchy permits — final is the lock on the door.Frequently Asked Questions
20+ years shipping production Java in banking & fintech. Everything here is grounded in real deployments.
That's Strings. Mark it forged?
8 min read · try the examples if you haven't