Immutability and StringBuilder
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
Stringto be immutable. - Explain why
s.toUpperCase();alone does not changes, and fix it. - Explain why
s = s + xin a loop creates a lot of garbage and why it gets slow. - Create a
StringBuilderand useappend,insert,reverse,length, andtoString. - Choose between
+andStringBuilderfor a given task. - Convert a
StringBuilderback to aStringand 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:
- A String method returns the changed text; it does not change the receiver. To keep the result, assign it.
String s = "java";
s.toUpperCase(); // result thrown away; s is still "java"
s = s.toUpperCase(); // s now points at the new String "JAVA"- 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:
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:
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 loopappend 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:
| Method | Effect |
|---|---|
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
StringBuilderwhen text is built in a loop or in many steps. - Convert to a
Stringat the end. Public methods normally returnString, not the builder. StringBuilderis documented as not safe for use by several threads at once.StringBufferis the synchronized sibling with a compatible API. For ordinary single-threaded code,StringBuilderis 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)
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):
java
JAVA
hi! hi
[1 2 3 4 5 ]
10The 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)
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:
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[
[]
JellAppending 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)
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):
true
plus ms: 3684
builder ms: 9Both 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.
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.
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:
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.
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
Stringis 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.StringBuilderis a mutable buffer: create it,appendin the loop, calltoString()at the end.- Use
+for a few pieces in one statement; useStringBuilderfor 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
- Does
s.toUpperCase();changes? - Why can
s += xin a loop be slow? - What are the three steps of the builder pattern?
- When is
+still the better choice? - What does
appendreturn, and why does that matter?
Show answers
- No. It returns a new String, which must be assigned.
- Every
+=builds a new String that copies all the previous characters, so total work grows quickly with the length. - Create the builder before the loop,
appendinside it,toString()after it. - For a few pieces in one statement, where it is shorter and clearer.
- The same builder, which allows chaining such as
sb.append(a).append(b).
References
- Oracle,
Stringclass, Java SE 21 API. It supports: Strings cannot be changed after creation, and methods such astrimandreplacereturn new Strings;StringBuilderis the suggested tool for mutable text. - Oracle,
StringBuilderclass, Java SE 21 API. It supports: it is a mutable sequence of characters withappendandinsert, itstoString()returns a String whose contents are not affected by later changes, and it is not safe for use by multiple threads (StringBufferis 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.
Marking a lesson complete is optional and you can undo it any time. Your progress stays in this browser.