Skip to content

Java · Fundamentals

Java Strings — Immutability, the String Pool & StringBuilder Explained

9 min read Updated 2026-06-20 Share:

Practice Strings interview questions

The most-used class in Java

String is the first type most Java developers touch and the one they understand least. It looks like a primitive, prints like text, and yet it's a full-blown object with surprising behavior around equality, memory, and performance. Interviewers lean on strings precisely because a clear answer reveals whether you understand object identity, immutability, and the cost of allocation. This guide walks through the model: why strings are immutable, how the pool works, when == lies to you, and why StringBuilder exists.

Immutability: the foundation

A String wraps a private final array of characters that is never exposed or modified after construction. Every "modifying" method — concat, toUpperCase, replace — returns a brand-new String and leaves the original untouched. This is the single fact that explains almost everything else about strings.

String s = "hello";
s.concat(" world");      // returns a NEW String, immediately discarded here
System.out.println(s);   // "hello" — s itself never changed
s = s.toUpperCase();     // you must REASSIGN to keep the new String
System.out.println(s);   // "HELLO"

Immutability buys four things worth naming in an interview: security (a validated path or username can't be mutated behind your back), safe sharing/caching (identical text can be pooled), thread safety (no synchronization needed for a value that never changes), and hashcode caching (the hash is computed once and can never go stale, making String the ideal HashMap key). The price is garbage: heavy string building churns out throwaway objects — which is exactly the problem StringBuilder solves.

The string pool and literals vs new String()

The JVM keeps a string constant pool (the intern pool) holding one shared copy of each distinct string literal. Two identical literals point at the same pooled object, saving memory. A string created with new String("x"), by contrast, forces a fresh heap object — defeating the pool for no benefit.

String a = "x";              // pooled object
String b = "x";              // reuses the SAME pooled object
String c = new String("x");  // a distinct heap object ("x" is also in the pool)

System.out.println(a == b);  // true  — same pooled reference
System.out.println(a == c);  // false — c is a different object
System.out.println(a.equals(c)); // true — same characters

Immutability is what makes pooling safe: since no one can mutate "x", sharing it everywhere is harmless. Since Java 7 the pool lives on the heap (not PermGen), so it's subject to ordinary garbage collection. The practical rule: never write new String("literal") — it's pure waste.

When you have a computed string and genuinely want the pooled instance, intern() returns the canonical copy: if an equal string is already pooled you get that reference, otherwise the string is added and that reference returned. Use it sparingly — interning millions of unique strings just bloats the pool. It earns its keep only when deduplicating a bounded set of repeated values.

String a = "hi";
String c = new String("hi");
System.out.println(a == c);          // false
System.out.println(a == c.intern()); // true — intern() hands back the pooled "hi"

== vs .equals() — the classic bug

== compares references (do both variables point to the same object?), while .equals() compares characters. For strings you almost always want .equals(); == only ever "works" by accident when both operands happen to be the same pooled literal.

String a = "hi";
String b = new String("hi");
System.out.println(a == b);      // false — different objects
System.out.println(a.equals(b)); // true  — same characters

String c = "hi";
System.out.println(a == c);      // true — both the pooled literal (coincidence!)

This is the most common Java beginner bug, and the fix has a twist: calling .equals() on a null reference throws NullPointerException. The idiomatic guard is the Yoda condition — put the known constant on the left — or reach for Objects.equals when either side might be null.

String input = maybeNull();
"yes".equals(input);          // safe — returns false when input is null
Objects.equals(input, "yes"); // safe both ways

String vs StringBuilder vs StringBuffer

Because String is immutable, building text by repeated concatenation is wasteful. The mutable companions exist for exactly this:

TypeMutable?Thread-safe?Use when
Stringnoyes (immutable)fixed text, map keys
StringBuilderyesnobuilding strings (the default)
StringBufferyesyes (synchronized)shared across threads (rare)

StringBuilder and StringBuffer share an identical API — the only difference is that StringBuffer's methods are synchronized, which makes it slower. Since shared mutable string state across threads is uncommon (and usually better handled with a local builder per thread), StringBuilder is the right default.

StringBuilder sb = new StringBuilder();
sb.append("a").append(1).append(true); // fluent, mutates one buffer in place
String result = sb.toString();         // "a1true"

Concatenation performance and how + compiles

Here's where immutability bites. Writing s += part inside a loop allocates a newString and copies all existing characters on every iteration — overall O(n²) work plus a mountain of garbage. A single StringBuilder reuses one growing buffer for amortized O(n).

// O(n²) — a new String allocated and copied each iteration
String r = "";
for (String p : parts) r += p;

// O(n) — one reused buffer
StringBuilder sb = new StringBuilder();
for (String p : parts) sb.append(p);
String r2 = sb.toString();

The subtlety interviewers probe: a single a + b + c expression is not a problem. Historically javac rewrote it into a StringBuilder chain; since Java 9 it emits an invokedynamic call bound to StringConcatFactory ("indified" string concatenation), letting the JVM choose the optimal strategy at runtime. Either way the compiler can only optimize one expression — it cannot collapse a += accumulation across loop iterations. That loop is the only case where you must reach for StringBuilder yourself.

String s = "x" + n + "y";
// pre-9 : new StringBuilder().append("x").append(n).append("y").toString()
// 9+    : invokedynamic -> StringConcatFactory.makeConcatWithConstants(...)

The String API you'll actually use

Most day-to-day string work is a handful of methods, each with a gotcha worth knowing. Searching: charAt(i) throws out of range, indexOf returns −1 (not an exception) when nothing matches, and contains returns a boolean. Slicing: substring(begin, end) is end-exclusive, and since Java 7u6 it copies the chars into a new array, so there's no longer a memory-retention trap.

String s = "interview";
s.charAt(1);          // 'n'
s.indexOf('e');       // 3   (-1 if absent — guard with if (i >= 0))
s.substring(0, 5);    // "inter" — chars 0..4, end is exclusive
s.contains("view");   // true

The biggest trap lives in the regex-based methods. split and replaceAll take a regular expression, not literal text, so an unescaped . matches any character. Use replace (literal) when you don't need regex, and pass a negative limit to split to keep trailing empties.

"a.b.c".replace(".", "-");      // "a-b-c"  — literal dot
"a.b.c".replaceAll(".", "-");   // "-----"  — "." is regex "any char"!
"a.b.c".split("\\.");           // ["a","b","c"] — escaped dot
"a,b,,".split(",", -1);          // ["a","b","",""] — limit -1 keeps trailing blanks

For trimming, prefer strip (Java 11+, Unicode-aware) over the legacy trim, which only removes characters <= U+0020. Use isBlank when an all-whitespace string should count as empty, and lean on the builder helpers — String.join, String.format, and repeat — instead of hand-rolled delimiter loops.

" hi ".strip();                 // "hi" — handles non-ASCII whitespace
"   ".isBlank();                // true (isEmpty() would be false)
String.join("-", "a", "b", "c"); // "a-b-c"
"ab".repeat(3);                  // "ababab"

Text blocks for multi-line literals

Text blocks (Java 15+) are multi-line string literals delimited by triple quotes """. They preserve line breaks and let you embed JSON, SQL, or HTML without escaping quotes or concatenating lines. Incidental leading whitespace is stripped relative to the closing delimiter's indentation, and the result is an ordinary String — pooled, with the full API available.

String json = """
    {
      "name": "Ada",
      "role": "engineer"
    }
    """;                  // a normal String; use \ at line end to suppress a newline

Why char[] beats String for passwords

Immutability has a security downside for secrets. A String can't be cleared — it may sit in the pool and lingers in memory until garbage collected, where it could surface in a heap dump or get logged accidentally via toString(). A char[] can be explicitly overwritten the instant you're done with it.

char[] pw = readPassword();
try {
    authenticate(pw);
} finally {
    Arrays.fill(pw, '\0'); // wipe it — minimizes the exposure window
}

This is precisely why Console.readPassword() and JPasswordField.getPassword() return char[] rather than String. The same charset-awareness applies to byte conversions: always pass an explicit charset (StandardCharsets.UTF_8) to getBytes and the String(byte[], charset) constructor, or you'll hit the classic "works on my machine" mojibake bug from the platform default encoding.

Recap

String is immutable: its backing char array is private final, and every "modifying" method returns a new object. That one fact powers the string pool (shared literals, made safe by immutability), justifies the == vs .equals() distinction (compare content, guard against null with the Yoda trick or Objects.equals), and explains why concatenation in a loop is O(n²) garbage that StringBuilder turns into O(n). Reach for StringBuilder by default and StringBuffer only when truly sharing across threads. Know the API gotchas — split/replaceAll take regex, substring is end-exclusive, indexOf returns −1, strip beats trim — use text blocks for multi-line literals, and remember char[] over String for passwords. Understand immutability and the rest of the String story falls into place.

More ways to practice

The self-quiz is live. Get notified when mock interviews and new question packs drop.

or
Join our WhatsApp Channel