Skip to content
Path to Engineer
All lessons
JavaJVM19 min read

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.

Day 29 of the 488-day pathway. Published in full — nothing held back.

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:

  1. Run it with n = 50_000, then n = 100_000. The StringBuilder time roughly doubles; the += time roughly quadruples. That's O(n) vs O(n²), measured by you.
  2. 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 single StringBuilder hoisted outside eliminates.

Q: StringBuilder or StringBuffer?

StringBuilder, essentially always. StringBuffer synchronises 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. split takes a regular expression, and . matches any character, so every part is empty and trailing empties are discarded. You need split("\\.").

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. codePointCount is 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

  1. Run StringDemo with n = 50,000 then 100,000. Record all four numbers and confirm the quadratic growth.
  2. Decompile the loop and point at the per-iteration allocation.
  3. Write a method that builds a 10,000-row CSV. Do it wrong, then right. Time both.
  4. Prove the split(".") trap and fix it.
  5. Run ThreadCost and push n until platform threads fail. Record the failure message.

Exit questions

  1. What does immutable mean for a String, and what's the difference from the variable?
  2. Give all five reasons String is immutable.
  3. Why is += in a loop O(n²)? When is + perfectly fine?
  4. StringBuilder or StringBuffer, and why?
  5. Why is String the ideal HashMap key? Name two properties.
  6. What does "a.b.c".split(".") return and why?
  7. Why isn't length() the number of characters?
  8. Threads vs processes — name the two facts that drive every design decision.
  9. 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