A String wraps a private final array of characters that is never exposed
or modified after construction; every "modifying" method returns a new
String. Immutability buys four things interviewers want to hear:
- Security — a path, URL, or username can't be changed after a check.
- Caching / pooling — identical literals can be shared safely.
- Thread safety — an immutable object needs no synchronization.
- Hashcode caching — the hash is computed once and can never go stale,
making
Stringan idealHashMapkey.
String s = "hello";
s.concat(" world"); // returns a NEW String, discarded here
System.out.println(s); // "hello" — s itself is unchanged
s = s.toUpperCase(); // reassign the reference to the new String
The trade-off is garbage: heavy string building creates throwaway objects,
which is exactly why StringBuilder exists.
The string pool (a.k.a. the intern pool) is a special area where the JVM
stores one shared copy of each distinct string literal. Two identical
literals refer to the same pooled object, saving memory. Strings built with
new are not pooled — they create a fresh heap object.
String a = "hi"; // pooled
String b = "hi"; // same pooled object
String c = new String("hi"); // new heap object, NOT pooled
a == b // true — same pooled reference
a == c // false — different objects
Immutability is what makes pooling safe — since no one can mutate "hi",
it's harmless to share it everywhere. Since Java 7 the pool lives on the
heap (not PermGen), so it's subject to normal garbage collection.
A literal is resolved at class-load time and points at the pooled
object — repeated literals share one instance. new String("x") forces a
brand-new object on the heap plus puts "x" in the pool, so it can create
two objects and is almost always wasteful.
String a = "x"; // 1 pooled object
String b = "x"; // reuses the pooled object
String c = new String("x"); // 1 new heap object (+ "x" already pooled)
a == b // true
a == c // false — c is a distinct object
a.equals(c) // true — same characters
Rule: never write new String("literal") — it defeats pooling for no benefit.
intern() returns the canonical pooled copy of a string: if an equal
string is already in the pool, you get that reference; otherwise the string
is added to the pool and that reference is returned. It lets you force
== to work on computed strings.
String a = "hi";
String c = new String("hi");
a == c // false
a == c.intern() // true — intern() returns the pooled "hi"
String built = ("h" + new String("i")).intern();
a == built // true
Use it sparingly: interning huge numbers of unique strings just fills the pool and can hurt more than it helps. It's mainly useful for deduplicating a bounded set of repeated values to save memory.
== compares references (do both variables point to the same object?),
while .equals() compares the characters. For strings you almost always
want .equals() — == only "works" by accident when both operands happen to
be the same pooled literal.
String a = "hi";
String b = new String("hi");
a == b // false — different objects
a.equals(b) // true — same characters
String c = "hi";
a == c // true — both the pooled literal (coincidence, don't rely on it)
This is the single most common Java beginner bug. Always compare string
content with .equals() (or equalsIgnoreCase).
Calling .equals() on a null reference throws NullPointerException.
The classic trick is to put the known constant on the left, or use the
null-safe Objects.equals.
String input = maybeNull();
input.equals("yes"); // NPE if input is null
"yes".equals(input); // safe — false when input is null
Objects.equals(input, "yes"); // safe both ways
The "Yoda condition" ("yes".equals(input)) is the idiomatic fix; reach for
Objects.equals(a, b) when either side could be null.
| 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 the same API; the only
difference is StringBuffer's methods are synchronized, which makes it
slower.
StringBuilder sb = new StringBuilder();
sb.append("a").append(1).append(true); // fluent, mutates in place
String result = sb.toString(); // "a1true"
Prefer StringBuilder by default; you rarely need StringBuffer because
shared mutable string state across threads is uncommon (and usually better
handled with a local builder per thread).
Because String is immutable, s += part allocates a new String (and
copies all existing characters) on every iteration — overall O(n²)
work and a pile of garbage. A single StringBuilder reuses one growing
buffer for amortized O(n).
// O(n²) — a new String 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 compiler optimizes a single a + b + c expression into StringBuilder
calls automatically — the problem is concatenation inside a loop, which it
cannot collapse.
Historically javac rewrote a + b + c into a chain of
new StringBuilder().append(a).append(b).append(c).toString(). Since
Java 9, it instead emits an invokedynamic call bound to
StringConcatFactory ("indified string concatenation"), letting the JVM pick
the optimal strategy at runtime.
String s = "x" + n + "y";
// pre-9 : new StringBuilder().append("x").append(n).append("y").toString()
// 9+ : invokedynamic -> StringConcatFactory.makeConcatWithConstants(...)
Practical takeaway is unchanged: a single + expression is fine and fast; a
+= accumulation inside a loop still creates one object per iteration —
use StringBuilder there.
equals— case-sensitive content equality, returnsboolean.equalsIgnoreCase— same, but ignores case.compareTo— lexicographic ordering, returns anint: negative if this string sorts before the argument,0if equal, positive if after.
"abc".equals("ABC") // false
"abc".equalsIgnoreCase("ABC") // true
"apple".compareTo("banana") // negative (a < b)
"abc".compareTo("abc") // 0
"abc".compareToIgnoreCase("ABC") // 0
Use equals/equalsIgnoreCase for "are these the same?" and compareTo
(or String.CASE_INSENSITIVE_ORDER) when sorting.
substring(begin) returns from begin to the end; substring(begin, end)
returns [begin, end) — end is exclusive. Indices out of range throw
StringIndexOutOfBoundsException.
"interview".substring(0, 5); // "inter" — chars 0..4
"interview".substring(5); // "view"
"abc".substring(3); // "" (begin == length is legal)
"abc".substring(1, 1); // "" (begin == end)
"abc".substring(2, 1); // exception — end < begin
Modern note: since Java 7u6 substring copies the chars into a new array
(older JVMs shared the backing array, which could leak the whole parent
string), so there's no longer a hidden memory-retention trap.
charAt(i)— thecharat indexi(0-based); out of range throwsStringIndexOutOfBoundsException.indexOf(x)— first index of a char or substring, or-1if absent.contains(seq)—boolean, true if the substring occurs.
String s = "banana";
s.charAt(1); // 'a'
s.indexOf('a'); // 1 (first match)
s.indexOf('a', 2); // 3 (start searching from index 2)
s.lastIndexOf('a'); // 5
s.indexOf("xyz"); // -1 (not found)
s.contains("nan"); // true
Remember indexOf returns −1, not throws, when nothing matches — guard
with if (i >= 0) before using the result as an index.
split(regex) breaks a string on a regular expression (not a literal!)
and returns a String[]. Two surprises: it drops trailing empty strings
by default, and regex metacharacters must be escaped.
"a,b,c".split(",") // ["a", "b", "c"]
"a,b,,".split(",") // ["a", "b"] — trailing empties removed
"a,b,,".split(",", -1) // ["a", "b", "", ""] — limit -1 keeps them
"a.b.c".split(".") // [] — "." matches ANY char; needs "\\."
"a.b.c".split("\\.") // ["a", "b", "c"]
Pass a negative limit to keep trailing blanks, and escape . | ( ) [ ] \
etc. For a plain delimiter use Pattern.quote(delim).
replace works on literal text (a char or CharSequence) and
replaces every occurrence. replaceAll (and replaceFirst) take a
regex as the pattern. Both return a new string — String is immutable.
"a.b.c".replace(".", "-") // "a-b-c" — literal dot
"a.b.c".replaceAll(".", "-") // "-----" — "." is regex "any char"!
"a.b.c".replaceAll("\\.", "-") // "a-b-c" — escaped dot
"a1b2".replaceAll("\\d", "#") // "a#b#" — regex digit class
If you don't need regex, prefer replace — it's faster and avoids the
"every char got replaced" trap.
Both remove leading and trailing whitespace, but trim (legacy) only
strips characters <= U+0020 (ASCII space and control chars), while
strip (Java 11+) is Unicode-aware and removes all Unicode
whitespace. strip also has one-sided variants.
" hi ".trim() // "hi"
" hi ".trim() // " hi " — thin space NOT removed
" hi ".strip() // "hi" — Unicode-aware
" hi ".stripLeading() // "hi "
" hi ".stripTrailing() // " hi"
Prefer strip on modern Java for correct handling of non-ASCII
whitespace; trim is fine for plain ASCII input.
isEmpty() is true only when the length is 0. isBlank() (Java
11+) is true when the string is empty or contains only whitespace.
"".isEmpty() // true
" ".isEmpty() // false — it has characters
" ".isBlank() // true — only whitespace
"x".isBlank() // false
Neither is null-safe, so check for null first (or use a helper like
StringUtils.isBlank). Use isBlank when "all-spaces" should count as
"empty" — e.g. validating user input.
A String is immutable and may sit in the pool, so you cannot clear
its contents — the password lingers in memory until garbage collected and
could surface in a heap dump. A char[] can be explicitly wiped
right after use.
char[] pw = readPassword();
try {
authenticate(pw);
} finally {
Arrays.fill(pw, '\0'); // overwrite — minimizes the exposure window
}
This is why JPasswordField.getPassword() and Console.readPassword()
return char[]. Strings also risk accidental logging via toString(),
which char[] avoids.
Use toCharArray() / the String(char[]) constructor for characters, and
getBytes(charset) / new String(bytes, charset) for bytes. Always
specify a charset for byte conversions to avoid platform-default surprises.
char[] chars = "hi".toCharArray(); // ['h','i']
String fromChars = new String(chars); // "hi"
String fromChars2 = String.valueOf(chars); // "hi"
byte[] bytes = "hi".getBytes(StandardCharsets.UTF_8);
String fromBytes = new String(bytes, StandardCharsets.UTF_8); // "hi"
Omitting the charset uses the JVM default encoding, which differs across
machines and causes the classic "works on my box" mojibake bug — pass
StandardCharsets.UTF_8 explicitly.
Parse with the wrapper classes' static methods; produce text with
String.valueOf or concatenation. parseXxx returns a primitive,
valueOf a wrapper object.
int n = Integer.parseInt("42"); // primitive int
double d = Double.parseDouble("3.14");
Integer w = Integer.valueOf("42"); // Integer object
String s = String.valueOf(42); // "42"
String s2 = "" + 42; // "42" via concatenation
A malformed string throws NumberFormatException, so validate or
try/catch when parsing untrusted input.
String.formatbuilds a string from a printf-style template.String.joinglues elements with a delimiter (Java 8+).repeatduplicates a stringntimes (Java 11+).
String.format("%s is %d", "Ada", 36); // "Ada is 36"
String.format("%.2f", 3.14159); // "3.14"
String.join("-", "a", "b", "c"); // "a-b-c"
String.join(",", List.of("x", "y")); // "x,y"
"ab".repeat(3); // "ababab"
String.join is the clean way to build delimiter-separated output without a
trailing-comma loop; format is handy but slower than direct concatenation
for hot paths.
Text blocks (Java 15+) are multi-line string literals delimited by triple
quotes """. They preserve line breaks and let you write JSON, SQL, or HTML
without escaping quotes or concatenating lines.
String json = """
{
"name": "Ada",
"role": "engineer"
}
""";
Incidental leading whitespace is stripped relative to the closing delimiter's
indentation, and the result is an ordinary String — it's pooled and supports
every String method. Use \ at line end to suppress a newline.
Since Java 7 you can switch on a String. The compiler implements it in
two steps: it switches on the string's hashCode() (an int switch),
then guards each matched bucket with an .equals() check to handle hash
collisions. So it is null-sensitive and case-sensitive.
switch (cmd) {
case "start" -> run();
case "stop" -> halt();
default -> usage();
}
// a null cmd throws NullPointerException — switch calls cmd.hashCode()
Because matching is by equals, the labels are exact and case-sensitive;
normalize with toLowerCase() first if you need case-insensitive matching.
String.hashCode() uses the polynomial formula
s[0]*31^(n-1) + s[1]*31^(n-2) + ... + s[n-1], accumulated in an int.
Because the string is immutable, the result is computed once and
stored in a private int hash field, then returned on every later call.
"Aa".hashCode() // 2112
"BB".hashCode() // 2112 — famous collision (same hash, not equal)
The constant 31 is an odd prime that the JVM can optimize to
(h << 5) - h. Caching is safe precisely because the contents can never
change — one of the concrete payoffs of immutability and why String is the
go-to HashMap key.
Classic options are an indexed loop with charAt, or toCharArray() with a
for-each. Java 8 added chars(), an IntStream of code units, for a
functional style.
String s = "abc";
for (int i = 0; i < s.length(); i++) { // indexed
char c = s.charAt(i);
}
for (char c : s.toCharArray()) { } // for-each over a copy
long vowels = s.chars() // IntStream of code units
.filter(c -> "aeiou".indexOf(c) >= 0)
.count();
chars() yields int code units; for full Unicode (emoji / supplementary
characters) use codePoints() instead, which combines surrogate pairs.
More Fundamentals interview questions
More ways to practice
The self-quiz is live. Get notified when mock interviews and new question packs drop.