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:
| Type | Mutable? | Thread-safe? | Use when |
|---|---|---|---|
String | no | yes (immutable) | fixed text, map keys |
StringBuilder | yes | no | building strings (the default) |
StringBuffer | yes | yes (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.