Skip to content
JavaAgentic

Type at least two characters. Try “RAG”, “pgvector” or “tool calling”.

Strings: Immutability, the Pool and StringBuilder

Why String is immutable and what that buys you, how the string pool and intern() really work, why == sometimes appears to work, compact strings, and when concatenation in a loop actually costs you.

Beginner6 min readUpdated
On this page

String questions look like beginner material and are asked at every level, because the follow-ups run deep: from why immutability was chosen, through the mechanics of the pool, into a real performance characteristic that still bites production code.

Key Takeaways

  • Immutability is what makes strings safe to cache, share across threads and use as HashMap keys — the hash is computed once and stays valid.
  • The string pool holds literals and anything explicitly interned. It has lived on the heap since Java 7, not in PermGen.
  • == compares references. It appears to work for literals because they are pooled, which is exactly what makes the bug hard to spot.
  • Concatenating in a loop is quadratic. Java 9's invokedynamic change did not fix that.
  • Compact strings (Java 9+) store Latin-1 text one byte per character, typically cutting string memory by close to half.

Why immutable

String is final, its backing array is private final, and no method mutates it — every operation returns a new instance. Four things follow.

Safe sharing. Any number of threads can read the same String with no synchronisation, because there is no state transition to observe. That is also what makes pooling possible: if strings were mutable, two variables sharing a pooled instance would see each other's edits.

Cached hash code. String.hashCode() computes once and stores the result in a field. Since the content cannot change, the cached value can never be stale. That makes String the ideal HashMap key, and it is why map-heavy code with string keys is fast.

Security. File paths, class names, database URLs and hostnames are all passed around as strings. If they were mutable, a caller could pass a validated path and then mutate it after the check but before the open — a time-of-check-to-time-of-use hole. Immutability makes validation meaningful.

Class loading. The JVM identifies classes by name. A mutable class name would be an obvious attack surface.

The string pool

The pool is a JVM-internal table of String instances, keyed by content. Every string literal in compiled code is added to it during class loading, and any literal with the same content resolves to the same instance.

identity vs equality
String a = "spring";
String b = "spring";
a == b;                       // true — same pooled instance
 
String c = new String("spring");
a == c;                       // false — c is a distinct heap object
a.equals(c);                  // true
 
String d = c.intern();
a == d;                       // true — intern() returns the pooled instance
 
String e = "spr" + "ing";     // constant-folded by javac into the literal
a == e;                       // true
 
String part = "spr";
String f = part + "ing";      // built at runtime, not pooled
a == f;                       // false

Lines four and eight are the whole lesson. "spr" + "ing" is folded by the compiler into a single literal, so it is pooled. part + "ing" is a runtime concatenation producing a fresh object. Two expressions that look identical in source have different identity — which is precisely why == on strings is a bug even when it appears to work.

Since Java 7 the pool is a hash table in normal heap, so interned strings are garbage-collectable and no longer risk filling PermGen. Its bucket count is tunable with -XX:StringTableSize, which matters only if you intern aggressively.

Compact strings

Before Java 9, String wrapped a char[] — two bytes per character, regardless of content. Since Java 9 it wraps a byte[] plus a one-byte coder flag. Text that fits in Latin-1 is stored one byte per character; anything else falls back to UTF-16.

For a typical English-language server workload where strings are often 20–30% of live heap, this is one of the largest single memory wins in the platform's history — commonly a 10–15% reduction in total heap occupancy for nothing. It also means String.length() is no longer simply the array length, and that a string containing one emoji costs twice as much as the same text without it.

Concatenation, and where the cost is

the quadratic loop
// O(n^2) in total characters copied
String out = "";
for (String line : lines) {
    out += line;          // new StringBuilder, new char array, new String — every iteration
}
 
// O(n)
StringBuilder sb = new StringBuilder(lines.size() * 32);   // sized up front
for (String line : lines) {
    sb.append(line);
}
String out = sb.toString();

Each += compiles to a fresh concatenation of the whole accumulated string with the new piece. By iteration n you are copying n characters, so the total work is proportional to n². With 10,000 lines of 50 characters that is roughly 2.5 billion character copies and 10,000 garbage strings.

A single expression — "user " + id + " failed after " + ms + "ms" — needs no StringBuilder. Write it plainly. Reach for StringBuilder when the concatenation is spread across iterations or branches.

StringBuilder, StringBuffer and the rest

TypeMutableThread-safeUse when
StringNoInherentlyValues, keys, anything shared
StringBuilderYesNoBuilding a string in one method — the default
StringBufferYesYes (synchronised)Effectively never

StringBuffer is a Java 1.0 relic. A builder that is genuinely shared between threads is a design mistake — build locally, publish the immutable result. Its synchronized methods are almost always uncontended and the JIT often elides the locks, so the honest answer is "same API, synchronised, and there is no good reason to choose it today".

Two other methods worth knowing: String.join(", ", list) for delimiting, and the Collectors.joining(", ", "[", "]") collector for the stream form. Both are clearer than a manual loop and avoid the trailing-separator bug.

Interview checklist

Expect the sequence: why is String immutable → what is the pool → how many objects does new String("x") create → why == sometimes works → what is wrong with concatenating in a loop. Each answer sets up the next, so a candidate who understands the pool tends to get all five.

The strongest single sentence to have ready: "Immutability lets the JVM cache the hash and share one instance for every occurrence of a literal — the pool is the optimisation immutability makes safe, and == appearing to work is a side effect of that, not a guarantee."

Frequently Asked Questions

How many objects does new String("hello") create?
Two, if the literal "hello" has not been seen before: one in the string pool for the literal itself, created when the class is loaded, and one on the heap from the new expression. If the literal is already pooled, only the heap object is created. This is why new String() on a literal is always pointless — it produces a second object that is equal to but not identical with the pooled one.
Is string concatenation in a loop still slow in modern Java?
Yes, and this has not changed. Java 9 replaced the compiler-generated StringBuilder for a single concatenation expression with an invokedynamic call to StringConcatFactory, which is faster. But a concatenation inside a loop is a separate expression per iteration, so each one still builds a new string from scratch — the loop is O(n squared) in total characters copied. Use an explicit StringBuilder.
When should I use intern()?
Almost never in application code. It is useful when you are parsing a large volume of data with a small set of repeated values — column names from a CSV, tag names from XML — and want to collapse the duplicates. Everywhere else it adds a synchronised native-map lookup to save memory you were not short of, and the pool itself is not free to grow.

Related tutorials