Java Immutable Classes
Design immutable classes in Java with final fields, defensive copies, and no setters.
An immutable class is one whose instances can't change after construction. String, Integer, LocalDate, BigDecimal, UUID — Java's standard library is full of them, and not by accident. Immutable objects are safe to share across threads, safe to use as HashMap keys, safe to cache, and easy to reason about: once you've seen one, you know its state for the rest of its life.
Making a class immutable isn't about adding a single keyword — it's about following a handful of rules together. Miss one and you get a class that looks immutable but isn't.
The five rules
To make a class genuinely immutable:
- Declare the class
final(or have only private constructors). Otherwise a subclass can break the contract. - Make every field
private final.finalprevents reassignment after construction;privatekeeps callers from touching them directly. - Don't expose setters. Any mutation method (
add,set,clear,reset) is out. - Defensively copy mutable inputs in the constructor. If the caller passes a
Dateor aList, copy it — otherwise they can mutate it from outside and your "immutable" object changes underneath you. - Defensively copy mutable returns in getters — for the same reason in reverse.
A class that does all five is deeply immutable. Miss any one and the guarantee leaks.
The minimal example
For a class whose fields are all primitive or already immutable, the rules collapse to almost nothing:
public final class Point {
private final int x;
private final int y;
public Point(int x, int y) {
this.x = x;
this.y = y;
}
public int x() { return x; }
public int y() { return y; }
}int is a primitive, so there's nothing to defensively copy. The class is final, the fields are private final, no setters exist. Done.
Mutable fields need defensive copies
The trouble starts when a field is itself mutable — an array, a Date, an ArrayList. If you store the caller's reference directly, they keep a handle to it and can mutate your internals:
// Broken: the array is shared
public final class Trajectory {
private final double[] points;
public Trajectory(double[] points) { this.points = points; }
public double[] points() { return points; }
}
double[] arr = {1.0, 2.0, 3.0};
Trajectory t = new Trajectory(arr);
arr[0] = 999; // mutates the "immutable" object!
System.out.println(t.points()[0]); // 999The fix is to copy on the way in and on the way out:
public final class Trajectory {
private final double[] points;
public Trajectory(double[] points) {
this.points = points.clone(); // copy in
}
public double[] points() {
return points.clone(); // copy out
}
}For collections, the equivalent is List.copyOf(other) (returns an unmodifiable list backed by a copy):
public final class Recipe {
private final String name;
private final List<String> steps;
public Recipe(String name, List<String> steps) {
this.name = name;
this.steps = List.copyOf(steps); // copy + unmodifiable view
}
public List<String> steps() { return steps; } // already unmodifiable
}"Modifications" return new instances
An immutable class can still support change — by returning a new instance:
public final class Money {
private final long cents;
public Money plus(Money other) { return new Money(cents + other.cents); }
public Money times(int factor) { return new Money(cents * factor); }
// constructor + accessors omitted
}Convention names the method with... when it produces a copy with one field changed: point.withX(5), user.withEmail("..."). The Java date/time API uses this pattern consistently — LocalDate.plusDays(7), LocalDate.withYear(2026).
Why this matters
Immutable objects buy you:
- Thread safety for free. No locks, no
volatile, no visibility surprises — there's nothing to synchronize because the state can't change. - Safe sharing and caching. Two callers holding the same
Money(2000, "USD")can't interfere with each other. - Reliable hash keys. Since the fields used in
hashCodecan't change, the object's bucket never goes stale. - Easier reasoning. Once you've seen an immutable object, you know what it'll do for the rest of its life. No "where did this get mutated?" archaeology.
The cost is allocating new instances for each "modification". For small, frequently used objects (String, Integer), this is rarely a problem; the JVM is very good at short-lived allocations. For genuinely expensive cases there are specific techniques (string builders, persistent data structures) — but reach for them only when profiling shows a real problem.
Records do most of the work
A record is implicitly final, has private final fields, generates accessors with no setters, and gives you equals/hashCode/toString for free:
public record Point(int x, int y) {}That's deeply immutable as long as the components themselves are immutable. For records that hold a mutable component (a List, an array), you still need a compact constructor that defensively copies:
public record Recipe(String name, List<String> steps) {
public Recipe {
steps = List.copyOf(steps);
}
}When records fit, they're the shortest path to a correct immutable class.
A worked example
What's next
Immutable classes are about controlling change. The final chapter of Part 6 is about controlling quantity — a class designed so that only one instance ever exists. Continue to Java singleton pattern.
Practice
Why is a defensive copy needed in the constructor of an immutable class that holds a mutable input like a `List` or an array?