Immutability in Java: Principles and Practice
Introduction
This tutorial explains immutability as a design principle, and how to apply it in Java. We will start with what an immutable object is and why immutability makes code safer and simpler. We will then run a Java example showing three classic problems - a mutable object lost inside aHashSet, a record that is less immutable than it looks, and the "wither" style of changing immutable values. Next, we will list the rules for writing an immutable class, see how records and the immutable collections help, and look at the difference between shallow and deep immutability. Lastly, we will cover the costs, and when mutable objects are the better choice.
What is an immutable object
An object is immutable if its state cannot change after it has been constructed. Every field is set once, in the constructor, and there is no method that modifies it. To "change" an immutable object, you create a new one with the new values, and the old one stays exactly as it was.Java has used immutability from the start: StringClick to read: String, the wrapper classes such as
Integer, BigDecimal, and every class in the java.time package are immutable. date.plusDays(3) does not change date; it returns a new date.
Why immutability matters
- Safe sharing: an immutable object can be passed to any method, stored in any collection and returned from any getter, with no risk that someone else changes it behind your back.
- Thread safety for free: immutable objects need no locks. Any number of threads can read them at the same time, because there are no writes to coordinate.
- Reliable map keys and set elements: a hash code that can never change means an object can never get "lost" inside a
HashMaporHashSet. - Easier reasoning: once an object is valid after construction, it stays valid. You check its rules once, in the constructor, rather than after every change.
- Caching and reuse: values can be cached and shared freely, as the JDK does for small
Integervalues and string literals.
Example - three immutability lessons in code
The program below shows, in order: what happens when a mutable object used as a set element is changed; why a record holding aList is not automatically immutable; and how an immutable record is "changed" with a with method.
Java code showing mutable set elements, shallow and deep immutable records, and withers
//ImmutabilityExample.java
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
public class ImmutabilityExample {
// ---------- 1. A mutable object used as a set element ----------
static class MutableEmployee {
private String name;
MutableEmployee(String name) { this.name = name; }
void setName(String name) { this.name = name; }
@Override
public boolean equals(Object o) {
return o instanceof MutableEmployee m && m.name.equals(name);
}
@Override
public int hashCode() { return name.hashCode(); }
}
// ---------- 2. A record is only as immutable as its fields ----------
record ShallowTeam(String name, List<String> members) {}
record Team(String name, List<String> members) {
Team {
members = List.copyOf(members); // defensive, unmodifiable copy
}
}
// ---------- 3. "Changing" an immutable value returns a new one ----------
record Employee(String name, double salary) {
Employee withSalary(double newSalary) { return new Employee(name, newSalary); }
}
public static void main(String[] args) {
Set<MutableEmployee> onPayroll = new HashSet<>();
MutableEmployee tom = new MutableEmployee("Tom Jones");
onPayroll.add(tom);
tom.setName("Thomas Jones"); // changes its hash code
System.out.println("1. contains(tom) after rename : " + onPayroll.contains(tom));
System.out.println(" size of the set : " + onPayroll.size());
List<String> names = new ArrayList<>(List.of("Tom Jones", "Harry Major"));
ShallowTeam shallow = new ShallowTeam("Payroll", names);
Team team = new Team("Payroll", names);
names.add("Intruder"); // the caller changes its own list
System.out.println("2. shallow record members : " + shallow.members());
System.out.println(" defensive-copy members : " + team.members());
try {
team.members().add("Intruder");
} catch (UnsupportedOperationException e) {
System.out.println(" team.members().add(...) : UnsupportedOperationException");
}
Employee nancy = new Employee("Nancy Smith", 5200);
Employee raised = nancy.withSalary(5600);
System.out.println("3. original : " + nancy);
System.out.println(" raised : " + raised);
}
}
1. contains(tom) after rename : false size of the set : 1 2. shallow record members : [Tom Jones, Harry Major, Intruder] defensive-copy members : [Tom Jones, Harry Major] team.members().add(...) : UnsupportedOperationException 3. original : Employee[name=Nancy Smith, salary=5200.0] raised : Employee[name=Nancy Smith, salary=5600.0]
Explanation of the code
- Lesson 1:
HashSetfiles each element under its hash code when it is added. Renamingtomchanged his hash code, so the set now looks in the wrong place and reports that he is not there - although the set still has one element. The object is in the set but cannot be found or removed. The equals and hashCode contractClick to read: equals and hashCode contract assumes the fields they use do not change while the object is in a collection. - Lesson 2: record fields are
final, butfinalonly stops the reference from changing.ShallowTeamstores the caller's ownArrayList, so when the caller added "Intruder" to its list, the team changed too. Teamfixes this in its compact constructor withList.copyOf(), which makes an unmodifiable copy. Later changes to the caller's list do not reach it, and the list it hands out cannot be modified.- Lesson 3:
withSalary()returns a newEmployee, andnancyis unchanged. Code that still holds the original sees the original - which is exactly what you want for an audit trail, an undo history, or a report that is being generated while salaries are updated.
Rules for writing an immutable class
Joshua Bloch's Effective Java lists five rules. A class that follows all of them is immutable -- Do not provide methods that modify state - no setters, and no other methods that change fields.
- Make sure the class cannot be extended - declare it
final, or make its constructors private and use static factory methods, so a subclass cannot add mutable state or override behaviour. - Make all fields
final- the compiler then checks that each is assigned exactly once. - Make all fields
private- so no one can reach a mutable field directly. - Ensure exclusive access to mutable components - if a field refers to a mutable object, such as a list, an array or a
Date, make a defensive copy when it comes in through the constructor, and never return the original from a getter.
Records make it easier, not automatic
A Java recordClick to read: Java record follows rules 1 to 4 by itself: it is final, its fields are private and final, and it has no setters. Rule 5 is still your job, because a record copies references, not the objects they point to.
Unmodifiable views versus unmodifiable copies
List<String> source = new ArrayList<>(List.of("Tom Jones"));
List<String> view = Collections.unmodifiableList(source); // a read-only VIEW of source
List<String> copy = List.copyOf(source); // an independent unmodifiable copy
List<String> literal = List.of("Tom Jones"); // unmodifiable from the start
source.add("Harry Major");
// view -> [Tom Jones, Harry Major] the view shows the change
// copy -> [Tom Jones]
// literal -> [Tom Jones]
Collections.unmodifiableList() stops you from changing the list, but anyone holding the original can still change it. Use List.copyOf(), Set.copyOf() and Map.copyOf() in constructors. The Java 9 immutable collections tutorialClick to read: Java 9 immutable collections tutorial covers List.of() and its relatives.
The costs, and when to stay mutable
- More objects: every change allocates a new object. For most business objects this is cheap - the JVM is very good at short-lived objects - but in a tight loop building a large string or collection, use a mutable builder such as
StringBuilder, then produce the immutable result at the end. - Many fields: an immutable object with ten fields needs a long constructor. The Builder patternClick to read: Builder pattern or a set of
withmethods keeps creation readable. - Frameworks: some frameworks, such as JPA, expect entity classes with a no-argument constructor and settable fields. Keep entities mutable, and use immutable objects for values inside them and for the data you pass between layers.
- Objects that model change: something that really is a changing thing - a shopping cart being filled, a game board - can be mutable, as long as the mutation is kept inside one object that guards its own rules.
Summary
In this tutorial we first saw what an immutable object is and that much of the JDK, fromString to java.time, is built on it. Next we listed its benefits - safe sharing, thread safety, reliable hash keys, easier reasoning and caching. We then ran an example in which a renamed object was lost inside a HashSet, a record changed through a list it shared with its caller, and a with method produced a new object instead of changing the old one. We then covered the five rules for immutable classes, and saw that records give you four of them while defensive copies with List.copyOf() provide the fifth. Lastly, we looked at the costs of immutability and when a mutable object is the better choice.Programming Principles Tutorials
Separation of Concerns Explained with Java ExamplesClick to read tutorial: Separation of Concerns Explained with Java Examples Command-Query Separation (CQS) with Java ExamplesClick to read tutorial: Command-Query Separation (CQS) with Java Examples SOLID Principles in Java: Overview with ExamplesClick to read tutorial: SOLID Principles in Java: Overview with Examples Composition over Inheritance in Java with ExamplesClick to read tutorial: Composition over Inheritance in Java with Examples Coupling and Cohesion Explained with Java ExamplesClick to read tutorial: Coupling and Cohesion Explained with Java Examples DRY, KISS and YAGNI Principles with Java ExamplesClick to read tutorial: DRY, KISS and YAGNI Principles with Java Examples Dependency Injection vs Dependency Inversion vs IoCClick to read tutorial: Dependency Injection vs Dependency Inversion vs IoC
Separation of Concerns Explained with Java ExamplesClick to read tutorial: Separation of Concerns Explained with Java Examples Command-Query Separation (CQS) with Java ExamplesClick to read tutorial: Command-Query Separation (CQS) with Java Examples SOLID Principles in Java: Overview with ExamplesClick to read tutorial: SOLID Principles in Java: Overview with Examples Composition over Inheritance in Java with ExamplesClick to read tutorial: Composition over Inheritance in Java with Examples Coupling and Cohesion Explained with Java ExamplesClick to read tutorial: Coupling and Cohesion Explained with Java Examples DRY, KISS and YAGNI Principles with Java ExamplesClick to read tutorial: DRY, KISS and YAGNI Principles with Java Examples Dependency Injection vs Dependency Inversion vs IoCClick to read tutorial: Dependency Injection vs Dependency Inversion vs IoC