Immutable Programming
Immutable programming in Java refers to a design discipline — not a single language feature — of constructing objects whose observable state cannot change after construction, relying on a combination of language features accumulated across many releases (final fields, defensive copying, immutable collection factories from Java 9, and records from Java 16) to make immutability both easier to express correctly and harder to violate accidentally. Immutable objects eliminate an entire category of bugs related to shared mutable state — unexpected changes visible through aliased references, broken invariants from partial mutation, and the need for defensive synchronization when objects cross thread boundaries — at the cost of needing to create new instances rather than mutating existing ones for every state change. This entry covers exactly what guarantees true immutability requires beyond just final fields, the defensive-copying problem for mutable fields holding collections or arrays, how records and immutable collection factories make correct immutability the easy default rather than something requiring extensive boilerplate, and the practical performance/design tradeoffs (wither-style update methods, structural sharing) immutable design requires.
What True Immutability Requires Beyond final Fields
// ── PITFALL: final field, but still mutable through an aliased reference
final class FakeImmutablePoint {
private final List<Integer> coordinates; // final FIELD, but the LIST it points to is mutable
FakeImmutablePoint(List<Integer> coordinates) {
this.coordinates = coordinates; // stores the CALLER'S own reference directly — no copy!
}
List<Integer> getCoordinates() {
return coordinates; // returns the INTERNAL mutable list directly — no protection here either
}
}
List<Integer> original = new ArrayList<>(List.of(1, 2));
FakeImmutablePoint p = new FakeImmutablePoint(original);
original.add(999); // mutating the ORIGINAL list the caller still holds...
System.out.println(p.getCoordinates()); // [1, 2, 999] — "immutable" object's state just changed!
p.getCoordinates().add(42); // ALSO mutable via the accessor's returned reference
System.out.println(p.getCoordinates()); // [1, 2, 999, 42] — mutated again, through a different path
// ── CORRECT — defensive copy at BOTH the constructor and accessor boundary
final class TrueImmutablePoint {
private final List<Integer> coordinates;
TrueImmutablePoint(List<Integer> coordinates) {
this.coordinates = List.copyOf(coordinates); // genuinely immutable copy — no shared reference
}
List<Integer> getCoordinates() {
return coordinates; // safe to return DIRECTLY now — it's already a structurally
// immutable collection (List.copyOf), no further copy needed here
}
}
List<Integer> original2 = new ArrayList<>(List.of(1, 2));
TrueImmutablePoint p2 = new TrueImmutablePoint(original2);
original2.add(999); // mutating the caller's own original list...
System.out.println(p2.getCoordinates()); // [1, 2] — UNAFFECTED, because the constructor copied it
// p2.getCoordinates().add(42); // UnsupportedOperationException — accessor returns
// a genuinely immutable collection, not the caller's ownRecords and Collection Factories — Making Correct Immutability the Easy Default
// ── Record with a mutable-type component — STILL needs explicit defensive copying
record Team(String name, List<String> members) {
Team { // compact constructor — the natural, single place for this protection
members = List.copyOf(members); // defensively copies AND makes the result
// structurally immutable — protects against BOTH the constructor-aliasing hazard
// AND the accessor-returns-a-mutable-reference hazard, in one line
}
}
List<String> mutableNames = new ArrayList<>(List.of("Alice", "Bob"));
Team team = new Team("Avengers", mutableNames);
mutableNames.add("Carol"); // mutating the caller's original list...
System.out.println(team.members()); // [Alice, Bob] — UNAFFECTED, thanks to List.copyOf()
// team.members().add("Dave"); // UnsupportedOperationException — the stored
// collection is itself immutable, so the accessor's result can't be mutated either
// ── Without the compact constructor's defensive copy — the record is NOT actually immutable
record LeakyTeam(String name, List<String> members) {
// No compact constructor at all — "members" stores whatever reference was passed in directly
}
List<String> mutableNames2 = new ArrayList<>(List.of("Alice", "Bob"));
LeakyTeam leaky = new LeakyTeam("X-Men", mutableNames2);
mutableNames2.add("Carol");
System.out.println(leaky.members()); // [Alice, Bob, Carol] — "immutable-looking" record,
// but actually still mutable through the aliased original list — records do NOT
// automatically protect against this; the defensive copy must still be written explicitly
// ── List.copyOf — no-op if the input is already structurally immutable ─
List<String> alreadyImmutable = List.of("a", "b");
List<String> copy = List.copyOf(alreadyImmutable);
System.out.println(copy == alreadyImmutable); // true in practice — no redundant copy made
// when the source is already one of these known-immutable collection typesWither Methods, Structural Sharing, and the Performance Tradeoff
// ── Wither methods — explicit, immutable-update counterpart to a setter ─
record Address(String street, String city, String zip) {}
record Person(String name, int age, Address address) {
// Each "wither" returns a NEW Person — original instance is never mutated:
Person withAge(int newAge) {
return new Person(name, newAge, address); // every OTHER field copied unchanged
}
Person withAddress(Address newAddress) {
return new Person(name, age, newAddress);
}
}
Person alice = new Person("Alice", 30, new Address("1 Main St", "Springfield", "00001"));
Person olderAlice = alice.withAge(31);
System.out.println(alice.age()); // 30 — ORIGINAL instance completely untouched
System.out.println(olderAlice.age()); // 31 — a DIFFERENT, new instance
System.out.println(alice.address() == olderAlice.address()); // true — STRUCTURAL SHARING:
// the unchanged "address" component is the SAME shared object in both versions,
// safe to share precisely because neither version could ever mutate it later
// ── Updating a deeply nested component — only the touched PATH is rebuilt
record AddressUpdate() {
static Person withCity(Person p, String newCity) {
Address oldAddr = p.address();
Address newAddr = new Address(oldAddr.street(), newCity, oldAddr.zip()); // new Address
return new Person(p.name(), p.age(), newAddr); // new Person
// Only Person and Address along this specific path were rebuilt;
// if Person had OTHER unrelated nested components, those would be shared, untouched
}
}
Person relocatedAlice = AddressUpdate.withCity(alice, "Shelbyville");
System.out.println(alice.address().city()); // "Springfield" — original fully intact
System.out.println(relocatedAlice.address().city()); // "Shelbyville" — only the touched
// path was actually reconstructed