immutability
final fields, defensive copies, and the aliasing bug that ruins your day.
- chapter
- chapter 03.12
- reading time
- 7 min read
an immutable object can't change after it's built. making one takes more than final: final freezes the reference, not the object it points at. the recipe is no setters, private final fields, a class nobody can subclass, and defensive copies of anything mutable that comes in or goes out. records get you most of the way, but they're only shallowly immutable.
the picture
a printed receipt versus a whiteboard. you can hand a receipt to anyone, photocopy it, file it, and it'll say the same thing forever. a whiteboard you share is a whiteboard someone else can erase.
the trap is a receipt that refers to a whiteboard. "total: see board 3". the receipt itself never changes, and yet what it says does.
final isn't immutable
final on a field (or a variable) means the slot can't be reassigned. the object it points at is another matter.
final slot is locked: it will always point at this list. the list itself is an ordinary ArrayList, and NAMES.add(...) changes it just fine.import java.util.*;
public class Fin {
static final List<String> NAMES = new ArrayList<>();
public static void main(String[] args) {
NAMES.add("ada"); // fine: changes the list
NAMES.add("duke");
NAMES = new ArrayList<>();
}
}error: cannot assign a value to static final variable NAMES. the arrow is frozen, the list isn't. (a public static final array is the worst version of this: anyone can overwrite its elements.)
the aliasing bug
here's the classic, from effective java item 50. a Period with two java.util.Dates (which are mutable), private final fields, a check in the constructor, no setters. looks immutable. isn't.
the Period is fine, for now. try the attacks.
import java.util.Date;
public final class Period {
private final Date start;
private final Date end;
public Period(Date start, Date end) {
if (start.compareTo(end) > 0) throw new IllegalArgumentException("start after end");
this.start = start;
this.end = end;
}
public Date start() { return start; }
public Date end() { return end; }
public static void main(String[] args) {
Date start = new Date(1_000_000_000_000L);
Date end = new Date(2_000_000_000_000L);
Period p = new Period(start, end);
end.setTime(0); // attack 1: keep the argument, mutate it
System.out.println(p.end().before(p.start()));
p.start().setTime(5_000_000_000_000L); // attack 2: mutate what the getter returned
System.out.println(p.start().after(p.end()));
}
}true true
two trues: the constructor's check passed, and the period ended up running backwards anyway. the fix copies on the way in and on the way out:
import java.util.Date;
public final class Safe {
private final Date start;
private final Date end;
public Safe(Date start, Date end) {
this.start = new Date(start.getTime()); // copy first,
this.end = new Date(end.getTime());
if (this.start.compareTo(this.end) > 0) // then check the copies
throw new IllegalArgumentException("start after end");
}
public Date start() { return new Date(start.getTime()); }
public Date end() { return new Date(end.getTime()); }
public static void main(String[] args) {
Date start = new Date(1_000_000_000_000L);
Date end = new Date(2_000_000_000_000L);
Safe p = new Safe(start, end);
end.setTime(0);
System.out.println(p.end().before(p.start()));
p.start().setTime(5_000_000_000_000L);
System.out.println(p.start().after(p.end()));
}
}false false
notice the order: copy first, validate the copies. validate the originals and another thread can change them between your check and your copy (a time-of-check/time-of-use bug). and the real modern fix is to not use Date at all: java.time.Instant and LocalDate are immutable, so there's nothing to copy. see dates and time.
unmodifiable is not immutable
Collections.unmodifiableList(list) doesn't copy anything. it's a read-only view: a wrapper holding a reference to your list, which throws on add. whoever still holds the original list can change it, and the view shows every change. List.copyOf takes a real snapshot.
ArrayList you can still change. the copy is its own object. class names are the real ones on jdk 25.import java.util.*;
public class Views {
public static void main(String[] args) {
List<String> list = new ArrayList<>(List.of("a", "b"));
List<String> view = Collections.unmodifiableList(list);
List<String> copy = List.copyOf(list);
list.add("c");
System.out.println(view + " " + copy);
try {
view.add("d");
} catch (UnsupportedOperationException e) {
System.out.println("view.add: " + e.getClass().getSimpleName());
}
}
}[a, b, c] [a, b] view.add: UnsupportedOperationException
more in immutable collections.
records are shallowly immutable
a record's fields are private final and there are no setters. but a component that's a mutable object is still that same mutable object:
record Team(String name, List<String> members) {}
record SafeTeam(String name, List<String> members) {
SafeTeam {
members = List.copyOf(members);
}
}
List<String> people = new ArrayList<>(List.of("ada"));
Team t = new Team("core", people);
SafeTeam s = new SafeTeam("core", people);
people.add("mallory");
IO.println(t.members() + " " + s.members());import java.util.*;
record Team(String name, List<String> members) {}
record SafeTeam(String name, List<String> members) {
SafeTeam {
members = List.copyOf(members); // compact constructor: defensive copy
}
}
public class Rec {
public static void main(String[] args) {
List<String> people = new ArrayList<>(List.of("ada"));
Team t = new Team("core", people);
SafeTeam s = new SafeTeam("core", people);
people.add("mallory");
System.out.println(t.members() + " " + s.members());
t.members().add("eve");
System.out.println(t);
}
}[ada, mallory] [ada] Team[name=core, members=[ada, mallory, eve]]
the recipe
- no mutators. no setters, no methods that change state.
- make the class
final(or give it only private constructors and static factories), so a subclass can't add mutable state or override methods to lie. - all fields
private final. private so nobody reaches in, final so even you can't reassign them, and so the memory model guarantees other threads see them fully initialized. - defensive copies of mutable inputs (in the constructor) and mutable outputs (in getters). or better, only use immutable component types.
- don't leak
thisfrom the constructor (no registering listeners, no starting threads), so nobody sees the object half-built.
"modifying" an immutable object means making a new one: String.toUpperCase(), BigDecimal.add(), LocalDate.plusDays() all return fresh objects and leave the original alone.
why bother
- thread-safe for free. no locks, no races: nothing changes, so there's nothing to coordinate.
- safe map keys and set elements. the hash can't change under the collection (see the vanishing key).
- shareable and cacheable. the string pool and the integer cache only work because strings and Integers can't change.
- failure atomic. an operation that throws halfway can't leave the object in a weird state, because there's no state to mess up.
the cost is allocation: every "change" is a new object. usually fine (young-generation garbage is cheap, see generational GC); in a hot loop, a mutable companion like StringBuilder does the building and an immutable value comes out the end.
finalfreezes the reference, not the object.- immutable = no mutators, final class, private final fields, defensive copies.
- copy mutable inputs in the constructor and mutable outputs in getters. copy, then validate.
- unmodifiable views show changes to the original.
List.copyOfdoesn't. - records are shallowly immutable. their components decide the rest.