PrepZone Logo
PrepZone

Strings, Builders and the Pool

Why String is immutable, what the pool actually stores, and when to reach for StringBuilder.

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.
Binary data
InputStream / OutputStreamImages, PDFs, audio
BufferedInputStreamReads in blocks
Text data
Reader / WriterHandles character encoding
BufferedReaderGives you readLine()
Pick the family by data type, then wrap a buffer around it. Buffering is what turns thousands of tiny system calls into a handful of big ones.

Immutability, and why

Java
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 String caches 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.

Java
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.

Java
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

Java
// 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

AspectStringBuilderStringBuffer
Thread-safeNoYes — every method synchronized
SpeedFasterSlower, from lock overhead
Added inJava 5Java 1.0
Use whenAlways, in practiceEssentially never
  • Thread-safe

    StringBuilderNo
    StringBufferYes — every method synchronized
  • Speed

    StringBuilderFaster
    StringBufferSlower, from lock overhead
  • Added in

    StringBuilderJava 5
    StringBufferJava 1.0
  • Use when

    StringBuilderAlways, in practice
    StringBufferEssentially never

A builder shared across threads is a design problem, not a case for StringBuffer. Build locally and publish an immutable String.

Java
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

Java
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

Java
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 to U+0020. Prefer strip().
  • isEmpty() is true only for length zero; isBlank() is also true for " ".
  • split takes a regular expression, so splitting on a dot needs split("\\.").
  • substring(begin, end) excludes end, and substring(begin) runs to the end.
  • indexOf returns -1 when 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.
  • "StringBuffer is the safe choice." Its synchronisation protects a builder that should not be shared in the first place.
  • "split takes 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. Use codePointCount when that matters.

Quick recall

Everything you need if you only revisit this box.

  • String is 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. Use StringBuilder there, and plain + for a few fixed parts.
  • Prefer StringBuilder over StringBuffer always; build locally and publish an immutable String.
  • strip() over trim(), isBlank() over isEmpty(), and remember split takes a regex.

Test yourself

Answer these before moving on — recall is what makes it stick.