Java strings, immutability and the string pool
Why String is immutable, where the string pool actually lives, and why building a string with += inside a loop is a real performance bug.
Strings — immutability, the string pool, StringBuilder, why += in a loop is fatal
Threads vs processes · user-level vs kernel-level threads · when each wins
Day 025 covered where strings live. Day 024 showed you the bytecode. Today is why immutability was chosen, and everything that follows from it.
Part 1 — L-029 · Strings
Immutability
A String's contents can never change after construction. Every method that appears to modify
one returns a new object.
String s = "hello";
s.toUpperCase(); // returns "HELLO" — and you threw it away
System.out.println(s); // "hello" — unchanged
s = s.toUpperCase(); // reassigns the REFERENCE to a new object
The variable can be reassigned; the object cannot be mutated. That distinction is the whole topic, and confusing the two is the source of most String bugs.
Why immutable — five reasons, and you should be able to give all five
1. The string pool depends on it
Day 025's pool only works because sharing is safe. If one holder could mutate "hello", every other
holder of the same pooled instance would see the change. Immutability is what makes interning
possible.
2. Thread safety, for free
An immutable object cannot be in an inconsistent state, so no synchronisation is ever needed. Strings pass between threads with zero coordination. This becomes a much bigger deal on Day 065.
3. Hash code caching
private int hash; // cached inside String
public int hashCode() {
int h = hash;
if (h == 0 && !hashIsZero) { h = ...compute...; hash = h; }
return h;
}
Because the contents can't change, the hash can be computed once and reused forever.
This is why String is the ideal HashMap key — the hash is computed on first use and every
subsequent lookup is free. Day 048.
4. Security
void connect(String url) {
checkPermission(url); // validate
doConnect(url); // use
}
If strings were mutable, another thread could change url between the check and the use — a
time-of-check-to-time-of-use (TOCTOU) vulnerability. Class names, file paths, URLs and connection
strings are all passed as Strings across trust boundaries; immutability closes that hole.
5. Safe to share
No defensive copying needed when returning a String from a getter — a genuine problem for mutable types, which is Day 034A.
The cost: every "modification" allocates. Which brings us to the loop.
Why += in a loop is fatal
String result = "";
for (int i = 0; i < n; i++) {
result += "x"; // 💀
}
What actually happens — you saw this in bytecode on Day 024:
iteration 1: allocate a 1-char string, copy 0 chars
iteration 2: allocate a 2-char string, copy 1 char
iteration 3: allocate a 3-char string, copy 2 chars
...
iteration n: allocate an n-char string, copy n-1 chars
Total characters copied: 0 + 1 + 2 + … + (n−1) = n²/2.
| n | Work | Real time (roughly) |
|---|---|---|
| 1,000 | 500,000 char copies | ~2 ms |
| 10,000 | 50,000,000 | ~200 ms |
| 100,000 | 5,000,000,000 | ~20 seconds |
Plus n garbage objects, all of increasing size — heavy GC pressure (Day 026).
StringBuilder sb = new StringBuilder();
for (int i = 0; i < n; i++) sb.append("x");
String result = sb.toString();
One buffer, doubled when full. O(n) amortised. 100,000 appends takes milliseconds.
The nuance interviewers probe
String s = "a" + "b" + "c"; // fine — constant-folded at COMPILE time (Day 025)
String s = a + b + c; // fine — ONE concatenation, compiler handles it
for (...) { s += x; } // 💀 fatal — a NEW concatenation each iteration
Concatenation outside a loop is fine. The compiler emits a single invokedynamic
(or one StringBuilder on Java 8). It's only fatal when the allocation is inside the loop.
Saying "never use + on strings" is wrong and marks you as someone repeating a rule. Say
"never accumulate with += in a loop".
StringBuilder vs StringBuffer
StringBuilder |
StringBuffer |
|
|---|---|---|
| Thread-safe | ❌ | ✅ (every method synchronized) |
| Speed | Faster | Slower |
| Since | Java 5 | Java 1.0 |
Always use StringBuilder. StringBuffer synchronises every method, which is pointless — a
builder is almost always a local variable inside one method (Day 025: locals are per-thread and
inherently thread-safe). Its synchronisation is pure overhead for a scenario that essentially never
occurs.
Pre-size it if you know the length: new StringBuilder(1024) avoids the doubling reallocations.
equals vs == — again
String a = "hello";
String b = new String("hello");
a == b // false — different objects
a.equals(b) // true
Same lesson as Day 025 and Day 028. == on any object compares references.
Useful variants:
a.equalsIgnoreCase(b)
"constant".equals(userInput) // ✅ null-safe: won't NPE if userInput is null
Objects.equals(a, b) // ✅ null-safe both ways
a.compareTo(b) // lexicographic ordering, for sorting
Yoda-style "constant".equals(x) is a genuine defensive idiom, not just style — it eliminates a
whole class of NPE.
Useful methods and their traps
s.length() // number of CHARS, not code points — see below
s.charAt(i)
s.substring(2, 5) // [2, 5) — end exclusive
s.indexOf("x") // -1 if absent
s.contains("x")
s.split(",") // takes a REGEX, not a literal — "." splits on everything!
s.replace("a", "b") // literal
s.replaceAll("a+", "b") // regex
s.trim() // whitespace only
s.strip() // Unicode-aware — prefer this (Java 11+)
s.isEmpty() // length == 0
s.isBlank() // empty or whitespace only (Java 11+)
String.join(", ", list)
"x".repeat(5) // Java 11+
s.chars() // IntStream of code units
split takes a regex. "a.b.c".split(".") returns an empty array, because . matches
everything. Use split("\\.") or Pattern.quote("."). This catches people constantly.
Unicode — the trap in length()
String emoji = "👨👩👧";
emoji.length() // NOT 1 — it's several UTF-16 code units
emoji.codePointCount(0, emoji.length()) // closer to what a human means
Java strings are UTF-16 code unit sequences. Characters outside the Basic Multilingual Plane —
emoji, some scripts — occupy two code units (a surrogate pair). So length() is not "number of
characters a person would count", and charAt can return half a character.
Where this bites: validating a username length, truncating text for a preview, or reversing a string. Truncating mid-surrogate produces mojibake.
(Since Java 9, compact strings store Latin-1 as one byte per char internally — an implementation detail that halves memory for most Western text, invisible to your code.)
Text blocks (Java 15+)
String json = """
{
"name": "Ramesh",
"role": "SDE"
}
""";
Incidental leading whitespace is stripped based on the closing delimiter's indentation. Use these for SQL, JSON and HTML instead of concatenated escaped strings.
Type this yourself
public class StringDemo {
public static void main(String[] args) {
// ---- 1. Immutability ----
String s = "hello";
s.toUpperCase();
System.out.println("After toUpperCase(): " + s); // still "hello"
// ---- 2. The quadratic disaster ----
int n = 50_000;
long start = System.currentTimeMillis();
String bad = "";
for (int i = 0; i < n; i++) bad += "x";
long badTime = System.currentTimeMillis() - start;
start = System.currentTimeMillis();
StringBuilder sb = new StringBuilder();
for (int i = 0; i < n; i++) sb.append("x");
String good = sb.toString();
long goodTime = System.currentTimeMillis() - start;
System.out.printf("%n+= in loop: %,5d ms%n", badTime);
System.out.printf("StringBuilder: %,5d ms%n", goodTime);
System.out.printf("Ratio: %.0f×%n", (double) badTime / Math.max(goodTime, 1));
System.out.println("Same result: " + bad.equals(good));
// Now try n = 100_000 and watch badTime roughly QUADRUPLE. That's O(n²).
// ---- 3. Hash caching ----
String key = "a-fairly-long-key-for-timing-purposes";
key.hashCode(); // first call computes
start = System.nanoTime();
for (int i = 0; i < 10_000_000; i++) key.hashCode(); // cached — near free
System.out.printf("%n10M cached hashCode(): %,d ms%n",
(System.nanoTime() - start) / 1_000_000);
// ---- 4. split takes a REGEX ----
System.out.println("\nsplit(\".\") → " + "a.b.c".split(".").length + " parts (!)");
System.out.println("split(\"\\\\.\") → " + "a.b.c".split("\\.").length + " parts");
// ---- 5. Unicode ----
String emoji = "😀";
System.out.println("\n'😀'.length() = " + emoji.length());
System.out.println("codePointCount = " + emoji.codePointCount(0, emoji.length()));
// ---- 6. Null-safe comparison ----
String maybeNull = null;
System.out.println("\n\"x\".equals(null) = " + "x".equals(maybeNull)); // false, no NPE
System.out.println("Objects.equals = " + java.util.Objects.equals("x", maybeNull));
}
}
Two things to do:
- Run it with
n = 50_000, thenn = 100_000. TheStringBuildertime roughly doubles; the+=time roughly quadruples. That's O(n) vs O(n²), measured by you. - Decompile the loop (
javap -c StringDemo) and point at the concatenation instruction inside the loop body. Day 024's skill, closing the loop.
Interview questions
Q: Why is String immutable in Java?
Five reasons. The string pool depends on it, since sharing instances is only safe if nobody can mutate them. It gives thread safety with no synchronisation. It allows the hash code to be computed once and cached, which is why String is the ideal HashMap key. It prevents time-of-check-to-time-of-use attacks where a validated URL or path could be mutated before use. And it means Strings can be returned from getters without defensive copying.
Q: Why is += in a loop bad, and when is + fine?
Each concatenation allocates a new String and copies all existing characters, so accumulating in a loop is quadratic and produces n garbage objects. Outside a loop it's fine — the compiler emits a single concatenation for an expression like
a + b + c. The problem is specifically allocation inside the loop body, which a singleStringBuilderhoisted outside eliminates.
Q: StringBuilder or StringBuffer?
StringBuilder, essentially always.StringBuffersynchronises every method, which is overhead for a scenario that rarely exists — a builder is almost always a local variable, and locals are already per-thread.
Q: Why is String a good HashMap key?
It's immutable, so its hash code can't change after insertion, which would otherwise leave the entry in the wrong bucket and make it unreachable. And because it's immutable, the hash is computed once and cached, making repeated lookups very cheap.
Q: What does "a.b.c".split(".") return, and why?
An empty array.
splittakes a regular expression, and.matches any character, so every part is empty and trailing empties are discarded. You needsplit("\\.").
Q: Does String.length() return the number of characters?
It returns the number of UTF-16 code units. Characters outside the Basic Multilingual Plane, such as emoji, use surrogate pairs and count as two, so
length()can exceed what a person would count.codePointCountis closer to the human notion.
Part 2 — B-03 · Threads vs processes
The comparison
Day 003 introduced both. Now the engineering tradeoff.
| Process | Thread | |
|---|---|---|
| Address space | Private, MMU-enforced | Shared with its process |
| Owns | Everything | Stack, registers, PC (Day 025) |
| Creation cost | ~1 ms (fork + page tables) | ~50 µs |
| Memory cost | Full address space | ~1 MB stack |
| Context switch | Expensive — page table / TLB flush | Cheaper — same address space |
| Communication | IPC: pipes, sockets, shared memory | Just read the same variable |
| Isolation | Crash affects only itself | A crash can kill the whole process |
The two facts that drive every design decision:
Threads share the heap. Processes don't. Context switching between threads is cheaper because the address space doesn't change.
That second point is Day 033's B-04 in one line: switching processes invalidates the TLB, so the new process starts with cold address translations.
User-level vs kernel-level threads
| Kernel-level (1:1) | User-level (N:1 / M:N) | |
|---|---|---|
| Known to the OS scheduler | ✅ | ❌ — the kernel sees one thread |
| Blocking call | Blocks only that thread | Blocks ALL of them (in N:1) |
| Multi-core parallelism | ✅ | ❌ in N:1 |
| Creation cost | Syscall — expensive | Just a function call — cheap |
| Examples | Java platform threads, pthreads | Go goroutines, Java virtual threads, Kotlin coroutines |
Java's platform threads are 1:1 kernel threads. One Thread object equals one OS thread. That's
why they cost ~1 MB and why you pool them (Day 069).
Virtual threads — the resolution
Java 21's virtual threads (Day 072) are M:N: many virtual threads multiplexed onto few carrier kernel threads.
Platform threads (1:1) Virtual threads (M:N)
──────────────────── ─────────────────────
10,000 tasks 10,000 tasks
= 10,000 OS threads = 10,000 virtual threads
= ~10 GB of stacks on ~8 carrier threads
= dead = ~few hundred MB, fine
The trick: when a virtual thread blocks on I/O, the JVM unmounts it from its carrier thread and mounts another. The blocking call looks synchronous in your code but doesn't hold an OS thread.
This is the same benefit as the event loop (Day 014, Day 016 C-11) — high concurrency without a thread per task — but with blocking-style code you can read. That's the whole point of Project Loom: the ergonomics of thread-per-request with the cost profile of async.
The historical caveat: blocking inside a synchronized block pins the virtual thread to its
carrier, defeating the mechanism. Use ReentrantLock (Day 067) instead. (This pinning was largely
addressed in JDK 24, but knowing the issue existed shows you understand the mechanism.)
When each wins
| Use | Choose |
|---|---|
| Isolation matters; a crash must not spread | Processes — browser tabs, container workloads |
| Shared data, frequent communication | Threads |
| CPU-bound parallel work | Threads, ~one per core |
| Massive concurrent I/O | Virtual threads or an event loop |
| Untrusted code | Processes — only real isolation |
Type this yourself
public class ThreadCost {
public static void main(String[] args) throws Exception {
int n = 10_000;
// Platform threads — 1:1 with OS threads
long start = System.currentTimeMillis();
Thread[] platform = new Thread[n];
for (int i = 0; i < n; i++) {
platform[i] = new Thread(() -> {
try { Thread.sleep(100); } catch (InterruptedException ignored) {}
});
platform[i].start();
}
for (Thread t : platform) t.join();
System.out.printf("%,d platform threads: %,d ms%n", n, System.currentTimeMillis() - start);
// Virtual threads — M:N (Java 21+)
start = System.currentTimeMillis();
Thread[] virtual = new Thread[n];
for (int i = 0; i < n; i++) {
virtual[i] = Thread.ofVirtual().start(() -> {
try { Thread.sleep(100); } catch (InterruptedException ignored) {}
});
}
for (Thread t : virtual) t.join();
System.out.printf("%,d virtual threads: %,d ms%n", n, System.currentTimeMillis() - start);
}
}
Run with -Xmx512m. Try raising n to 100,000 — platform threads will typically fail with
OutOfMemoryError: unable to create new native thread (Day 027's table) while virtual threads
handle it comfortably. That failure message is the 1 MB stack cost, made visible.
Interview questions
Q: Process vs thread?
A process has its own private address space enforced by hardware; a thread lives inside a process and shares its heap, having only its own stack, registers and program counter. Threads are much cheaper to create and switch between because the address space doesn't change, but they share memory, which is why they need synchronisation and why one thread's crash can take down the process.
Q: User-level vs kernel-level threads?
Kernel threads are scheduled by the OS, so they achieve real parallelism and a blocking call only blocks that thread, but each costs a syscall to create and around a megabyte of stack. User-level threads are managed in userspace and are very cheap, but in a pure N:1 model a blocking call stalls all of them. Java's platform threads are 1:1 kernel threads; virtual threads are an M:N scheme.
Q: What problem do virtual threads solve?
Thread-per-request is the most readable concurrency model but doesn't scale, because each request pins an OS thread costing a megabyte. Virtual threads multiplex many lightweight threads onto few carrier threads, unmounting on blocking I/O — so you get the scalability of an event loop while writing ordinary blocking code.
Mini task
- Run
StringDemowith n = 50,000 then 100,000. Record all four numbers and confirm the quadratic growth. - Decompile the loop and point at the per-iteration allocation.
- Write a method that builds a 10,000-row CSV. Do it wrong, then right. Time both.
- Prove the
split(".")trap and fix it. - Run
ThreadCostand pushnuntil platform threads fail. Record the failure message.
Exit questions
- What does immutable mean for a String, and what's the difference from the variable?
- Give all five reasons String is immutable.
- Why is
+=in a loop O(n²)? When is+perfectly fine? StringBuilderorStringBuffer, and why?- Why is String the ideal HashMap key? Name two properties.
- What does
"a.b.c".split(".")return and why? - Why isn't
length()the number of characters? - Threads vs processes — name the two facts that drive every design decision.
- What are virtual threads, and what problem do they solve?
Articulation drill
Two minutes: "Why is String immutable, and what problems does that create?"
Five benefits, then the allocation cost, then the loop trap and its fix. Benefits and costs is what makes it sound like experience rather than recall.
Previous: Day 028 · Tomorrow: Day 030 — operators, control flow,
switch expressions, var, text blocks