Java Immutability Best Practices
Why immutability is a good default in Java, and patterns for building immutable types safely.
Java Immutability Best Practices
An immutable object is one whose state cannot change after construction. That single property removes a whole class of bugs: there are no surprise mutations from another thread, no aliasing surprises where two variables share state, and no need to defensively copy on every read. In Java, immutability isn't automatic — you have to build it deliberately. This chapter covers the patterns that make a type truly immutable and the habits that keep it that way.
Why immutability is a good default
Mutable shared state is the root of most concurrency bugs and a surprising number of single-threaded ones too. When an object can't change, you can pass it freely, cache it, and reason about it without tracking who else holds a reference.
| Property | Mutable object | Immutable object |
|---|---|---|
| Thread safety | Needs locks or care | Inherently thread-safe |
| Safe to share | No — callers may mutate | Yes — hand out the same instance |
| Safe as a map key | Risky — hashCode can drift | Yes — identity is stable |
| Caching | Must invalidate on change | Cache forever |
| Reasoning | Track every write | Value is fixed at construction |
The cost is allocation: changing one field means creating a new object. For most code that cost is negligible and the safety is worth it. Reach for mutability only when profiling proves you need it.
The five rules for an immutable class
A class is immutable when all of the following hold. Miss one and a caller can reach in and change state.
- The class is
final(or all constructors are private) so it can't be subclassed with mutable behavior. - All fields are
private final. - There are no setters — and no other methods that change a field.
- Mutable fields are defensively copied on the way in so the caller's reference can't be used to mutate your state.
- Getters never expose a mutable internal object directly — return a copy or an unmodifiable view.
public final class Money {
private final long cents;
private final String currency;
public Money(long cents, String currency) {
this.cents = cents;
this.currency = currency;
}
public long cents() { return cents; }
public String currency() { return currency; }
// "Change" returns a new object instead of mutating this one.
public Money plus(Money other) {
return new Money(this.cents + other.cents, currency);
}
}Defensive copies for mutable fields
Primitives and String are already immutable, so storing them is safe. The danger is mutable fields — arrays, collections, dates. If you store the caller's reference directly, they keep a handle to your internals.
public final class Schedule {
private final List<String> slots;
public Schedule(List<String> slots) {
// Copy IN: the caller can't mutate our list later.
this.slots = List.copyOf(slots);
}
public List<String> slots() {
// copyOf already returns an unmodifiable list, so this is safe to hand out.
return slots;
}
}List.copyOf, Set.copyOf, and Map.copyOf (Java 10+) do both jobs at once: they copy the data and return an unmodifiable view. For arrays, use array.clone() on the way in and clone() again on the way out, since arrays are always mutable and have no read-only wrapper.
Records: immutability by construction
A record (Java 16+) is the most concise way to declare an immutable carrier of data. The compiler generates private final fields, a canonical constructor, accessors, and value-based equals/hashCode/toString.
public record Point(int x, int y) {
// Compact constructor for validation and defensive copying.
public Point {
if (x < 0 || y < 0) {
throw new IllegalArgumentException("coordinates must be non-negative");
}
}
}Records cover the common case beautifully, but they are not a magic shield: if a record component is a mutable type (like List), you still must defensively copy it in the compact constructor, because the generated accessor returns the stored reference as-is.
Producing changed copies: the "wither" pattern
Since you can't mutate an immutable object, you create a modified copy. The convention is a withX method that returns a new instance with one field changed and the rest carried over.
public final class User {
private final String name;
private final String email;
public User(String name, String email) {
this.name = name;
this.email = email;
}
public User withEmail(String newEmail) {
return new User(this.name, newEmail); // new object, original untouched
}
}This keeps the original safe to share while letting callers build variations. It's the same model the JDK uses internally — LocalDate.plusDays, String.replace, and BigDecimal.add all return new instances rather than mutating the receiver.
A complete runnable example
The program below builds a small immutable Account, then tries every trick a caller might use to mutate it — passing in a list and mutating the original, mutating the returned list, and renaming. It proves each defense holds, then shows why immutable values make safe map keys.
What to take from the run:
Roles after mutating source: [read, write]proves the defensive copy worked — addingadminto the original list never reached theAccount.- The
UnsupportedOperationExceptiononacc.roles().add("hacker")shows the getter returned an unmodifiable view, so callers can't mutate internals through it. Original name still: Adanext toNew object name: GraceproveswithNameproduced a copy and left the original untouched.Different instance: trueconfirms the wither returned a genuinely new object rather than the same reference.Records equal by value: trueandKey still found: origin-ishshow that immutable values compare and hash by content, making them dependableHashMapkeys.
Practice
When an immutable class stores a mutable field such as a List, why must you make a defensive copy in the constructor?