Skip to main content

String Immutability Hiccups

· 10 min read
Linh Nguyen
T-90MS Main Battle Tank
thumbnail

You know String is immutable. But did you ever wonder how deep the rabbit hole can go?

The Interview Room

You walk in, palms slightly damp, wearing your best "I definitely know Java" face. The interviewer leans forward, and asks the question. The question. The one you have drilled into your skull since your first encounter with a Java tutorial:

"Can you explain why String is immutable in Java?"

You have read this question a thousand times. You have answered it in your sleep, in the shower, during long commutes, and once, embarrassingly, out loud on a bus. You smile and deliver the well-worn explanation:

String is immutable because its value cannot be changed after it has been created. Most operations on a String, like concatenation or substring, return a modified copy of the original. This means the original String remains untouched, making it safe to share between threads, suitable for use as HashMap keys, and eligible for storage in the String Constant Pool.

The interviewer nods. Maybe you get the job.

But here is the thing that will gnaw at you on the commute home:

Is what you just said actually correct, or have you been confidently repeating a half-truth for years?

Welcome to the club. There is free coffee and a lot of Stack Overflow tabs.

The Short Answer

String is technically (and academically) NOT immutable.

There. Someone finally said it out loud.

Before you throw this article at your interviewer (or call me heretic), let me explain.

The keyword here is technically.

By the strict academic and technical definition, String does not qualify as a truly immutable object. To see why, we need to look at what "truly immutable" actually means.

Also, I am not the only one who said that. Check the short video made by Jose Paumard, hosted on Java YouTube Official channel here:

The "True Immutability" Checklist

For a class to be genuinely, academically, philosophically immutable in Java, two conditions must hold:

  1. The class itself must be final (so nobody can extend it and ruin everything with a mutable subclass).

  2. All of its fields must be final (so nothing inside can change after construction).

String passes the first test. It is, without a doubt, final.

Great.

But it stumbles, trips, and faceplants spectacularly on the second one.

Problem One: The Mutable hash Field

In Java 12 and earlier, the String class contains a single non-final field:

private int hash; // Default to 0

This field caches the hash code of the string. It starts at 0, and gets populated lazily, meaning it is only calculated when something actually needs it. That "something" is typically:

  • Placing the string into a hash-based collection like HashMap or HashSet.

  • Calling Stream.distinct(), which commonly uses a hash-based set to avoid comparing every element with every earlier element. Note, however, that the Stream API does not require that exact implementation.

  • Using it in a switch statement or expression, where literal cases are compiled into their hash codes and compared using equality checks in bytecode.

Computing a hash for a very long string (think the text of a novel, or a particularly dramatic README) is not free. Since hash codes are only used in a handful of scenarios, calculating them eagerly at construction time would be wasteful. So Java lazily computes it on first demand and caches it in hash for all future calls.

This is clever and pragmatic. It is also, by definition, mutable state.

Then String has a minor change, and nothing changes at all

Java 13 introduced a second non-final field to address a subtle edge case (JDK-8221836):

private boolean hashIsZero; // Default to false

The problem it solves: if a string's actual hash code happens to be zero, the hash field looks exactly like the "not yet computed" default, causing the JVM to recalculate it on every single call. hashIsZero is a flag that says, "yes, I calculated it, and it really is zero, stop asking."

Functionally, it is essentially the same story as hash: a non-final field that gets written to after construction. Technically mutable. Two strikes.

Problem Two: The Naked, Woefully Mutable Backing Array

This one is arguably more fundamental, and is frustratingly underrated in most "is String immutable?" discussions.

Under the hood, a String stores its characters in an array. Before Java 9, this was a char[]. Starting with Java 9, the Compact Strings improvement (JEP 254) changed this to a byte[], which halved memory usage for strings containing only Latin-1 characters. Regardless of the era, the core problem is the same: arrays in Java are always mutable.

Sure, the reference to the array is final, which means the field cannot point to a different array after construction. But final on a reference does not freeze the contents of the object being pointed to. The bytes inside that array can, in principle, be modified. We simply do not have natively frozen arrays in Java. Not yet, anyway.

This is why String.toCharArray() does not hand you the internal array. It returns a defensive copy. If the String were truly immutable, the internals could theoretically expose a toImmutableCharArray() that returned the original directly, because nobody could corrupt it. Instead, we pay the cost of copying every single time, because the alternative is handing out a loaded gun.

note

To be fair: if you called toCharArray(), you almost certainly intend to modify the result, so a copy is the reasonable and sensible thing to return anyway. But the reason a copy is mandatory, rather than optional, is that the array itself is not frozen.

The Enum Cautionary Tale

If you want a more visceral illustration of what mutable backing arrays can do in the wrong hands, look no further than Enum.values(). Every call to values() on an enum also returns a defensive copy of its backing array. Why? Because if the original array were returned directly, nothing would stop someone from mutating it, corrupting the enum's state, and causing the kind of chaos that makes senior developers stare blankly at walls.

You can destroy your carefully guarded app for yourself!

In fact, you can demonstrate this rather conclusively with the following code. Add this to your toolbox of "things to never run in production but very satisfying to run in a demo":

public enum YourEnum {
ENUM_1, ENUM_2;
}

// JVM flag: --add-opens=java.base/java.lang=ALL-UNNAMED

public static void main(String[] args) throws IllegalAccessException {
var fields = YourEnum.class.getDeclaredFields();

// On Oracle JDK 17, javac's compiler-synthetic backing array is last
var valuesField = fields[fields.length - 1];

// open the gate!!! VIP pass, you can trust me!
valuesField.setAccessible(true);

// a static field
var values = (YourEnum[]) valuesField.get(null);

// Let the world burn!!!
values[0] = null;

// [null, ENUM_2]
System.out.println(Arrays.toString(YourEnum.values()));
}

At least on an Oracle JDK 17 build, you have just set the first enum constant to null for the remainder of the JVM session. All subsequent calls to values() will now return a defensive copy of your newly corrupted array. Anything that relied on that constant being non-null will now behave in creative and horrifying ways. Tell your friends.

The point is: if even the mighty enum, one of Java's most guarded constructs, is vulnerable to low-level array manipulation, that gives you a very clear picture of just how mutable those backing arrays really are.

What happens if someone gets a hold of the actual array?

The consequences of a String's internal array being corrupted are not merely inconvenient. They include:

  • Hash code corruption: if hash has already been cached, it now disagrees with the actual content. Everything that depends on hash-based lookups breaks silently.

  • Security vulnerabilities: strings are used extensively in security-sensitive contexts, like storing credentials or file paths. A mutated string can bypass checks that passed moments ago.

  • JVM optimization failures: the JVM performs aggressive optimizations under the assumption that strings are effectively stable. String deduplication, for example, can arrange for equal String objects to share one backing array; it does not merge the String objects themselves. Corrupting such an array may therefore affect more than one string and can invalidate assumptions made by the runtime.

The JVM does everything in its considerable power to prevent you from reaching the backing array by normal means. This is not a coincidence.

So, What Are We Dealing With Here?

What String actually is can be described with a single phrase: shallowly immutable.

The reference to the backing array is final, meaning it cannot be repointed. But final references to mutable objects are not the same as immutable objects. The contents of the array can still be changed. The non-final hash and hashIsZero fields can still be written to after construction.

This category of "immutable-ish" is familiar from everyday Java. Your class with getters but no setters? Still mutable if it returns a direct reference to one of its internal lists. A naive ArrayList wrapper? Same story. A plain array? You already know.

Shallow immutability means the structure of the object is locked, but the contents of the things it holds are not.

But Hold On: "Effectively Immutable" Is Not a Cop-Out

Here is where the plot thickens slightly in String's favor.

Yes, String is not immutable by the academic definition. But none of that actually matters to you as a developer, because String is effectively immutable, and that is the part that counts.

The keyword is effective. What "effectively immutable" means in practice:

  • The hash cache is populated lazily. Racing threads can perform equivalent writes, but they are benign and invisible from outside the class. In OpenJDK 25, hash is also annotated @Stable (though not final); hashIsZero is not.

  • The backing array is private final, meaning no external code can legally access it.

  • The JVM places special trust in String. It is the subject of aggressive internal optimizations and guarantees. The @Stable annotation on the value field, for instance, allows the JIT compiler to treat its contents as trusted and stable, unlocking constant folding and other tricks that would be off-limits for ordinary mutable objects.

So while someone armed with reflection and the --add-opens flag can theoretically poke at the internals, for all practical purposes, from the outside world, a String behaves exactly as though it were immutable. Its value never visibly changes. Every operation returns a new object. Thread safety is guaranteed.

The JVM is, essentially, working very hard to make the lie close enough to the truth that it does not matter.

The One-Sentence Verdict

Technically and academically: mutable. For all intents and purposes: effectively immutable.

Congratulations. You now know slightly more than the question was asking for, and slightly too much to answer it without a footnote.

What the future may hold?

There is a reason to be cautiously optimistic. There are open proposals to introduce truly frozen arrays to the JVM:

  • JDK-8261007: Frozen Arrays (Preview), for user-visible frozen arrays with language and VM support.

  • JDK-8261099: Internal Frozen Arrays, a lower-level, unsafe primitive method to lock down a mutable array in place.

If frozen arrays land and String's internal backing array can be frozen at construction time, String would graduate from "effectively immutable" to something much closer to the real thing. The JVM would be able to do even more with it, and the entire discussion above would become a historical footnote rather than a live gotcha.

Until then, the answer to the interview question is technically inaccurate but practically correct, which, if you think about it, describes a surprisingly large portion of software engineering advice.

Now go get that job.