Imagine you write a Person class with a getFriends() method that returns the internal list of friends. You hand that list to a report generator, and later you discover the generator added a few names — your internal list has been corrupted by code you never intended to touch. That is a side effect: a method changed state that belonged to someone else. Side effects are the reason large programs become impossible to reason about: you cannot trust a method to do only what its name promises, because it might be reaching out and altering data that lives far away. Java gives you two weapons against this chaos: immutability — objects that cannot change once built — and defensive copying — returning a duplicate so the original stays safe.
flowchart LR GET["getFriends()"] --> LIST["internal List"] CALLER["caller"] --> LIST CALLER -.->|"adds names"| LIST LIST -.->|"corrupted"| GET style LIST fill:#b45309,color:#fff
Immutability: the object that never changes
Some classes in Java are designed so that every method that looks like modification actually builds a brand-new object. String is the most important example, and every Java program relies on its immutability. When you write String greeting = "Hello"; greeting = greeting + "!";, the + operator does not modify the original "Hello" string in place. It allocates a new "Hello!" string somewhere else in memory and repoints the variable. The old "Hello" still exists in memory, unchanged, until the garbage collector eventually reclaims it. Because String never changes, you can pass it to any method, store it in any field, or share it across threads with zero risk that the recipient will corrupt your copy. That safety is why String is the default choice for text.
public class Main {
public static void main(String[] args) {
String greeting = "Hello";
String updated = greeting + "!";
System.out.println("original: " + greeting);
System.out.println("updated: " + updated);
}
}
Mutability: power with responsibility
Not every object is immutable. StringBuilder, ArrayList, and arrays are all mutable — they expose methods that modify their internal state in place. That power is useful: appending to a StringBuilder is far faster than concatenating String objects in a loop. But it is also dangerous. Hand a mutable object to another method and you are implicitly granting permission to mutate it. If that method appends, removes, or sorts, your original object changes with it. There is no compiler warning; the change simply happens. The receiver did not ask for ownership; it was handed ownership by default, and that is the trap.
public class Main {
static void addExclaim(StringBuilder sb) {
sb.append("!");
}
public static void main(String[] args) {
StringBuilder msg = new StringBuilder("hi");
addExclaim(msg);
System.out.println("msg: " + msg);
}
}
flowchart LR
subgraph IMM["Immutable String"]
S1["greeting"] --> O1["Hello"]
S2["greeting"] --> O2["Hello!"]
end
subgraph MUT["Mutable StringBuilder"]
T1["msg"] --> O3["hi"]
O3 -.->|"append() changes same object"| O3
end
Arrays: the hidden risk
Arrays are the most commonly leaked mutable objects in beginner code. Because they are built into the language and have no methods you call with dots, it is easy to forget they are objects with reference semantics. Passing an int[] to a helper method hands over a live reference to your internal data. Even Arrays.sort mutates the array in place. If you need the original order preserved, you must copy the array before sorting. Treat every array as a loaded gun: powerful, but dangerous the moment it leaves your hands. The compiler will not warn you; the bug will surface only when data changes mysteriously.
Defensive copying: trust but verify
When a class stores a mutable object — say an array or a list — and exposes it through a getter, it is giving away the keys to its internal state. Any caller with that reference can mutate, replace, or clear the data, and your object will have no idea it happened. The fix is defensive copying: instead of returning the internal array, return a copy of it. Instead of storing a caller's array directly in your constructor, store a copy. The copy costs a little extra time and memory, but it buys you a clear contract: no outside code can corrupt your internals, and your internals cannot leak out and surprise callers. Java's own ArrayList and HashMap use exactly this strategy at their boundaries. It is the standard practice in every well-designed Java API, and it is the first thing experienced reviewers check for in a pull request.
public class Main {
static class ScoreBoard {
private int[] scores;
ScoreBoard(int[] scores) {
this.scores = scores.clone();
}
int[] getScores() {
return scores.clone();
}
}
public static void main(String[] args) {
int[] data = {80, 90};
ScoreBoard board = new ScoreBoard(data);
int[] got = board.getScores();
got[0] = 99;
System.out.println("got[0]: " + got[0]);
System.out.println("internal: " + board.getScores()[0]);
System.out.println("original data: " + data[0]);
}
}
flowchart LR GET["getScores()"] --> COPY["copy of array"] CALLER["caller"] --> COPY COPY -.->|"mutates"| COPY INT["internal array"] -.->|"safe"| INT style INT fill:#3776ab,color:#fff
Choosing your strategy
Use immutable objects when you can — they eliminate entire categories of bug and make your code safe to share across methods and threads. Use mutable objects when performance or API design demands it, but keep them private to the class that owns them. Use defensive copies at the boundaries of your class: accept copies in constructors and setters, and return copies in getters. The rule is simple — never let a mutable object enter or leave your class without asking whether you have handed over the original or a duplicate. The combination is how Java's own libraries stay safe, and it is how you write code that other engineers can trust without reading every line.
A pattern to recognise
In technical interviews and certification exams, one question appears constantly: a String is passed to a method and apparently modified, yet the caller's variable is unchanged. The answer is always the same — String is immutable, so the method built a new object and left the original intact. If the same question uses a StringBuilder or an array, the answer flips: the caller sees the change because the object was mutable and shared. Recognising that pattern quickly is a reliable signal that you have moved from syntax to real understanding.
Immutable String. Concatenation creates a new object. Predict both lines.
public class Main {
public static void main(String[] args) {
String name = "Ada";
String greet = name + "!";
System.out.println(name);
System.out.println(greet);
}
}
String is immutable;
+creates a new String.The original
namestill holds "Ada".The new
greetholds "Ada!".
Mutable StringBuilder. A method appends to a StringBuilder. Predict the single line.
public class Main {
static void addWorld(StringBuilder sb) {
sb.append(" world");
}
public static void main(String[] args) {
StringBuilder msg = new StringBuilder("hi");
addWorld(msg);
System.out.println(msg);
}
}
StringBuilder is mutable.
The method mutates the same object that
msgpoints at.The append changes the object in place.
Defensive copy. A class clones the array in its constructor and getter. Predict all three lines.
public class Main {
static class Safe {
private int[] nums;
Safe(int[] nums) {
this.nums = nums.clone();
}
int[] getNums() {
return nums.clone();
}
}
public static void main(String[] args) {
int[] data = {1, 2};
Safe s = new Safe(data);
int[] out = s.getNums();
out[0] = 99;
System.out.println(out[0]);
System.out.println(s.getNums()[0]);
System.out.println(data[0]);
}
}
The constructor clones the input, so
datais safe.The getter clones again, so
outis a separate array.Mutating
outdoes not reach the internal array or the originaldata.
Which of these types is immutable?
String is immutable in Java. Methods like + or substring return new String objects rather than modifying the original. StringBuilder, ArrayList, and arrays are all mutable.
Why does a well-designed class use defensive copying in its getters and setters?
Defensive copying keeps the original mutable object private. Callers receive a copy, so any mutations they make affect only their duplicate, not the object's internal state.
Recap
- Immutable objects like
Stringnever change; methods that look like modification create new objects. - Mutable objects like
StringBuilderand arrays can change in place, so sharing them creates side effects. - A side effect is when one method corrupts state that belongs to another.
- Defensive copying returns or stores a duplicate, keeping the original safe from outside mutation.
- Apply defensive copies at class boundaries: getters, setters, and constructors that accept mutable objects.