JAVA / CLASSES, CONSTRUCTORS AND ENCAPSULATION
final fields and immutable class design
Design a Java class whose instances can never change: blank final fields, defensive copies, withX methods, and a final class that keeps those promises.
What you will learn
- Assign every final field exactly once, at its declaration or in each constructor path
- Copy mutable input in the constructor and copy again in getters that expose it
- Replace setters with withX methods that return a new instance of the same class
- Mark the class final so no subclass can make a fixed value appear to change
Understanding final fields and immutable class design
A final instance field may be assigned exactly once, and the compiler verifies that the assignment happens before the constructor returns: at the declaration, in an instance initializer, or on every path through every constructor. A field declared without an initializer is a blank final, which is what lets you validate arguments first and throw before the field is ever set. What final freezes is the slot, not the value sitting in it, so for an int the number can never change, while for a List field only the reference is locked and the list it points at is as mutable as ever.
Immutability is therefore a property you design into a whole class, not something one keyword grants. The recipe is private final fields, no method that assigns them, a copy of each mutable constructor argument so the caller's reference cannot reach back inside the object, and a copy or unmodifiable view returned from anything that exposes a mutable field. Changes are expressed as new objects, so withStop builds the next value and leaves the old one untouched, and the class itself is final so a subclass cannot override an accessor and turn a fixed value into a moving one.
The payoff is that a reference to an unchanging object can be handed to any number of callers and threads with no copying and no locking, because the memory model guarantees that final fields written in the constructor are visible to every thread that later sees the object. Fixed state also makes equals and hashCode trustworthy for as long as the object lives, which is what makes such instances safe HashMap keys and Set elements. The cost is one allocation per change, so when state has to be built up in a loop, mutate a local ArrayList or StringBuilder and freeze the result into the immutable object at the end.
import java.util.ArrayList;
import java.util.List;
final class Route {
private final String name;
private final List<String> stops;
Route(String name, List<String> stops) {
this.name = name;
this.stops = new ArrayList<>(stops); // copy in: caller keeps no handle
}
List<String> stops() {
return new ArrayList<>(stops); // copy out: nobody edits our list
}
Route withStop(String stop) {
List<String> next = new ArrayList<>(stops);
next.add(stop);
return new Route(name, next); // a change means a new object
}
@Override
public String toString() {
return name + " " + stops;
}
}
public class Main {
public static void main(String[] args) {
List<String> input = new ArrayList<>();
input.add("Depot");
input.add("Market");
Route morning = new Route("Morning", input);
input.add("Harbour"); // mutating the caller's own list
morning.stops().add("Airport"); // mutating a returned copy
System.out.println(morning);
Route extended = morning.withStop("Harbour");
System.out.println(morning);
System.out.println(extended);
System.out.println(morning.stops() == morning.stops());
}
}final locks the field, not the object it refers to, so immutability has to be designed into the whole class: copy in, copy out, no mutators, no subclasses.
Worked examples
All fields final, still mutable
Shows that final protects the field itself and says nothing about the contents of the object it points to.
public class Main {
static final class Counter {
private final int[] hits = new int[1];
private final String label;
Counter(String label) {
this.label = label;
}
void hit() {
hits[0]++;
// hits = new int[1]; // compile error: cannot assign a final field
}
@Override
public String toString() {
return label + "=" + hits[0];
}
}
public static void main(String[] args) {
Counter clicks = new Counter("clicks");
clicks.hit();
clicks.hit();
System.out.println(clicks);
}
}Example explained
Line 1hits is final, so this object can never point that field at a different array.
Line 2hits[0]++ writes inside the array, which final does not restrict in any way.
Line 3Uncommenting the assignment fails to compile, because that line changes the field, not the array.
Line 4Every field is final and Counter is still mutable, which is why final alone is not immutability.
Why immutable objects make safe keys
Compares a final-field key with a mutable one to show how changing state after insertion strands an entry in a HashSet.
import java.util.HashSet;
import java.util.Objects;
import java.util.Set;
public class Main {
static final class Fixed {
private final int x;
private final int y;
Fixed(int x, int y) { this.x = x; this.y = y; }
@Override public boolean equals(Object o) {
return o instanceof Fixed && ((Fixed) o).x == x && ((Fixed) o).y == y;
}
@Override public int hashCode() { return Objects.hash(x, y); }
}
static final class Loose {
int x;
int y;
Loose(int x, int y) { this.x = x; this.y = y; }
@Override public boolean equals(Object o) {
return o instanceof Loose && ((Loose) o).x == x && ((Loose) o).y == y;
}
@Override public int hashCode() { return Objects.hash(x, y); }
}
public static void main(String[] args) {
Set<Fixed> fixed = new HashSet<>();
fixed.add(new Fixed(1, 2));
System.out.println(fixed.contains(new Fixed(1, 2)));
Set<Loose> loose = new HashSet<>();
Loose key = new Loose(1, 2);
loose.add(key);
key.x = 9;
System.out.println(loose.contains(key));
System.out.println(loose.contains(new Loose(9, 2)));
System.out.println(loose.size());
}
}Example explained
Line 1The hash code chooses the bucket at insertion time, and Fixed can never produce a different one.
Line 2key.x = 9 changes Loose's hash code, so contains(key) searches the bucket for (9, 2) and finds nothing.
Line 3Even a freshly built Loose(9, 2) misses, because the stored entry still sits in the bucket for (1, 2).
Line 4size() still reports 1: the element is present but unreachable by any key, a leak caused purely by mutability.
A subclass breaking an unchanging value
Shows why a class with only final fields still needs to be final if callers are to trust its accessors.
public class Main {
static class Money { // not final: anyone may extend it
private final long cents;
Money(long cents) { this.cents = cents; }
long cents() { return cents; }
}
static class Drifting extends Money {
private long extra = 0;
Drifting(long cents) { super(cents); }
@Override long cents() { return super.cents() + extra++; }
}
static void printTwice(Money m) {
System.out.println(m.cents() + " then " + m.cents());
}
public static void main(String[] args) {
printTwice(new Money(500));
printTwice(new Drifting(500));
}
}Example explained
Line 1Money.cents is final, and the value stored in a Money object genuinely never changes.
Line 2Drifting overrides the accessor, so callers reach the field only through a method that reports something new each call.
Line 3printTwice holds one reference and gets two different answers, exactly what an immutable type should rule out.
Line 4Declaring Money final, or cents() final, or the constructor private behind a static factory, closes the hole.
Important notes
A record hands you private final fields, accessors and equals/hashCode, but it is only shallowly immutable: a record with a List component still needs a canonical constructor that copies the list.
The cross-thread visibility guarantee for final fields holds only if the constructor does not publish this before it finishes; registering a half-built object in a listener list or passing it to a thread lets other code observe fields as 0 or null.
Common mistakes
Writing private final List<String> tags and calling the class immutable: callers reach the same list through the getter, so tags.add("x") changes an object that was supposed to be fixed.
Storing the constructor's List or array argument directly instead of copying it: the caller keeps a reference and can rewrite the state afterwards, so invariants checked in the constructor no longer hold.
Forgetting to assign a blank final on one branch of a constructor: the class refuses to compile with a complaint that the field may not have been initialized, and the usual wrong fix is to drop final instead of assigning on both branches.
Try it yourself
Change, predict, then run
Write a final class Playlist with a String title and a List<String> tracks, copying the list in the constructor and returning a copy from tracks(). Add withTrack(String), call it, then print the original playlist to prove it did not change.
Open the Java workspaceCheck your understanding
A class stores the caller's map in private final Map<String, Integer> scores, assigns it once in the constructor, and exposes public Map<String, Integer> scores() { return scores; }. What is true of its instances?
- They are immutable, because final stops anything from changing the map
- They are immutable as long as the class declares no setter for scores
- They can still change after construction: the caller kept a reference to the same map, and the getter hands that map out again
- The class will not compile, because a final field may not hold a mutable type
Show answer
final only forbids pointing scores at a different map; the map object keeps its own put and remove methods, and here two routes reach it, the reference the caller still holds and the one the getter returns. Option 2 is tempting because "no setters" is the usual rule of thumb, but a setter is just one leak: you also need to copy the map in the constructor and copy or wrap it on the way out.