Skip to content
JavaAgentic

Type at least two characters. Try “RAG”, “pgvector” or “tool calling”.

Immutable Objects & Defensive Copying

The five conditions for a genuinely immutable class, the leaked-collection bug that defeats final, why records are only shallowly immutable, and what final fields guarantee across threads.

Intermediate6 min readUpdated
On this page

"Make it immutable" is advice everyone repeats and fewer can implement correctly, because Java gives you three ways to think you have done it while a caller can still change your object's state.

Key Takeaways

  • Five conditions: final class, private final fields, no mutators, copy in the constructor, copy out of the getters.
  • final on a field freezes the reference, not the object it points at. A final List is still mutable.
  • Collections.unmodifiableList is a view; List.copyOf is a snapshot. Only the second is a defensive copy.
  • A record is shallowly immutable. Mutable components leak.
  • Final fields give safe publication — a correctly constructed immutable object crosses threads with no synchronisation.

The five conditions

Money.java
public final class Reservation {                     // 1. final: no subclass can add mutability
    private final String guest;                      // 2. private final fields
    private final LocalDate arrival;
    private final List<String> roomNumbers;
 
    public Reservation(String guest, LocalDate arrival, List<String> roomNumbers) {
        this.guest = Objects.requireNonNull(guest);
        this.arrival = Objects.requireNonNull(arrival);
        // 4. Defensive copy IN. Without this the caller keeps a live handle
        //    to our internal list and can mutate it after construction.
        this.roomNumbers = List.copyOf(roomNumbers);
    }
 
    // 3. No setters, and no method that changes state.
 
    public List<String> roomNumbers() {
        // 5. Defensive copy OUT. List.copyOf() already returned an immutable
        //    list, so returning the field directly is safe here — but say so,
        //    because it stops being safe the moment someone changes line 13.
        return roomNumbers;
    }
 
    /** Mutation returns a new instance — the "wither" pattern. */
    public Reservation withArrival(LocalDate newArrival) {
        return new Reservation(guest, newArrival, roomNumbers);
    }
}

Condition 1 exists for a reason people miss: without final on the class, a subclass can add a mutable field and override an accessor, and callers holding your declared type cannot tell.

Conditions 4 and 5 are the ones that actually get skipped. Here is the bug in isolation:

what happens without copy-in
List<String> rooms = new ArrayList<>(List.of("101"));
Reservation r = new Reservation("ada", LocalDate.now(), rooms);
 
rooms.add("102");        // caller still holds the reference...
r.roomNumbers();         // ...and the "immutable" object just changed

final did nothing here, because final freezes the reference and says nothing about the object it points to. This is the single most common misconception about immutability in Java, and a reliable interview probe.

Unmodifiable is not immutable

the view versus the snapshot
List<String> source = new ArrayList<>(List.of("a", "b"));
 
List<String> view = Collections.unmodifiableList(source);
List<String> snapshot = List.copyOf(source);
 
source.add("c");
 
view.size();       // 3 — the view sees through to the original
snapshot.size();   // 2 — independent copy
 
view.add("d");     // UnsupportedOperationException
snapshot.add("d"); // UnsupportedOperationException

Both reject mutation through them. Only List.copyOf is independent of the source. unmodifiableList is the right tool for returning a read-only window onto state you own and intend to keep changing — which is a different job from defensive copying.

Three more distinctions worth having ready:

  • Arrays.asList(a, b) is fixed-size but mutableset() works, add() throws, and it writes through to the backing array.
  • List.of(...) is genuinely immutable and rejects nulls, which surprises code migrating from Arrays.asList.
  • List.copyOf returns the argument unchanged if it is already one of the immutable implementations, so the copy is free in the common case.

Records are shallowly immutable

the leak
public record Order(String id, List<String> lines) { }
 
List<String> lines = new ArrayList<>();
Order order = new Order("A-1", lines);
lines.add("injected");        // mutates the "immutable" record
order.lines();                // ["injected"]

A record generates final fields and no setters, which handles conditions 1–3. It does nothing about 4 and 5. The fix is a compact constructor plus a copying accessor:

Order.java
public record Order(String id, List<String> lines) {
 
    // Compact constructor: runs before the fields are assigned, so
    // reassigning the parameter changes what gets stored.
    public Order {
        Objects.requireNonNull(id);
        lines = List.copyOf(lines);      // copy IN
    }
 
    // Not needed here — List.copyOf already produced an immutable list — but
    // required whenever a component is an array or another mutable type.
    @Override
    public List<String> lines() { return lines; }
}

An array component always needs both directions, because there is no immutable array:

array component
public record Payload(byte[] bytes) {
    public Payload { bytes = bytes.clone(); }
    @Override public byte[] bytes() { return bytes.clone(); }
}

Note that a record's generated equals() compares arrays by reference, so a record with an array component also needs a hand-written equals/hashCode using Arrays.equals. Records are a convenience for value types with immutable components; the moment a component is mutable you are back to writing the class properly.

Thread safety for free

This is the payoff, and the reason immutability is worth the copying.

The Java Memory Model makes a specific promise about final fields: if an object is correctly constructed — meaning the this reference does not escape the constructor — then any thread that sees a reference to that object is guaranteed to see the fully initialised values of its final fields, without any synchronisation.

safe publication without a lock
public final class Config {
    private final Map<String, String> values;
 
    public Config(Map<String, String> values) {
        this.values = Map.copyOf(values);
        // 'this' must not escape here — no registering listeners,
        // no starting threads, no adding to a static registry.
    }
 
    public String get(String key) { return values.get(key); }
}
 
// Elsewhere: publishing through a plain non-volatile field is safe.
static Config current;
current = new Config(loaded);      // another thread reading `current` either
                                   // sees null or a fully built Config

Without final fields, a reader on another core could legally observe a non-null reference whose fields are still at their defaults — the classic unsafe-publication bug that made double-checked locking broken before Java 5. Immutability plus final fields removes the whole class of problem.

Where it costs you

Immutability is not free, and an interviewer who asks about it usually wants to hear you say so.

Every "mutation" allocates. For a hot loop building a value step by step, that is real garbage — the standard answer is a mutable builder that produces one immutable result at the end, which is exactly what StringBuilder is to String.

Deep copies of large collections on every construction can dominate a request. Persistent data structures (structural sharing, as in Vavr or Clojure's collections) avoid it, at the cost of a dependency and unfamiliar types.

And ORM entities cannot be immutable in the usual sense — Hibernate needs a no-arg constructor and field access. The pragmatic pattern is a mutable entity at the persistence boundary and an immutable record as the domain and API type, with an explicit mapping between them.

Frequently Asked Questions

Is a record immutable?
A record is shallowly immutable: its component fields are final and there are no setters. If a component is a mutable type — a List, a Date, an array — the record hands out the same reference every caller can mutate. Making a record deeply immutable requires a compact constructor that copies incoming collections and accessors that return copies or unmodifiable views.
What is the difference between Collections.unmodifiableList and List.copyOf?
unmodifiableList returns a read-only view over the original list, so changes to the original are still visible through the view. List.copyOf takes a snapshot into a genuinely independent immutable list. For defensive copying you almost always want copyOf; unmodifiableList only prevents the caller from mutating, not you.
Why do immutable objects not need synchronisation?
Because there is no state transition for another thread to observe halfway through. Combined with the Java Memory Model guarantee for final fields — any thread that sees a properly constructed object sees its final fields fully initialised — an immutable object can be shared across threads with no locking and no volatile.

Related tutorials