Strings: Immutability, StringBuilder & the String Pool

Why do Strings behave so oddly?

If you come to Java from Python or JavaScript, String looks familiar: quotes, concatenation with +, methods like length() and substring(). Then something strange happens. You compare two strings that print identically and == says they are different. You "change" a string and the original is still there. You concatenate in a loop and your program crawls.

All three mysteries have the same root cause: String is immutable, and the runtime keeps a special cache of strings called the string pool. This post explains both, shows where they bite in real code, and gives you the one decision rule that settles every "which string thing do I use?" question.

Assumed from Equality & Identity: you know the difference between == (same object reference) and equals() (same logical value). Strings are where that distinction bites hardest.

Strings are immutable — really, truly

Once a String object exists, its contents can never change. Every "modifying" method — toUpperCase(), trim(), replace(), substring() — returns a new String and leaves the original untouched:

String name = "devendra";
name.toUpperCase();            // result is discarded!
System.out.println(name);      // still "devendra"

name = name.toUpperCase();     // reassign to keep the new object
System.out.println(name);      // "DEVENDRA"

The first toUpperCase() call created a perfectly good "DEVENDRA" object and nobody held onto it. This is the single most common string bug in beginner code: calling a method and forgetting the return value. If your string "isn't changing", check whether you assigned the result.

Note what immutability does not mean. A String variable is still reassignable — name = name.toUpperCase() points the variable at a new object. And the variable can be shared freely, because no one can mutate the object it points to.

Why immutable? Four reasons that pay rent every day

Immutability is not a quirk; it is the load-bearing design choice behind four guarantees the JDK depends on:

1. The string pool can share instances safely

The JVM caches string literals in a shared pool. If two pieces of code both write "config", they get the same object, which saves memory across the whole application. That sharing is only safe because nobody can mutate the shared object — imagine one thread renaming a pool entry that another thread is using as a filename. We will look at the pool in detail below.

2. Security: class names and file paths can't be tampered with

Think about what happens when Java loads a class. You call Class.forName("com.bank.SecureLogin"). Between the security check ("is this class allowed?") and the actual load, there is a gap in time. If strings were mutable, malicious code could change the string after the check passed — swapping the approved class name for a different one. Immutability closes that hole: once checked, the string is what it is. The same reasoning protects file paths, hostnames, and URLs that security checks inspect.

3. Thread safety comes free

An immutable object is inherently thread-safe: with no way to mutate it, there is no state for two threads to fight over, no lock needed, no race condition possible. Strings get passed between threads constantly — log messages, request parameters, map keys — and none of that code has to synchronize around them.

4. hashCode caching makes HashMaps fast

Strings are the most common HashMap keys in existence. Computing a hash over the characters every time would be expensive, so String computes its hashCode() once and caches it. That cache is only valid because the characters can never change — a mutable string would have to recompute (or, worse, silently return a stale hash and corrupt the map).

== vs equals(): the interning trap

Now the trap. Because literals live in the shared string pool, this works:

String a = "hello";
String b = "hello";
System.out.println(a == b);      // true — same pooled object
System.out.println(a.equals(b)); // true

And this is where it falls apart:

String c = new String("hello");          // forces a NEW object
String d = "hel" + "lo";                 // compile-time constant, still pooled
String prefix = "hel";
String e = prefix + "lo";                // built at RUNTIME

System.out.println(a == c); // false — c is a separate object
System.out.println(a == d); // true  — compiler folded it into a literal
System.out.println(a == e); // false — runtime-built string is NOT pooled

Every one of those prints true with equals(). That is the rule, stated plainly:

Always compare strings with equals(). Use == only when you deliberately mean "the exact same object", which is almost never what you want for text.

The trap is that == sometimes works — literals in the same file usually pass — so the bug hides in testing and explodes in production, where strings arrive from user input, databases, network responses, and files. None of those are pooled automatically.

The string pool and intern()

The pool is a JVM-managed cache of string literals (plus anything you explicitly intern). Literals and compile-time constant expressions land there automatically. You can force a string into the pool with intern(), which returns the pooled instance — but you almost never should: modern JVMs pool efficiently on their own, and manual interning is a niche memory optimization (large sets of repeated strings, like tokens from a parser), not everyday tooling. Know it exists so you understand why == sometimes works; reach for it only when a profiler tells you string memory is actually a problem.

Concatenation in a loop is a garbage factory

Here is the performance side of immutability. Each + on strings produces a new String. For one or two joins that is fine — since Java 9 the compiler turns simple + chains into efficient bytecode. But in a loop, you create one throwaway object per iteration:

// BEFORE: looks innocent, scales terribly
String result = "";
for (String word : words) {
    result += word + " ";   // new String object EVERY iteration
}

With n words, that loop creates roughly n intermediate strings and copies their characters each time — the classic quadratic slowdown. Building a 100,000-word string this way can take seconds and fill the heap with garbage. The fix is StringBuilder, a mutable buffer designed for exactly this:

// AFTER: one buffer, no throwaway objects
StringBuilder sb = new StringBuilder();
for (String word : words) {
    sb.append(word).append(' ');
}
String result = sb.toString();

Same result, linear time, one final String. (And if you are joining with a delimiter, skip the loop entirely — String.join(" ", words) does it in one call.)

StringBuilder vs StringBuffer: one is history

You will meet both classes; here is the whole story. StringBuffer is the 1996 original from Java 1.0. Every one of its methods is synchronized, which was meant to make it thread-safe. StringBuilder arrived in Java 5 (2004) as the unsynchronized twin — same API, no locking overhead.

In practice you almost never want StringBuffer. A string builder is almost always a local buffer used by one thread to assemble one result — there is nothing to synchronize. If you genuinely share a buffer across threads (exceedingly rare), synchronize it yourself at a higher level rather than paying synchronized on every append. Rule of thumb: default to StringBuilder; StringBuffer is legacy you read, not code you write.

Modern ways to build strings

Java 15+ gives you three tools that make most StringBuilder uses unnecessary for simple cases:

String.join — the delimiter loop, done

String csv = String.join(",", "dev", "sre", "data");
System.out.println(csv);   // dev,sre,data

List<String> tags = List.of("java", "backend", "2026");
System.out.println(String.join(" | ", tags));  // java | backend | 2026

String.formatted() — readable templates

String name = "Anaya";
int inbox = 5;
String msg = "Hi %s, you have %d unread messages.".formatted(name, inbox);
System.out.println(msg);   // Hi Anaya, you have 5 unread messages.

formatted() is String.format() as an instance method — same %s/%d placeholders, less ceremony. Prefer it over + chains when a string has more than a couple of moving parts.

Text blocks — multiline strings without the pain

String json = """
    {
      "name": "Anaya",
      "role": "FDE",
      "skills": ["java", "sql"]
    }
    """;
System.out.println(json);

Output:

{"name": "Anaya", "role": "FDE", "skills": ["java", "sql"]}

Text blocks (triple quotes, since Java 15) are the default for SQL queries, JSON payloads, and HTML templates — no more "..." + "..." + "..." chains or escaped newlines. The compiler strips the common leading whitespace, so indent the block naturally and the content stays clean. Note the closing """ on its own line: that is what keeps the trailing newline out.

The methods tour: the dozen you will actually use

String has a big API. Here are the ones that show up in real code, with the gotchas:

  • length() — character count. (A method, not a field — unlike arrays' .length.)
  • charAt(i) — the character at index i, zero-based. Throws StringIndexOutOfBoundsException for bad indexes — bounds-check when the index comes from outside.
  • substring(begin, end) — characters from begin (inclusive) to end (exclusive). "hello".substring(1, 4) is "ell". Off-by-one errors live here; remember end is exclusive.
  • split(regex) — splits on a regular expression, not a plain string. "a.b".split(".") does NOT split on the dot — . in regex means "any character". Escape it: "a.b".split("\\."). This is the most common split bug in existence.
  • strip() vs trim() — both remove surrounding whitespace, but trim() (the old one) only removes characters up to U+0020, while strip() (Java 11) understands all Unicode whitespace. Prefer strip() in new code.
  • toLowerCase() / toUpperCase() — take a Locale when the result matters beyond display. The one-line warning: in Turkish, "TITLE".toLowerCase() produces a dotless ı, not i — use toLowerCase(Locale.ROOT) for programmatic comparisons.
  • startsWith() / endsWith() — exactly what they say; great for file extensions and URL routing.
  • isEmpty() vs isBlank() — isEmpty() is true only for ""; isBlank() (Java 11) is true for "" and for strings that are all whitespace, like " ". For validating user input, isBlank() is almost always the one you want.
  • contains(), indexOf(), replace() — the bread-and-butter search trio; replace(CharSequence, CharSequence) does literal replacement (unlike replaceAll, which takes a regex — another classic mix-up).
String raw = "   Devendra@Example.COM   ";
String email = raw.strip().toLowerCase(java.util.Locale.ROOT);
System.out.println("[" + email + "]");  // [devendra@example.com]
System.out.println(email.endsWith("@example.com")); // true
System.out.println("   ".isBlank());    // true
System.out.println("   ".isEmpty());    // false

The decision rule

Every string choice in Java collapses to one rule:

String is for values. StringBuilder is for building. Hold text in a String; assemble text in a StringBuilder and call toString() at the end.

Concretely:

  • Comparing, passing, storing, returning, or using text as a key? String — and compare with equals().
  • Appending in a loop, or assembling more than a couple of pieces? StringBuilder (local variable, one thread).
  • Joining with a delimiter? String.join() before you write a loop.
  • Formatting a template? String.formatted().
  • Multiline SQL/JSON/HTML? Text blocks.
  • StringBuffer? Read it in old code; do not write it in new code.

Immutability plus the pool is why Java strings are safe to share everywhere — as map keys, across threads, through security checks — and why the two classic mistakes are comparing with == and concatenating in a loop. Now you know the machinery, so the mistakes look obvious before you make them.

Continue: Java Learning Roadmap 2026

Comments

Popular posts from this blog

JSP Servlet Interview Questions For Freshers Series 1

Java Banking Finance Services and Insurance (BFSI) domain interview questions

Java program to check even or odd number