Why this matters
- String concatenation inside a loop is the most common accidental performance bug in Java, and it is invisible in the source.
- The
==versus.equals()question has a specific answer for strings because of the pool, and that answer is asked constantly. - Strings are the most allocated type in almost every Java application, so the small decisions here add up.
Immutability, and why
String name = "java";
name.toUpperCase(); // result discarded — `name` is unchanged
System.out.println(name); // java
name = name.toUpperCase(); // reassignment is how you "change" a string
System.out.println(name); // JAVA
Internally a String wraps a private final byte array with no method that writes to it. The
immutability is a deliberate design choice with four payoffs.
- Safe to share. Many references to one string cannot interfere, so the pool is possible at all.
- Thread-safe with no locking. No state changes, so no synchronisation is ever needed.
- Reliable as a map key. The hash code cannot drift after insertion, and
Stringcaches it after first use. - Secure. A file path or connection string passed to a library cannot be altered behind your back after validation.
The string pool
String literals are interned: the JVM keeps one copy in a pool and hands out the same reference for identical literals.
String a = "java"; // created in the pool
String b = "java"; // same pooled object
String c = new String("java"); // explicitly a new heap object
String d = c.intern(); // the pooled instance
System.out.println(a == b); // true — one object
System.out.println(a == c); // false — different objects
System.out.println(a.equals(c)); // true — same characters
System.out.println(a == d); // true — intern() returns the pooled one
Compile-time constant expressions are folded and pooled; anything computed at run time is not.
String one = "ja" + "va"; // folded at compile time → pooled
System.out.println(one == "java"); // true
String part = "ja";
String two = part + "va"; // computed at run time → new object
System.out.println(two == "java"); // false
final String constant = "ja"; // a compile-time constant
String three = constant + "va"; // folded → pooled
System.out.println(three == "java"); // true
Since Java 7 the pool lives in the heap rather than PermGen, so unreferenced pooled strings can be garbage collected like anything else.
Concatenation and the loop trap
// Quadratic: each + builds a whole new string
String result = "";
for (int i = 0; i < 10_000; i++) {
result += i; // ~10,000 allocations, each longer than the last
}
// Linear: one growable buffer
StringBuilder builder = new StringBuilder();
for (int i = 0; i < 10_000; i++) {
builder.append(i);
}
String result2 = builder.toString();
The compiler does optimise a single + expression into a StringBuilder behind the scenes. What it
cannot do is hoist that builder out of a loop, because each iteration is a separate statement. So
the loop allocates a new builder, a new char array and a new string on every pass.
StringBuilder and StringBuffer
| Aspect | StringBuilder | StringBuffer |
|---|---|---|
| Thread-safe | No | Yes — every method synchronized |
| Speed | Faster | Slower, from lock overhead |
| Added in | Java 5 | Java 1.0 |
| Use when | Always, in practice | Essentially never |
Thread-safe
StringBuilderNoStringBufferYes — every method synchronizedSpeed
StringBuilderFasterStringBufferSlower, from lock overheadAdded in
StringBuilderJava 5StringBufferJava 1.0Use when
StringBuilderAlways, in practiceStringBufferEssentially never
A builder shared across threads is a design problem, not a case for StringBuffer. Build locally and publish an immutable String.
StringBuilder builder = new StringBuilder("Core");
builder.append(" Java") // these all return `this`, so they chain
.insert(0, ">> ")
.reverse()
.reverse()
.replace(0, 3, "** ");
System.out.println(builder); // ** Core Java
System.out.println(builder.length());
Giving the builder an expected capacity up front — new StringBuilder(256) — avoids intermediate
array copies when you already know roughly how large the result will be.
Comparison
String left = "Java", right = "java";
left.equals(right); // false — case-sensitive content comparison
left.equalsIgnoreCase(right); // true
left.compareTo(right); // negative — lexicographic ordering
left == right; // reference identity — almost never what you want
// Null-safe ordering and comparison
Objects.equals(left, right); // handles either side being null
"Java".equals(possiblyNull); // literal first: cannot NPE
Methods worth knowing
String text = " Core Java Programming ";
text.strip(); // trims Unicode whitespace — prefer over trim()
text.isBlank(); // true if empty or whitespace only
text.length(); // includes the surrounding spaces
"a,b,,c".split(","); // [a, b, , c] — trailing empties dropped
String.join(" / ", "x", "y", "z"); // x / y / z
"ab".repeat(3); // ababab
"Core Java".chars().count(); // stream of code points
"Hello %s, you are %d".formatted("Ana", 30); // instance-style formatting, Java 15+
String.format("%,.2f", 1234.5); // 1,234.50
"""
Multi-line text keeps its
shape without escapes.
""".lines().forEach(System.out::println); // text block, Java 15+
Small distinctions that matter
strip()understands Unicode whitespace;trim()only removes characters up toU+0020. Preferstrip().isEmpty()is true only for length zero;isBlank()is also true for" ".splittakes a regular expression, so splitting on a dot needssplit("\\.").substring(begin, end)excludesend, andsubstring(begin)runs to the end.indexOfreturns-1when not found, never throws.
Common misreadings
- "Strings are immutable, so concatenation is fine." Immutability is exactly why each
+allocates. In a loop that is the problem. - "
==compares string content." It compares references. The pool makes it work for literals, which is what makes the bug sneaky. - "
intern()is a performance tool." It was sometimes used to save memory on heavy duplication, but modern JVMs and deduplicating collectors make it rarely worthwhile. - "
StringBufferis the safe choice." Its synchronisation protects a builder that should not be shared in the first place. - "
splittakes a plain delimiter." It takes a regex.split(".")returns an empty array. - "
length()counts characters as a user would." It counts UTF-16 units, so emoji and some scripts count as two. UsecodePointCountwhen that matters.
Quick recall
Everything you need if you only revisit this box.
Stringis immutable; every "modifying" method returns a new object. That is what makes it poolable, thread-safe and valid as a map key.- The pool gives one instance per literal, so
==works for literals and fails for computed strings. Always use.equals(). new String("x")forces a duplicate — avoid it.intern()returns the pooled instance.- Compile-time constant concatenation is folded and pooled; run-time concatenation is not.
+in a loop is quadratic. UseStringBuilderthere, and plain+for a few fixed parts.- Prefer
StringBuilderoverStringBufferalways; build locally and publish an immutableString. strip()overtrim(),isBlank()overisEmpty(), and remembersplittakes a regex.
Test yourself
Answer these before moving on — recall is what makes it stick.