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.

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

  1. Make the class final, so a subclass cannot add mutable state.
  2. Make every field private final.
  3. Provide no setters and no method that changes state.
  4. Copy mutable arguments on the way in.
  5. 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 getter
final 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 typeOn the way inOn the way out
String, wrappers, LocalDate, recordsNothing neededNothing needed
List, Set, MapList.copyOf(...)Return the immutable copy
Arrayvalues.clone()values.clone() again
A mutable object of your ownA copy constructorA copy, or expose only values
java.util.Datenew 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 immutable
List<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());   // unchanged

An unmodifiable view blocks writes through itself but still reflects changes to the backing collection. Only a copy is genuinely immutable.

Common mistakes

  • Believing final makes 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.copyOf to Collections.unmodifiableList at a boundary.
  • Provide withX methods for updates, and a builder when there are many fields.
  • Confine mutability to a single method where performance requires it.

Practice

  1. Make a class with a List field genuinely immutable and demonstrate the fix.
  2. Show that a record holding a mutable list can still be changed, then close the hole.
  3. Explain the difference between an unmodifiable view and an immutable copy with a short program.
  4. Add withTitle and withTags methods to a record.
  5. 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.

Written by Lorens Mishra

Default administrator account created by the installer.

Continue reading

All Java notes →
Java

Reflection in Java

Reflection inspects and manipulates classes at runtime. It powers most frameworks and should be rare in application code.

Read more
Java

Dynamic Proxies in Java

A dynamic proxy implements an interface at runtime and routes every call through one handler, which is how cross cutting behaviour is added.

Read more

Discussion

0 comments
Sign in to join the discussion.

No comments yet. Be the first to say something.