Topic 4.11
Immutable Objects
In one line
An immutable object can't change after it's constructed: every "change" returns a new object. Immutable objects are simple to reason about, safe to share between threads, and safe as HashMap keys, which is why String, Integer, LocalDate and records are built that way.
Think of it like this
A printed photograph versus a whiteboard. Anyone walking past a whiteboard can rub out and rewrite what's there, so you can never be sure it still says what you wrote. A photo stays exactly as it was printed. If you want a brighter version, you print a new photo, and the old one is untouched. Immutable objects are photographs; mutable ones are whiteboards.
Words you'll meet
New words in this topic, in plain English. Come back here whenever one feels fuzzy.
- Immutable
- Can't be changed after it's created. To get a different value you make a new object.
- Mutable
- Can be changed after it's created, for example through setters or by adding items.
- `final` field
- A field that must be assigned exactly once, in the constructor or its declaration, and can never be reassigned.
- Defensive copy
- A copy made of a mutable object you receive or hand out, so others can't change your internal state through it.
- Unmodifiable view
- A wrapper that refuses changes made through it, but still shows changes made to the original underneath.
- Wither
- A method like
withPrice(200)that returns a new object equal to this one except for one changed value. - Thread-safe
- Works correctly even when several threads use it at the same time.
Step by step
01Mutable objects surprise you
A mutable Money object is passed to two orders as their price. Someone applies a discount to order A by calling price.setAmount(80). Order B's price changes too, because both orders share one object (Topic 3.3: the reference is copied, not the object).
Bugs like this are hard to find: the line that changed the value is far from the line that sees the wrong value. With immutable objects, sharing is always safe.
02Build an immutable class step by step
final class Money stops subclasses from adding setters. private final fields can be assigned only in the constructor. There are no setters. Operations like plus return a new Money.
Because Money can't change, you can share one instance anywhere, cache common values (Money.ZERO), and use it as a HashMap key with confidence.
final class Money {
private final long paise;
private final String currency;
Money(long paise, String currency) {
this.paise = paise;
this.currency = currency;
}
Money plus(Money other) {
if (!currency.equals(other.currency)) throw new IllegalArgumentException("currency mismatch");
return new Money(paise + other.paise, currency); // a new object
}
Money withPaise(long newPaise) { // a "wither"
return new Money(newPaise, currency);
}
long paise() { return paise; }
}03final is not immutable
private final List<String> tags; stops reassignment (tags = otherList won't compile) but tags.add("x") works fine. The reference is frozen; the list isn't.
So for each field ask two questions: can the field be reassigned? (fix with final) and can the object it points to change? (fix with an immutable type or a defensive copy).
04Defensive copies, in and out
In: if the constructor stores a caller's mutable object, the caller can change it later. Copy it: this.tags = List.copyOf(tags);. Copy *before* validating, so the caller can't change the value between your check and your copy (a time-of-check/time-of-use bug).
Out: if a getter returns a mutable internal object, the caller can change it. Return an immutable type (an unmodifiable list), or a copy.
Don't defensively copy immutable things (String, Integer, LocalDate, List.copyOf results). There's no need, and it wastes memory.
05View vs copy
Collections.unmodifiableList(original) returns a thin wrapper. Calling add on the wrapper throws UnsupportedOperationException, but changes to original show through the wrapper. It's read-only, not immutable.
List.copyOf(original) copies the elements into a new unmodifiable list. Later changes to original don't affect it. Note it rejects null elements with NullPointerException.
06Why immutable objects are thread-safe
Most concurrency bugs come from one thread changing something while another reads it. If nothing can change, there's nothing to race on and no lock needed.
There's one subtle requirement: the object must be safely constructed. JLS 17.5 guarantees that a thread that sees a reference to the object also sees the values the constructor wrote to its final fields, provided this didn't escape during construction. That's why the recipe forbids leaking this and why fields must be final, not just "never changed by convention".
Try it yourself
- 1
Make the mutable price safe
In the first example, give
Ordera copy of the price in its constructor (new MutableMoney(p.amount)). Predict themutable:line. Which design is simpler, copying everywhere or makingMoneyimmutable? - 2
Swap view and copy
In the second example, add
tags.remove("java");before the prints. Predict all three lines first. - 3
Check-then-copy bug
In
SafePeriod, move the validity check above the copies (checking the caller'sstart/end). Explain why that's weaker when another thread could change the caller's dates between the check and the copy.
Code & diagrams
Expected output
mutable: A=80 B=80
immutable: original=100 discounted=80
String: s=chai t=CHAIExpected output
tags: [java, records]
view: [java, records]
copy: [java]
view.add -> UnsupportedOperationException
copy.add -> UnsupportedOperationException
copyOf(copy) is same object: truejava.util.Date is mutable, which is one reason java.time (immutable, Java 8) replaced it. Use Instant or LocalDate in new code.
Expected output
naive: 1000 -> 0 valid? false
safe: 1000 -> 5000 valid? trueBreak it on purpose
Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.
Break #1
Reassign a final field in a method
In a class with private final long balance = 0;, write a method void add() { balance = 5; }.
Break #2
Modify an immutable list
Write List<String> xs = List.of("a"); xs.add("b");.
Myth vs fact
Myth
Marking fields final makes a class immutable.
Fact
final stops reassignment of the field. If it points to a mutable object (a list, an array, a Date), that object can still change. You also need defensive copies and no mutating methods.
Myth
Collections.unmodifiableList makes an immutable list.
Fact
It's a read-only view. Anyone holding the original list can still change it, and the view shows those changes. List.copyOf makes an independent copy.
Myth
Immutable objects are slow because of all the copying.
Fact
Short-lived allocations are cheap on modern JVMs, and immutability removes the need for defensive copies when sharing and for locks across threads. Measure before optimising.
Pro corner
Extra depth for experienced readers. New to this? Skip it for now and come back later.
- ▸
JLS 17.5 (final field semantics): a thread that obtains a reference to an object after its constructor completes is guaranteed to see the correctly initialised values of its
finalfields, and of objects reachable through them as of the end of construction. Non-final fields get no such guarantee without synchronisation, which is how "effectively immutable" objects published through a data race can show default values. - ▸
HotSpot doesn't trust
finalinstance fields to be constant-folded by default, because reflection and deserialization can change them; it does truststatic finalfields, record fields, and fields of some JDK-internal classes (@Stable,@TrustFinalNonStaticFields). Work on "stable values" and "final means final" aims to change this in future releases. - ▸
Immutable classes can cache derived values lazily without breaking immutability, as
Stringdoes with its hash: the field isn'tfinal, but every thread computes the same value, so a benign race is harmless. This trick only works for values that are deterministic and atomic to write (anint, not alongon old 32-bit JVMs). - ▸
For big immutable objects that change often, look at persistent data structures (structural sharing, as in Vavr or Clojure collections) or a builder that produces the final immutable object once.
String+StringBuilderis exactly this pairing (Topic 6.3).
Remember this
- 1
An object is immutable if its observable state can never change after the constructor finishes.
Stringis the famous example:s.toUpperCase()doesn't changes; it returns a new string. The same holds forInteger,BigDecimal,LocalDate,List.of(...)and records with immutable components. - 2
The recipe (Effective Java Item 17): (1) make all fields
private final; (2) don't provide setters or any method that changes state; (3) make the classfinal(or use private constructors with factories) so subclasses can't add mutable state; (4) make defensive copies of mutable inputs in the constructor and of mutable outputs in getters; (5) don't letthisescape from the constructor. - 3
finalalone is not immutability.final List<String> itemsmeans the variable always points to the same list, but the list's contents can still change.finalfreezes the reference, not the object it points to. - 4
An unmodifiable view is not a copy.
Collections.unmodifiableList(list)blocks changes made *through the view*, but if someone still holds the original list and changes it, the view shows the change.List.copyOf(list)(Java 10) makes a real, independent, unmodifiable copy (and returns the same object if it's already an unmodifiableList.of-style list). - 5
To change an immutable value you build a new one, usually through wither methods:
price.withAmount(200)ordate.plusDays(1). This costs an allocation, which modern JVMs handle cheaply, and it buys a lot: no defensive copies needed when sharing, no locks needed across threads, safe caching and safe use as map keys (Topic 4.8). - 6
Immutable objects are automatically thread-safe: if nothing can change, no thread can see a half-finished change. The Java Memory Model adds a special guarantee for
finalfields: once the constructor finishes (without leakingthis), every thread sees their correct values, even without synchronisation (Phase 12).
Explain it without notes
List the rules for making a class immutable and explain the reason for each.
Why isn't final enough to make an object immutable?
What is the difference between Collections.unmodifiableList(list) and List.copyOf(list)?
Why are immutable objects thread-safe, and what condition must hold for that guarantee?
Practice
Write an immutable final class Temperature with a double celsius field, a plus(double delta) method returning a new object, and toString(). Show that the original is unchanged after plus.
Write an immutable final class Playlist holding a List<String> songs with a defensive copy in and an unmodifiable list out, plus withSong(String s) that returns a new playlist. Prove the original doesn't change.
Show that String methods return new objects: call concat, replace and trim on " hi " and print the original afterwards between brackets.
Trade-offs
- ↔
Immutable objects make code easier to reason about and safe to share, but each change allocates a new object. For a value updated millions of times in a tight loop (a counter, a buffer), a mutable object confined to one thread is the pragmatic choice.
- ↔
Defensive copying protects invariants but costs memory and time on every call. Using immutable component types (
List.copyOf,String,java.time) removes the need to copy on the way out. - ↔
Immutability by convention ("we just don't call the setters") gives none of the guarantees. Either enforce it with
finalfields and no mutators, or treat the class as mutable and document who may change it.
Done when you can
Done when you can write an immutable class following all five rules.
Done when you can explain why
finalisn't the same as immutable.Done when you use defensive copies in and out, and copy before validating.
Done when you can explain unmodifiable view vs
List.copyOf.Done when you can explain why immutable objects are thread-safe and what safe construction means.