8.2
Immutable Classes
An object that can never change is safe to share with anyone, forever, with no locks and no copies. Getting there takes four steps, and `final` is only the first one.
Previously on
Three separate threads arrive here at once.
In Section 6.3 you found that copying an object copies the arrows, not what they point at. In its last exercise you watched a private field get modified twice from outside the class.
In Section 6.4 you found that final freezes the variable and says nothing at all about the object at the other end.
In Section 7.1 you learned encapsulation, and were told it goes further than getters and setters.
This section is all three arriving together.
The problem
Here is a class that looks completely safe. Every field is private. Every field is final. There is not a single setter.
public class Squad {
private final String name;
private final int[] memberIds;
public Squad(String name, int[] memberIds) {
this.name = name;
this.memberIds = memberIds;
}
public int[] getMemberIds() {
return memberIds;
}
}And here is somebody changing it from outside, twice, with no reflection and no tricks.
int[] ids = {101, 102, 103};
Squad alpha = new Squad("Alpha", ids);
ids[0] = 999; // the caller kept the array
alpha.getMemberIds()[1] = 888; // and the getter handed it straight backBoth of alpha’s member ids have changed. private stopped nobody. final stopped nobody.
Neither keyword ever promised to. private controls who can name the field. final controls whether the arrow can be replaced. A reference that escaped your class defeats both, and you handed it out yourself.
The idea
An immutable object is one whose state can never change after it is constructed.
Not hard to change. Not changed only by approved methods. Cannot change. There is no sequence of legal Java that alters it.
You already use several. String is immutable. So are Integer, Long, Double, LocalDate and BigDecimal.
Why this is worth so much
| Property | Why it follows from immutability | |
|---|---|---|
| Safe to share | give it to anybody | nobody can change it, so nobody can surprise you |
| Thread safe for free | no locks, ever | a race needs something that changes |
| Safe as a map key | HashMap will not lose it | its hash can never change (Section 8.3) |
| Can cache its own hash | compute once | the answer can never go stale |
| No defensive copies needed | by the caller | there is nothing to protect against |
| Easy to reason about | read the constructor, done | no later line can have changed it |
That third row is the one that will matter most. Phase X is coming, and a mutable object used as a map key is a bug that loses your data silently.
Under the hood
Going deeperThe four steps
Making a class genuinely immutable
- Make the class finalOtherwise somebody extends it, adds a mutable field, and hands their subclass to code that expected your immutable one.
- Make every field private and finalprivate stops direct naming. final stops reassignment. Necessary, and on their own not enough.
- Provide no setters, and no method that changes a fieldAny method that wants to change something returns a new object instead.
- Defensively copy every mutable field, in AND outCopy in the constructor so the caller cannot keep a handle. Copy in the getter so the caller cannot take one. This is the step people miss.
Here is the fixed class:
public final class Squad { // 1. final class
private final String name; // 2. private final
private final int[] memberIds;
public Squad(String name, int[] memberIds) {
this.name = name;
this.memberIds = memberIds.clone(); // 4a. copy on the way IN
}
public String getName() {
return name; // safe: String is immutable
}
public int[] getMemberIds() {
return memberIds.clone(); // 4b. copy on the way OUT
}
public Squad withMember(int newId) { // 3. change means a NEW object
int[] longer = Arrays.copyOf(memberIds, memberIds.length + 1);
longer[longer.length - 1] = newId;
return new Squad(name, longer);
}
}Now run the attack again:
int[] ids = {101, 102, 103};
Squad alpha = new Squad("Alpha", ids);
ids[0] = 999; // changes the caller's array. alpha's copy is untouched.
alpha.getMemberIds()[1] = 888; // changes a throwaway copy. alpha's array is untouched.Both attacks now hit copies. The real array never leaves the object.
Changing an immutable object means making a new one
Squad alpha = new Squad("Alpha", new int[]{101, 102});
Squad bigger = alpha.withMember(103); // alpha is unchangedNotice the naming. Not addMember, which sounds like it changes something, but withMember, which reads as “give me one like this, with an extra member”. Java’s own immutable types follow the same convention: LocalDate.plusDays(), String.toUpperCase(), BigDecimal.add().
Why step one matters
Leaving off final on the class looks harmless:
public class Squad { ... } // not final
public class SneakySquad extends Squad {
private int[] extra = new int[10]; // a mutable field the parent knows nothing about
public void change() { extra[0] = 999; }
}Now any method expecting a Squad can be handed a SneakySquad, and thanks to polymorphism from Section 7.2 it cannot tell the difference. Your guarantee is gone, and the code relying on it has no way to know.
String is final for exactly this reason.
The other route: a private constructor
If you need subclasses for internal reasons, make the constructor private and hand out static factory methods instead:
public class Squad {
private Squad(...) { }
public static Squad of(String name, int[] ids) { return new Squad(name, ids.clone()); }
}Nobody outside can extend a class they cannot construct.
Records give you most of this for free
public record Point(int x, int y) { }That single line gives you private final fields, a constructor, accessors, and correct equals, hashCode and toString. Records are immutable by design and the class is implicitly final.
But records do not solve step four:
public record Squad(String name, int[] memberIds) { } // still leakyAn int[] is mutable, so this record hands out a reference to it. You have to add a compact constructor to defend it:
public record Squad(String name, int[] memberIds) {
public Squad {
memberIds = memberIds.clone(); // copy on the way in
}
public int[] memberIds() {
return memberIds.clone(); // copy on the way out
}
}The lesson generalises: a record is shallowly immutable. It stops the fields being reassigned. It cannot stop the objects they point at from changing, because nothing in Java can do that for you.
What it costs
Every change creates a new object. Changing one field of a ten field immutable object means building a whole new one. In a tight loop that is real allocation pressure, and it is exactly why StringBuilder exists alongside the immutable String.
Defensive copies cost memory and time. Copying a large array on every getter call is expensive, and it happens whether or not the caller ever intended to modify anything.
Constructors get large. With no setters, everything has to arrive at once, so a ten field immutable class has a ten argument constructor. That is hard to call correctly, and it is where the builder pattern usually shows up.
Some things also genuinely change. A bank balance, a game score, a connection pool. Forcing immutability onto something naturally stateful produces awkward code. The rule is not “make everything immutable”, it is “make everything immutable that can be”.
What you get is a whole category of bug removed. No aliasing surprises, no locks, no defensive copying by callers, no stale hash codes. You can hand an immutable object to anyone, including ten threads at once, and never think about it again.
Check yourself
Answer from memory before opening the explanation. Then compare your rule with the answer and revisit the example if they differ.
You made every field `private final` and removed all setters. Somebody still changes your object. How?
Show the answer
Through a reference you handed them, in one of two places.
The constructor. If you stored the object the caller passed in, the caller still holds it. They can change it whenever they like, and your field points at the thing they changed.
A getter. If you returned a mutable field directly, the caller now has a reference to your internals and can modify it from outside.
Both are the same mistake: a reference crossing the boundary of your class.
finalfroze the arrow, and both of these change what is at the end of it, whichfinalnever promised anything about.The fix is a defensive copy, in both directions. Copy on the way in, copy on the way out.
Why does an immutable object need no locks when several threads use it?
Show the answer
Because every race condition needs one thing to exist: something that changes while somebody else is looking at it. Remove the change and the race cannot happen.
Ten threads reading the same immutable object all see identical data, because no sequence of events can make it differ. There is no window between reading and writing, because there is no writing.
This is why
String,IntegerandLocalDateare immutable, and why "make it immutable" is the first advice you get for any concurrency problem. Not a workaround, but the removal of the condition the bug requires.If an immutable object cannot change, how does `name.toUpperCase()` work?
Show the answer
It does not change anything. It builds and returns a new String and leaves the original exactly as it was.
So
name.toUpperCase()on its own throws the result away and leavesnameunchanged. You have to writename = name.toUpperCase();for anything to happen.Every method on every immutable type works this way. It produces a new object and returns it, and if you do not assign the result, nothing happened.
The cost is a new object per operation, which is why joining a thousand Strings with
+is slow and whyStringBuilderexists.
Exercises
Write each solution in VS Code, predict its result, and then run the checks. Reading an example does not replace implementing it.
4 exercises115 pointsabout 125 minutes
Break It, Then Seal It
ex-8-2-aEvery field is private. Every field is final. The class is final. And there are still two ways to change it from outside.
Run it and watch both happen. Then name each one precisely. Not “the array leaked”, but which line handed out which reference, and to whom.
Fix both with defensive copies, then run the attack again and confirm both now hit throwaway copies.
Finish with the design question. The name field needs no copy at all. Say why, then say what would have to change if it were a StringBuilder instead of a String. That distinction is what tells you where copies are needed and where they are waste.
What to write
These exact signatures. The checks call them by name, so a different name or a different parameter type will not build.
class Seal {
static boolean leaksThroughConstructor()
static boolean leaksThroughGetter()
static boolean sealedResistsConstructor()
static boolean sealedResistsGetter()
static boolean nameNeedsCopy()
static boolean builderNeedsCopy()
static boolean leaksThroughStringBuilder()
static int copiesNeeded()
}LeakySquad keeps the array it is given and hands it straight back. Leave it as it is. SealedSquad is the same class with both holes closed. Each reporting method tries to change the private array from outside and says whether it worked, so leaks return true and sealed returns false. BuilderSquad makes the same point with a field that is not an array.
What your program must do
- Change a private final field from outside, through the constructor and through the getter
- Close both holes with defensive copies and confirm neither attack works
- Say why the name field needs no copy
- Show a StringBuilder field leaking the same way, and say what that proves
import java.util.Arrays;
public class Seal {
// Both ends are open. Leave this one exactly as it is.
static final class LeakySquad {
private final String name;
private final int[] memberIds;
LeakySquad(String name, int[] memberIds) {
this.name = name;
this.memberIds = memberIds;
}
String getName() { return name; }
int[] getMemberIds() { return memberIds; }
public String toString() { return name + " " + Arrays.toString(memberIds); }
}
// TODO: the same class with both holes closed
static final class SealedSquad {
private final String name;
private final int[] memberIds;
SealedSquad(String name, int[] memberIds) {
this.name = name;
this.memberIds = memberIds;
}
String getName() { return name; }
int[] getMemberIds() { return memberIds; }
public String toString() { return name + " " + Arrays.toString(memberIds); }
}
// A mutable field that is not an array. Same question.
static final class BuilderSquad {
private final StringBuilder notes;
BuilderSquad(StringBuilder notes) { this.notes = notes; }
StringBuilder getNotes() { return notes; }
}
// Try to change the PRIVATE array from outside. Did it work?
static boolean leaksThroughConstructor() { return false; } // TODO
static boolean leaksThroughGetter() { return false; } // TODO
// Now try the same two attacks on SealedSquad.
static boolean sealedResistsConstructor() { return true; } // TODO
static boolean sealedResistsGetter() { return true; } // TODO
// Which fields need a defensive copy, and which do not?
static boolean nameNeedsCopy() { return true; } // TODO
static boolean builderNeedsCopy() { return false; } // TODO
// Show the StringBuilder field leaking the same way the array did.
static boolean leaksThroughStringBuilder() { return false; } // TODO
// How many copies does closing this properly take?
static int copiesNeeded() { return 1; } // TODO
public static void main(String[] args) {
// TODO: change a private final field from outside, twice, two different ways
}
}
Hint 1
Hint 2
memberIds.clone() in the constructor and memberIds.clone() in the getter. After that the real array never leaves the object.Hint 3almost the answer
String is already immutable, so sharing it is safe and copying it would waste memory for nothing. A StringBuilder is mutable, so it needs exactly what the array needed. The rule is about whether the field's type can change, not about whether it is an array.Change Means a New Object
ex-8-2-bBuild a small immutable value type, the way Java’s own ones are built.
Every operation returns a new object and leaves the original alone. Prove it by printing the original after every operation and confirming it never moved.
Pay attention to the method names. plus rather than add. Java’s immutable types use this convention consistently, because a method called add on something that cannot change is a lie that costs the next reader real time.
Then notice what you did not have to write. No defensive copies anywhere, because both fields are already immutable. That is the compounding benefit: every immutable class you build makes the next one simpler.
What to write
These exact signatures. The checks call them by name, so a different name or a different parameter type will not build.
class Money {
Money plus(Money other)
Money minus(Money other)
Money times(int factor)
BigDecimal amount()
String currency()
static boolean fieldsNeedDefensiveCopies()
}Every operation returns a new Money and leaves both operands where they were. Adding two different currencies has no answer, so throw IllegalArgumentException rather than returning one. amount() and currency() are there so the tests can read the value without depending on how toString formats it. fieldsNeedDefensiveCopies() is your written answer to the last requirement: say whether BigDecimal and String need copying before you store them.
What your program must do
- Implement plus, minus and times so each returns a new Money
- Confirm the original is never changed by any of them, including when a call fails
- Reject adding two different currencies with IllegalArgumentException
- Answer fieldsNeedDefensiveCopies, and be able to say why
import java.math.BigDecimal;
public final class Money {
private final BigDecimal amount;
private final String currency;
public Money(BigDecimal amount, String currency) {
this.amount = amount;
this.currency = currency;
}
public BigDecimal amount() { return amount; }
public String currency() { return currency; }
// TODO: return a new Money holding the sum. Reject a different currency.
public Money plus(Money other) {
return null;
}
// TODO: return a new Money holding the difference. Reject a different currency.
public Money minus(Money other) {
return null;
}
// TODO: return a new Money worth factor times this one.
public Money times(int factor) {
return null;
}
// TODO: answer this once you have written the three methods above.
// Do BigDecimal and String need copying before you store them?
public static boolean fieldsNeedDefensiveCopies() { return true; }
@Override public String toString() { return currency + " " + amount; }
public static void main(String[] args) {
Money a = new Money(new BigDecimal("10.50"), "INR");
Money b = new Money(new BigDecimal("4.25"), "INR");
Money sum = a.plus(b);
System.out.println("a = " + a); // must still be 10.50
System.out.println("sum = " + sum);
}
}
Sample run
a = INR 10.50
sum = INR 14.75
Hint 1
Hint 2
plus rather than add, because add sounds like it changes the object. Java's own immutable types follow this, as in LocalDate.plusDays.Hint 3almost the answer
BigDecimal and String cannot be changed by anybody, so sharing them is safe. That is how immutability spreads usefully: one immutable class makes every class holding it simpler.The Record Is Only Shallowly Immutable
ex-8-2-cRecords are described as immutable. Test that claim.
Three lines of outside code, and all three reach inside a record that the language calls immutable. Run it and watch.
Then state precisely what a record does guarantee. The answer is real and it is narrower than the word suggests, and it is exactly the same distinction as final on a reference from Section 6.4.
Fix it with a compact constructor and overridden accessors. Note that one of the three fields needs nothing at all, and be able to say why.
The point is not that records are flawed. It is that no Java feature can make a mutable field immutable for you. Shallow immutability is the most any of them can offer, and knowing that is what keeps you from trusting the word.
What to write
These exact signatures. The checks call them by name, so a different name or a different parameter type will not build.
class Shallow {
static boolean nameFieldNeededCopying()
static boolean notesAccessorNeededOverriding()
}Squad is the record already in the file. Its constructor must copy both mutable inputs, memberIds() must return a fresh array copy on every call, and notes() must return an unmodifiable list. The two boolean methods record why String needs no copy and why the copied list needs no custom accessor.
What your program must do
- Run it and confirm all three outside changes reached the record
- Explain what a record actually guarantees, precisely
- Fix it with a compact constructor and overridden accessors
- Say whether the name field needed anything, and why
import java.util.*;
public class Shallow {
record Squad(String name, int[] memberIds, List<String> notes) { }
// TODO: did the String field need copying before it was stored?
static boolean nameFieldNeededCopying() { return true; }
// TODO: after List.copyOf, did the notes accessor need overriding?
static boolean notesAccessorNeededOverriding() { return true; }
public static void main(String[] args) {
int[] ids = {101, 102};
List<String> notes = new ArrayList<>(List.of("initial"));
Squad alpha = new Squad("Alpha", ids, notes);
System.out.println("built : " + alpha.name() + " " + Arrays.toString(alpha.memberIds()) + " " + alpha.notes());
ids[0] = 999;
notes.add("added from outside");
alpha.memberIds()[1] = 888;
System.out.println("after : " + alpha.name() + " " + Arrays.toString(alpha.memberIds()) + " " + alpha.notes());
// TODO: fix the record so none of those three lines can affect it
}
}
Hint 1
Hint 2
public Squad { memberIds = memberIds.clone(); notes = List.copyOf(notes); }. No parameter list, no assignment to this. It runs before the fields are set.Hint 3almost the answer
public int[] memberIds() { return memberIds.clone(); }. List.copyOf already returns an unmodifiable list, so that accessor needs nothing.An Immutable Registry
ex-8-2-dBuild the Registry as a nested immutable structure. A Registry holding Unit objects, where neither can ever change.
Start with Unit, and notice how easy it is. Every field is a primitive or a String, so no defensive copies are needed at all. That ease is not luck. It is what you bought by making the inner class immutable first.
Then Registry, which holds a list and therefore needs the full four steps.
Prove it works properly. After every withUnit call, print the original and confirm it is unchanged. Then try to modify the list a caller receives from units() and confirm it throws.
This is the shape real code wants. Immutable objects holding immutable objects, where you can hand any part of it to anyone and never think about it again.
What your program must do
- Make both classes genuinely immutable, all four steps
- withUnit and withoutUnit must return new Registry objects, never modify
- Prove that the original Registry is unchanged after every operation
- Show that a caller cannot modify the list returned by units()
import java.util.*;
public class ImmutableRegistry {
// TODO: final class Unit - name, id, readiness, active. Immutable.
// TODO: final class Registry - name, List<Unit>. Immutable.
//
// Registry needs:
// withUnit(Unit u) -> a NEW Registry with one more unit
// withoutUnit(int id) -> a NEW Registry with that unit gone
// units() -> callers must not be able to modify the list
// findById(int id) -> Unit or null
// averageReadiness()
public static void main(String[] args) {
// build a Registry, add units with withUnit, and prove the original never changes
}
}
Hint 1
Hint 2
List<Unit>, which is mutable, so it needs the full treatment. List.copyOf(units) in the constructor gives you an unmodifiable copy in one call, and it can then be returned directly from the accessor.Hint 3almost the answer
withUnit: build a new ArrayList from the existing list, add the unit, and pass it to a new Registry constructor. The original list is never touched, so the original Registry cannot change.After the credits
Immutability is about to be needed in four places, and one of them is a bug that eats data.
Next section. In Section 8.3 you will write hashCode(). An immutable object can compute its hash once and cache it forever, because the answer can never go stale. String does exactly this.
Phase IX. String is immutable, and that is the whole reason the String Pool can exist. If one variable could change a shared String, every other variable holding it would change too. It also finally explains the == behaviour that surprised you in Section 3.2.
Phase X, and this is the important one. Use a mutable object as a HashMap key, then change a field it uses in its hash, and the map loses the entry. Not an exception. containsKey returns false for the very object you used to store it, and the value is unreachable forever.
That single bug is why immutability is not a style preference. Every safe map key in Java is immutable, and now you know both how to build one and what happens if you do not.
Phase XIV. An immutable object is thread safe with no locks at all, because a race condition needs something that changes. This is the first thing anyone says about concurrency, and you already have the reason.
Threads you opened in this section
- ImmutabilityString is immutable, and the String Pool only works because of it.Phase IX. Text and Types
- ImmutabilityUse a mutable object as a HashMap key and you will lose it. Guaranteed.Phase X. The Collections Framework
- ImmutabilityAn immutable object is thread safe for free. No locks needed.Phase XIV. Concurrency
Immutability will return in 8.3 - The Object Class: `equals`, `hashCode`, `toString`