Immutability and Defensive Copying in Java
An immutable object cannot change after construction, which removes whole categories of bug at the cost of a little copying.
-
Java Basics
- Introduction to Java
- Setting Up Java and Writing Your First Program
- Variables, Data Types and Literals in Java
- Type Casting and Type Conversion in Java
- Operators and Expressions in Java
- Input and Output in Java
- Comments, Keywords and Naming Conventions in Java
- Control Flow in Java: if, else and switch
- Loops in Java: for, while and do-while
- Methods
- Arrays and Strings
-
OOP
- Classes and Objects in Java
- Constructors in Java
- The this Keyword in Java
- The static Keyword in Java
- Encapsulation in Java
- Access Modifiers in Java
- Inheritance in Java
- Method Overriding and super in Java
- Polymorphism in Java
- Abstraction, Abstract Classes and Interfaces in Java
- Composition, Aggregation and Association in Java
- The Object Lifecycle in Java
- Core Java
- Exception Handling
-
Collections
- The Java Collections Framework
- List in Java: ArrayList, LinkedList, Vector and Stack
- Set in Java: HashSet, LinkedHashSet and TreeSet
- Map in Java: HashMap, LinkedHashMap and TreeMap
- How HashMap Works Internally in Java
- Queue and Deque in Java: ArrayDeque and PriorityQueue
- Iterators in Java
- Comparable and Comparator in Java
- Collections Utilities and Choosing the Right Collection
- Generics
- Java 8+
- Stream API
- Date and Time
- File and I/O
-
Multithreading
- Threads in Java: Processes, Runnable and Thread
- Thread Lifecycle in Java
- Synchronization in Java: synchronized and volatile
- Locks and Atomic Classes in Java
- Race Conditions and Deadlocks in Java
- The Executor Framework and Thread Pools in Java
- Future and CompletableFuture in Java
- Concurrent Collections in Java
- The Java Memory Model
- JVM and Memory
- Advanced Java
- Networking
- JDBC
- Testing
Why immutability matters
- Thread safe automatically. Nothing changes, so nothing needs synchronising.
- Safe to share. No defensive copy is needed when handing it out.
- Reliable as a key. The hash code cannot drift.
- Easy to reason about. A value that never changes can be understood in one place.
- No invalid state. If the constructor validated it, it stays valid forever.
The five rules
- Make the class
final, so a subclass cannot add mutable state. - Make every field
private final. - Provide no setters and no method that changes state.
- Copy mutable arguments on the way in.
- Copy or wrap mutable fields on the way out.
Getting it wrong
public final class Booking {
private final LocalDate date;
private final List<String> guests;
public Booking(LocalDate date, List<String> guests) {
this.date = date;
this.guests = guests; // the caller keeps a reference
}
public List<String> guests() {
return guests; // and now so does everyone else
}
}List<String> guests = new ArrayList<>(List.of("Meera"));
Booking booking = new Booking(LocalDate.now(), guests);
guests.add("Arun"); // changed from outside
booking.guests().clear(); // and from the getterfinal stops the field being reassigned; it does nothing about the object it points to. Every mutable field is a hole in immutability until it is copied.Getting it right
public final class Booking {
private final LocalDate date;
private final List<String> guests;
public Booking(LocalDate date, List<String> guests) {
this.date = Objects.requireNonNull(date);
this.guests = List.copyOf(guests); // copy in, and the copy is immutable
}
public LocalDate date() {
return date; // LocalDate is already immutable
}
public List<String> guests() {
return guests; // safe: the list cannot change
}
public Booking withGuest(String guest) {
List<String> updated = new ArrayList<>(guests);
updated.add(guest);
return new Booking(date, updated); // a new object, not a mutation
}
}Copying in and out
| Field type | On the way in | On the way out |
|---|---|---|
String, wrappers, LocalDate, records | Nothing needed | Nothing needed |
List, Set, Map | List.copyOf(...) | Return the immutable copy |
| Array | values.clone() | values.clone() again |
| A mutable object of your own | A copy constructor | A copy, or expose only values |
java.util.Date | new Date(d.getTime()) | The same, or use Instant |
public final class Report {
private final int[] values;
public Report(int[] values) {
this.values = values.clone(); // arrays are always mutable
}
public int[] values() {
return values.clone(); // a fresh copy on every call
}
}Records
public record Booking(LocalDate date, List<String> guests) {
public Booking {
Objects.requireNonNull(date);
guests = List.copyOf(guests); // defensive copy in the compact constructor
}
public Booking withGuest(String guest) {
List<String> updated = new ArrayList<>(guests);
updated.add(guest);
return new Booking(date, updated);
}
}A record is shallowly immutable: the components cannot be reassigned, but a mutable component can still be changed through the reference. The compact constructor is the right place to close that.
Copy on write updates
public record Note(String title, String body, Set<String> tags, boolean published) {
public Note {
tags = Set.copyOf(tags);
}
public Note withTitle(String newTitle) {
return new Note(newTitle, body, tags, published);
}
public Note published() {
return new Note(title, body, tags, true);
}
}Note updated = note.withTitle("Java immutability").published();Each method returns a new object, so the original is untouched and can be shared safely. With many components a builder keeps this readable.
The cost, honestly
- Copying allocates, and allocation is not free.
- Updating one field of a large object rebuilds the whole object.
- A tight loop that rebuilds an immutable value on every pass is genuinely slower.
// Rebuilds a string on every iteration
String result = "";
for (String line : lines) { result += line; }
// A mutable builder locally, an immutable result at the end
StringBuilder builder = new StringBuilder();
for (String line : lines) { builder.append(line); }
String result = builder.toString();The practical pattern is a mutable buffer confined to one method, converted to an immutable value before it escapes. That keeps the performance and the safety.
Immutability in the standard library
String, Integer, Long, Double, Boolean // immutable
LocalDate, LocalDateTime, Instant, Duration // immutable
BigDecimal, BigInteger // immutable
List.of(...), Set.of(...), Map.of(...) // immutable
Optional // immutable
records // shallowly immutableList<String> view = Collections.unmodifiableList(source); // a read only VIEW
List<String> copy = List.copyOf(source); // an independent COPY
source.add("x");
System.out.println(view.size()); // changed
System.out.println(copy.size()); // unchangedAn unmodifiable view blocks writes through itself but still reflects changes to the backing collection. Only a copy is genuinely immutable.
Common mistakes
- Believing
finalmakes a collection unmodifiable. - Storing a caller supplied list or array without copying.
- Returning the internal collection from a getter.
- Returning an unmodifiable view and calling it immutable.
- Assuming a record is deeply immutable.
- Leaving the class non final, so a subclass can add mutable state.
Best practices
- Make immutability the default and justify mutability.
- Use records for value types.
- Copy mutable arguments in the constructor and mutable fields in getters.
- Prefer
List.copyOftoCollections.unmodifiableListat a boundary. - Provide
withXmethods for updates, and a builder when there are many fields. - Confine mutability to a single method where performance requires it.
Practice
- Make a class with a
Listfield genuinely immutable and demonstrate the fix. - Show that a record holding a mutable list can still be changed, then close the hole.
- Explain the difference between an unmodifiable view and an immutable copy with a short program.
- Add
withTitleandwithTagsmethods to a record. - Give one case where immutability is the wrong choice, and justify it.
Conclusion
Immutable objects are thread safe, shareable and impossible to corrupt. Make the class final, keep fields private and final, copy mutable state in and out, and use records with a validating compact constructor.