JAVA / STRINGS AND TEXT HANDLING
Immutability and why string code creates garbage
Explain why every String method returns a new object, spot code that discards the result, and see where transforming text in loops creates needless garbage.
What you will learn
- Read s.toUpperCase() as 'return a new string'; s itself can never change.
- Assign or use the result of every string method, or the work becomes instant garbage.
- Count the intermediates in a chain: each call after the first leaves one behind.
- Skip defensive copies of String fields; still make them for char[] and other mutables.
Understanding Immutability and why string code creates garbage
A String holds its characters in a private array that is filled during construction and never written to again. That is why there is no setCharAt and no appendInPlace: toUpperCase, trim and replace allocate a second String, copy characters into it, and hand that back while the receiver stays byte for byte what it was. Read a string method as a question that returns an answer, not a command that changes something. The variable in your code is only a reference, so s = s.trim() moves the name onto a new object, while s.trim(); on its own moves nothing.
Java gives up in-place editing to buy guarantees. Because nobody can alter the characters, a String can be passed to another thread, used as a HashMap key, or returned from a getter with no defensive copy and no risk that a caller corrupts it afterwards, and its hash code can be computed once and cached in a field. Immutability also lets methods take shortcuts: trim() is documented to return the same object when there is nothing to remove, and replace does the same when there is no match, which would be unsafe if the caller could then edit what it received.
The price is allocation, not slow method calls. raw.trim().toLowerCase().replace(' ', '_') allocates three strings with three backing arrays, and two of them are unreachable before the statement even ends. One chain like that costs nothing worth measuring, because objects that die immediately are the cheapest thing a generational collector handles. It becomes real when the volume scales with a loop: re-deriving the same lowercase copy on every iteration, or peeling a string one character at a time, copies on the order of n squared characters and drops one dead object per step, all of it removable by hoisting a variable or keeping a single mutable buffer.
public class Immutable {
public static void main(String[] args) {
String raw = " Ada Lovelace ";
String trimmed = raw.trim();
String lower = trimmed.toLowerCase();
String same = lower.trim();
System.out.println("raw [" + raw + "] length " + raw.length());
System.out.println("trimmed [" + trimmed + "] length " + trimmed.length());
System.out.println("lower [" + lower + "]");
System.out.println("trimmed is a different object: " + (trimmed != raw));
System.out.println("nothing to trim, same object: " + (same == lower));
String result = raw.trim().toLowerCase().replace(' ', '_');
System.out.println("result [" + result + "]");
}
}A String is never edited; every method that looks like editing allocates a new object and abandons the old result, so string code costs allocations rather than changes.
Worked examples
Peeling a string and counting the copies
Shows that shortening a string one character at a time allocates a fresh object per step and copies quadratically many characters.
public class Peeling {
public static void main(String[] args) {
String s = "immutability";
int calls = 0;
long copiedChars = 0;
while (!s.isEmpty()) {
s = s.substring(1);
calls++;
copiedChars += s.length();
}
System.out.println("substring calls: " + calls);
System.out.println("characters copied: " + copiedChars);
System.out.println("final length: " + s.length());
}
}Example explained
Line 1s = s.substring(1) cannot shrink the existing object, so it allocates a String plus a new backing array and copies the remaining characters across.
Line 2The previous value of s becomes unreachable on that same line, so a 12-character input leaves 11 dead strings behind.
Line 3copiedChars reaches 66, which is 11+10+...+1: the copying work grows with the square of the length even though the visible text only gets shorter.
Line 4The last call produces an empty result, and on current JDKs that returns the shared empty string, making it the one iteration that allocates nothing.
A method cannot change the caller's string
Contrasts rebinding a String parameter with mutating an array parameter to show what immutability rules out.
public class Aliasing {
static void clean(String text) {
text = text.trim();
}
static void clean(char[] text) {
text[0] = 'x';
}
public static void main(String[] args) {
String s = " hi ";
clean(s);
System.out.println("[" + s + "]");
char[] c = {'a', 'b'};
clean(c);
System.out.println("[" + new String(c) + "]");
}
}Example explained
Line 1clean(String) receives a copy of the reference, and text = text.trim() points that local name at a new object without touching the caller's variable.
Line 2The trimmed string is unreachable the moment the method returns, so the only lasting effect of the call is garbage.
Line 3clean(char[]) writes into the array itself, and the caller sees 'xb' because both names refer to one mutable object.
Line 4With String this second outcome is impossible, which is why sharing a String reference is never a hazard.
Storing a String needs no defensive copy
Demonstrates that a stored String field is safe from outside interference while a stored array is not.
class Badge {
private final String name;
private final char[] pin;
Badge(String name, char[] pin) {
this.name = name;
this.pin = pin;
}
void show() {
System.out.println(name + " " + new String(pin));
}
}
public class SharedState {
public static void main(String[] args) {
String name = "ada";
char[] pin = {'1', '2', '3'};
Badge badge = new Badge(name, pin);
badge.show();
name = name.toUpperCase();
pin[0] = '9';
badge.show();
System.out.println(name);
}
}Example explained
Line 1this.name = name is safe without a copy because no code anywhere can change the characters of that String object.
Line 2name = name.toUpperCase() builds "ADA" and repoints the local variable, while the badge keeps its reference to the original "ada".
Line 3pin[0] = '9' reaches into the very array the badge holds, so the badge's state changes behind its back.
Line 4The final line proves the local variable really did move, so rebinding a name and mutating an object are two different operations.
Important notes
Immutable object and final variable are separate promises: final String s stops you rebinding the name, immutability stops anyone changing the characters, and you can have either without the other.
== between a result and its receiver can legitimately be true, since trim, replace and substring may return the receiver when there is nothing to change; read that as an allocation avoided, not as a way to compare text.
Common mistakes
Writing input.trim(); as its own statement and then validating input: the trimmed string is built and discarded, whitespace passes the check, and nothing warns you because the statement is legal Java.
Looping over banned words with clean.replace(word, "*"); and never assigning back, so clean comes out uncensored while the loop allocates one dead string per word.
Putting the transformation inside the loop, as in for (int i = 0; i < s.length(); i++) { if (s.toLowerCase().charAt(i) == 'a') ... }, which copies the whole string on every iteration and turns a linear scan into quadratic copying.
Try it yourself
Change, predict, then run
Start from String messy = " Grace HOPPER "; and write two blocks: one that calls trim(), toLowerCase() and replace(' ', '.') as bare statements and then prints messy, and one that assigns each result and prints it. Then state how many String objects the second block leaves unreachable.
Open the Java workspaceCheck your understanding
The code String s = "a b c"; s.replace(' ', '-'); System.out.println(s); prints a b c. Which explanation is correct?
- replace built a new string and returned it, nothing assigned it, so that result was garbage immediately and s still refers to the original object
- replace only edits a string in place when it is not a literal, and literals are read-only, so this particular string could not be changed
- replace requires String arguments rather than chars, so the call compiled but did no work at all
- println reads a cached copy of s that was taken before the replace call, so the change is simply not visible yet
Show answer
replace(char, char) is a real overload and it did run: it allocated "a-b-c", returned it, and the value was dropped because no variable received it, leaving s pointing at the untouched original. Option two is tempting because literals do get shared, but sharing is about object identity, not mutability; no String is ever editable in place, whether it came from a literal, a concatenation, or the network.