Skip to content
Ameen Abozaid
PublishedBEG-015-L05

Immutability and StringBuilder

  • Lesson 5 of 8
  • 11 min read
  • Beginner
On This Lesson 11 topics

Lesson Overview

Lesson 4 ended with a rule you may have found odd: s.trim(); on its own line changes nothing. This lesson explains why, and then handles the practical consequence: what to do when you must build a long piece of text from many small parts.

The module picture is a printed sheet that cannot be erased. A String is such a sheet. To "change" it you print a new sheet, and the old one stays as it was. That is perfectly fine for a few edits, but picture printing a fresh copy of a growing document after every single added word: you would spend all your time copying. For that job you need a notebook you can write in: the StringBuilder.

Why this matters: building text in a loop (a report, a CSV export, a log line, a JSON answer) is one of the most common backend tasks, and doing it the naive way is a classic cause of slow code.

This lesson builds on Lesson 4 and on += for Strings (BEG-010).

Learning Objectives

By the end of this lesson you will be able to:

  • State what it means for a String to be immutable.
  • Explain why s.toUpperCase(); alone does not change s, and fix it.
  • Explain why s = s + x in a loop creates a lot of garbage and why it gets slow.
  • Create a StringBuilder and use append, insert, reverse, length, and toString.
  • Choose between + and StringBuilder for a given task.
  • Convert a StringBuilder back to a String and say which one a method should usually return.

Written Explanation

Immutable: never changes once created

An immutable object cannot be modified after it is created. A String is immutable: the Java API documentation states that Strings are constant and cannot be changed, and that methods which seem to modify one, such as trim and replace, return a new String.

Two things follow:

  1. A String method returns the changed text; it does not change the receiver. To keep the result, assign it.
java
String s = "java";
s.toUpperCase();          // result thrown away; s is still "java"
s = s.toUpperCase();      // s now points at the new String "JAVA"
  1. Assigning to a String variable moves the pointer; it does not edit the text. With a = a + "!" the old text is still there for anyone else who points at it:
java
String a = "hi";
String b = a;       // two pointers, one text
a = a + "!";        // a now points at a NEW text "hi!"; b still points at "hi"

Immutability is a feature. Because a String can never change under your feet, it is safe to share between parts of a program, to use as a lookup key, and to pass to any method without a defensive copy.

The cost of building text with +

Each + or += on Strings creates a new String that holds all the old characters plus the new ones. Doing it once costs nothing noticeable. Doing it in a loop with n steps copies a longer and longer String every time. The total number of characters copied grows roughly with the square of the final length: a result ten times longer costs about a hundred times more work. At 100,000 steps the difference between "instant" and "many seconds" is real, as the third example below shows.

StringBuilder: a notebook you can edit

StringBuilder is a class for a mutable sequence of characters: it keeps one internal buffer and adds to it in place, growing it automatically. The pattern is always the same:

java
StringBuilder sb = new StringBuilder();   // 1. create once, before the loop
for (int i = 1; i <= 5; i++) {
    sb.append(i).append(' ');             // 2. append inside the loop
}
String result = sb.toString();            // 3. convert once, after the loop

append accepts many types (numbers, characters, Strings) and returns the same builder, which is why calls can be chained: sb.append(a).append(b). toString() makes a String snapshot; later changes to the builder do not affect it.

A few more methods are handy:

MethodEffect
length()Number of characters so far
insert(pos, x)Adds x at position pos
reverse()Reverses the characters in place
setCharAt(i, c), deleteCharAt(i)Replace or remove one character
setLength(0)Empties the builder so it can be reused

Unlike a String, a builder can be changed, so passing it to a method lets that method change it, just like an array in Lesson 1.

When to use which

  • Use + for a few pieces in one statement: "Hello, " + name + "!". It is clearer.
  • Use StringBuilder when text is built in a loop or in many steps.
  • Convert to a String at the end. Public methods normally return String, not the builder.
  • StringBuilder is documented as not safe for use by several threads at once. StringBuffer is the synchronized sibling with a compatible API. For ordinary single-threaded code, StringBuilder is the one to use.

A note on compilers: modern Java compilers may turn a simple one-line + expression into efficient code on their own, so you do not need a builder for that case. That help does not extend to a += that repeats across loop iterations, which is the case you must write with a builder yourself.

Examples

Example 1: the ignored result and the builder pattern (simple)

java
public class Ex1 {
    public static void main(String[] args) {
        String s = "java";
        s.toUpperCase();
        System.out.println(s);
        s = s.toUpperCase();
        System.out.println(s);
        String a = "hi";
        String b = a;
        a = a + "!";
        System.out.println(a + " " + b);
        StringBuilder sb = new StringBuilder();
        for (int i = 1; i <= 5; i++) {
            sb.append(i).append(' ');
        }
        String result = sb.toString();
        System.out.println("[" + result + "]");
        System.out.println(result.length());
    }
}

Output (compiled and run with JDK 21):

text
java
JAVA
hi! hi
[1 2 3 4 5 ]
10

The first toUpperCase() result was thrown away. The second was assigned and kept. After a = a + "!", a and b differ because the text itself was never edited. The builder produced five numbers each followed by a space, so the String has a trailing space (the brackets show it).

Example 2: more builder methods (practical)

java
public class Ex2 {
    public static void main(String[] args) {
        StringBuilder sb = new StringBuilder();
        for (int i = 1; i <= 10; i++) {
            if (i > 1) {
                sb.append(',');
            }
            sb.append(i);
        }
        System.out.println(sb);
        System.out.println(sb.length());
        sb.insert(0, "[").append("]");
        System.out.println(sb.toString());
        sb.reverse();
        System.out.println(sb);
        sb.setLength(0);
        System.out.println("[" + sb + "]");
        StringBuilder w = new StringBuilder("hello");
        w.setCharAt(0, 'J');
        w.deleteCharAt(4);
        System.out.println(w);
    }
}

Output:

text
1,2,3,4,5,6,7,8,9,10
20
[1,2,3,4,5,6,7,8,9,10]
]01,9,8,7,6,5,4,3,2,1[
[]
Jell

Appending the comma before every item except the first is the standard way to avoid a trailing comma. Printing a builder directly calls its toString(). reverse reversed the whole text, including the digits of 10. setLength(0) emptied the builder, and the last lines edited "hello" in place to "Jell".

Example 3: the same job, two ways, timed (realistic)

java
public class Ex3 {
    static String joinWithPlus(int n) {
        String s = "";
        for (int i = 0; i < n; i++) {
            s += i + ",";
        }
        return s;
    }

    static String joinWithBuilder(int n) {
        StringBuilder sb = new StringBuilder();
        for (int i = 0; i < n; i++) {
            sb.append(i).append(',');
        }
        return sb.toString();
    }

    public static void main(String[] args) {
        int n = 100000;
        long t0 = System.nanoTime();
        String a = joinWithPlus(n);
        long t1 = System.nanoTime();
        String b = joinWithBuilder(n);
        long t2 = System.nanoTime();
        System.out.println(a.equals(b));
        System.out.println("plus ms: " + (t1 - t0) / 1_000_000);
        System.out.println("builder ms: " + (t2 - t1) / 1_000_000);
    }
}

Output from one run on the author's machine (timings vary a lot between machines and runs):

text
true
plus ms: 3684
builder ms: 9

Both methods produce exactly the same text, as equals confirms, but the += version took roughly four hundred times longer in this run. The exact numbers do not matter; the gap, and the fact that it grows quickly with n, do. A single timing like this is only a rough demonstration, not a proper benchmark.

Backend Insight

Servers build text constantly: log messages, SQL, HTML, JSON, CSV exports, error reports. A request that builds a large response with += in a loop wastes CPU and creates lots of short-lived garbage on every call. At one request it is invisible; at thousands per second it shows up as slow responses. The fix costs three lines.

The other half of the lesson matters just as much. Because Strings are immutable, they are safe to store in shared structures and to use as keys, and a method that receives one cannot damage it. That safety is why identifiers, names, and configuration values are almost always Strings, while a mutable StringBuilder is a local tool you create, fill, convert, and discard inside one method.

Common Mistakes

Mistake 1: Ignoring the returned String

  • Incorrect: name.toUpperCase();
  • Correct: name = name.toUpperCase();

Mistake 2: += on a String inside a big loop

It works, and it is correct, so it passes small tests, then becomes slow with real data. Use a builder when a loop appends.

Mistake 3: Creating the builder inside the loop

for (...) { StringBuilder sb = new StringBuilder(); sb.append(x); } makes a new empty builder on every pass, so the result is lost. Create it before the loop.

Mistake 4: Forgetting toString() and comparing builders

A StringBuilder is not a String. sb.equals("abc") is not a text comparison, and String x = sb; does not compile. Call sb.toString() first.

Mistake 5: Appending a separator after the last item

sb.append(i).append(',') for every item leaves a trailing comma. Add the separator before every item except the first, or remove the last character afterwards.

Exercises

Try each exercise yourself before opening the solutions.

Level A: Foundation

A1. What does this print? String t = "abc"; t.concat("def"); System.out.println(t); (concat returns the joined text, like +.)

Show solution A1

A1. It prints abc. concat returns a new String, which was ignored; t is unchanged.

A2. What does it mean that a String is immutable, in one sentence?

Show solution A2

A2. Once a String object exists its characters can never change; every "modification" produces a new String object.

A3. Which fits better: (a) "Hi " + name; (b) building a 10,000-line report line by line. Name the tool for each.

Show solution A3

A3. (a) The + operator. (b) StringBuilder, because the text is built in a loop.

Level B: Application

B1. Fix the bug: String name = " ana "; name.trim(); System.out.println("[" + name + "]"); so that it prints [ana].

Show solution B1

B1. name = name.trim(); on the second statement. The trimmed String must be assigned.

B2. Write static boolean isPalindrome(String s) that returns true when s reads the same forwards and backwards, using StringBuilder.reverse().

Show solution B2

B2.

java
static boolean isPalindrome(String s) {
    String reversed = new StringBuilder(s).reverse().toString();
    return s.equals(reversed);
}

The builder is created from s, reversed in place, and converted back. "level" gives true and "Level" gives false (case matters; use toLowerCase() first if you want to ignore it).

B3. Write static String removeSpaces(String s) that returns s without any space character, using a loop, charAt, and a builder.

Show solution B3

B3.

java
static String removeSpaces(String s) {
    StringBuilder sb = new StringBuilder();
    for (int i = 0; i < s.length(); i++) {
        char ch = s.charAt(i);
        if (ch != ' ') {
            sb.append(ch);
        }
    }
    return sb.toString();
}

Level C: Challenge

C1. Predict the output:

java
String x = "go";
String y = x;
x += "!";
y = y + "?";
StringBuilder p = new StringBuilder("go");
StringBuilder q = p;
p.append("!");
q.append("?");
System.out.println(x + " " + y + " " + p + " " + q);
Show solution C1

C1. It prints go! go? go!? go!?. The Strings x and y start as the same text but each + creates a separate new String, so they end up different. The builders p and q are one object with two names, so both appends went to the same text.

C2. Write static String join(String[] items, String sep) that returns the items separated by sep (no separator at the start or end). Return "" for an empty array.

Show solution C2

C2.

java
static String join(String[] items, String sep) {
    StringBuilder sb = new StringBuilder();
    for (int i = 0; i < items.length; i++) {
        if (i > 0) {
            sb.append(sep);
        }
        sb.append(items[i]);
    }
    return sb.toString();
}

For an empty array the loop does not run and toString() returns "".

Mini Challenge

Write a program that builds a multiplication-table string for numbers 1 to 12 (one line per number: 3 x 1 = 3, 3 x 2 = 6, ..., comma-separated, no trailing comma) using a single StringBuilder, and prints it. Then write the same thing with +=, time both for a table of 20,000 rows (you may change the number of rows), and describe what you see.

Check your work: the builder is created once outside the loops, every separator is added before the item, and the two methods produce identical text (test with equals).

Summary & Next Lesson

Key takeaways

  • A String is immutable: its methods return new Strings and never change the receiver.
  • Assign results you want to keep: s = s.trim();.
  • += on a String in a loop copies the growing text every time, which gets slow as the text grows.
  • StringBuilder is a mutable buffer: create it, append in the loop, call toString() at the end.
  • Use + for a few pieces in one statement; use StringBuilder for loops.

Next lesson

The next lesson, "Arrays utility class", returns to arrays and shows the ready-made helpers for printing, sorting, filling, copying, and comparing them.

Self-check

  1. Does s.toUpperCase(); change s?
  2. Why can s += x in a loop be slow?
  3. What are the three steps of the builder pattern?
  4. When is + still the better choice?
  5. What does append return, and why does that matter?
Show answers
  1. No. It returns a new String, which must be assigned.
  2. Every += builds a new String that copies all the previous characters, so total work grows quickly with the length.
  3. Create the builder before the loop, append inside it, toString() after it.
  4. For a few pieces in one statement, where it is shorter and clearer.
  5. The same builder, which allows chaining such as sb.append(a).append(b).

References

  • Oracle, String class, Java SE 21 API. It supports: Strings cannot be changed after creation, and methods such as trim and replace return new Strings; StringBuilder is the suggested tool for mutable text.
  • Oracle, StringBuilder class, Java SE 21 API. It supports: it is a mutable sequence of characters with append and insert, its toString() returns a String whose contents are not affected by later changes, and it is not safe for use by multiple threads (StringBuffer is the synchronized alternative).
  • Not verified from a primary page: the statement that the work of repeated += grows roughly with the square of the length (this is the standard reasoning about copying; the timing in Example 3 is consistent with it), and the note that compilers may optimize simple one-line + expressions. The outputs and the timing were produced by running the code with JDK 21.

Practice

Apply this lesson with the module's lab, exercises and quiz.

Lab and exercises Quiz

Marking a lesson complete is optional and you can undo it any time. Your progress stays in this browser.

Continue to next lesson

Type to search levels, phases, modules and lessons.