Ch. 7 · Java

Java Immutable Objects and Defensive Copies

How to make a class immutable in Java: final fields, no setters, defensive copies of mutable inputs and outputs, and safe publication.

~6 min readbeginnerupdated Oct 6, 2026

“How do you create an immutable class in Java?” is a staple interview question, and the usual checklist answer (make the class final, make fields private and final, remove setters) is only half right. The interviewer is waiting to see whether you mention defensive copies, because a class with only final fields can still be modified through a list or date it shares with its caller.

Immutability matters beyond interviews. Immutable objects are safe to share between threads, safe to use as map keys, and easy to reason about because their state never changes after construction. String, Integer, LocalDate and BigDecimal are all immutable for these reasons.

Before you start

You should understand reference types: a variable holds a reference to an object, and copying the variable copies the reference, not the object. You should also know final, the collection interfaces, and the difference between modifying a variable and modifying the object it points to.

The short answer

An immutable class guarantees that its observable state cannot change after construction. Make the class final (or give it a private constructor) so subclasses cannot add mutable behaviour, make every field private final, provide no mutators, and, critically, copy any mutable objects passed into the constructor and never return internal mutable objects directly. Use unmodifiable copies such as List.copyOf or immutable types such as java.time classes. Done properly, instances are thread-safe without synchronization.

How it works

final on a field prevents reassignment of the reference. It says nothing about the object the reference points to:

public final class Team {
    private final String name;
    private final List<String> members;

    public Team(String name, List<String> members) {
        this.name = name;
        this.members = members;         // stores the caller's list
    }

    public List<String> members() {
        return members;                 // hands out the internal list
    }
}

List<String> people = new ArrayList<>(List.of("Asha", "Ben"));
Team team = new Team("core", people);
people.add("Mallory");                  // team changes from outside
team.members().clear();                 // or from any reader
java

Both leaks come from aliasing: two parts of the program hold references to the same mutable list. Defensive copying breaks the alias at both boundaries:

public Team(String name, List<String> members) {
    this.name = Objects.requireNonNull(name);
    this.members = List.copyOf(members);   // copy in, unmodifiable, rejects nulls
}

public List<String> members() {
    return members;                         // safe: already unmodifiable
}
java

List.copyOf (Java 10+) returns an unmodifiable list. If the argument is already an unmodifiable list created by List.of or List.copyOf, it may return the same instance, avoiding a copy. Contrast it with Collections.unmodifiableList(list), which returns a read-only view of the original: anyone holding the original can still change what the view shows.

The Java Memory Model gives immutable objects a thread-safety bonus. Section 17.5 of the language specification guarantees that once a constructor finishes, any thread that sees a reference to the object also sees the correct values of its final fields, even without synchronization, as long as this did not escape during construction.

Step-by-step walkthrough

Step 1: Lock down the class and fields

public final class Booking {
    private final String guest;
    private final LocalDate checkIn;
    private final List<String> extras;
java

final on the class prevents a subclass from overriding accessors to return mutable state. Every field is private final, so it must be assigned exactly once in the constructor.

Step 2: Validate and copy in the constructor

    public Booking(String guest, LocalDate checkIn, List<String> extras) {
        this.guest = Objects.requireNonNull(guest, "guest");
        this.checkIn = Objects.requireNonNull(checkIn, "checkIn"); // LocalDate is immutable
        this.extras = List.copyOf(extras);
        if (this.extras.size() > 5) {
            throw new IllegalArgumentException("too many extras");
        }
    }
java

Validate after copying. If you validate the caller’s list first and copy afterwards, another thread could change the list between the check and the copy, a time-of-check to time-of-use bug.

Step 3: Return safe views and offer “wither” methods

    public List<String> extras() { return extras; }
    public LocalDate checkIn() { return checkIn; }

    public Booking withCheckIn(LocalDate newDate) {
        return new Booking(guest, newDate, extras);
    }
}
java

Instead of setters, return a new instance with the changed value. This is how String.toUpperCase and LocalDate.plusDays work.

Step 4: Handle legacy mutable types

If a field must be a java.util.Date or an array, copy it on the way in and on the way out:

private final Date created;
public Booking(Date created) { this.created = new Date(created.getTime()); }
public Date created() { return new Date(created.getTime()); }
java

Prefer Instant or LocalDateTime instead, which are immutable and need no copies.

Worked scenario

A payroll system builds a PayPeriod object and passes it to several calculation services running in parallel. PayPeriod holds a List<Shift> and was “immutable” because its fields were final. One service sorted the shifts for its own report:

Collections.sort(period.shifts(), comparing(Shift::start));
java

The sort reordered the shared internal list while another thread iterated it, producing a ConcurrentModificationException in production and, on other runs, overtime calculated against the wrong shift order. The bug was intermittent because it depended on thread timing.

The fix made the copy explicit and the shift objects themselves immutable:

public record Shift(Instant start, Instant end) {}

public final class PayPeriod {
    private final List<Shift> shifts;
    public PayPeriod(List<Shift> shifts) { this.shifts = List.copyOf(shifts); }
    public List<Shift> shifts() { return shifts; }
}
java

Now Collections.sort(period.shifts(), ...) throws UnsupportedOperationException immediately, which pushes the reporting service to sort its own copy: period.shifts().stream().sorted(...).toList(). A loud failure at the first call replaced a silent data race.

Common mistake

  • “final fields make a class immutable.” Final references can still point to mutable lists, arrays and dates.
  • Using unmodifiableList as a copy. It is a view; changes to the backing list show through.
  • Copying only the outer collection. List.copyOf(shifts) is shallow. If Shift has setters, the elements are still shared and mutable.
  • Letting this escape. Registering this with a listener or starting a thread inside the constructor can expose the object before its final fields are set.
  • Forgetting arrays. Returning a byte[] field gives the caller write access to every element. Return clone() or expose a read-only view.

Verify the behavior

import static org.junit.jupiter.api.Assertions.*;
import java.util.*;
import org.junit.jupiter.api.Test;

class TeamTest {
    static final class Team {
        private final List<String> members;
        Team(List<String> members) { this.members = List.copyOf(members); }
        List<String> members() { return members; }
    }

    @Test
    void callerChangesDoNotLeakIn() {
        var source = new ArrayList<>(List.of("Asha"));
        var team = new Team(source);
        source.add("Mallory");
        assertEquals(List.of("Asha"), team.members());
    }

    @Test
    void readersCannotModify() {
        var team = new Team(List.of("Asha"));
        assertThrows(UnsupportedOperationException.class, () -> team.members().add("x"));
    }

    @Test
    void unmodifiableViewStillReflectsSource() {
        var source = new ArrayList<>(List.of("Asha"));
        var view = Collections.unmodifiableList(source);
        source.add("Ben");
        assertEquals(2, view.size());   // view is not a copy
    }
}
java

Follow-up questions

Why is String immutable? It enables the string pool, safe use as HashMap keys with cached hash codes, thread safety, and security, since a validated file path or class name cannot change after the check.

Are records immutable? Their fields are final and there are no setters, but they are only shallowly immutable. A record with a List component needs a compact constructor that copies it.

What is the cost of immutability? Every change allocates a new object. For most domain objects this is cheap; for very hot loops, builders such as StringBuilder handle the mutable phase before producing an immutable result.

How do immutable objects help with HashMap keys? Their hash code cannot change after insertion, so they never get stranded in the wrong bucket.

Interview exercise

Review this class and list every way its state can change after construction:

public class Config {
    private final Map<String, String> values;
    private final String[] profiles;
    public Config(Map<String, String> values, String[] profiles) {
        this.values = values;
        this.profiles = profiles;
    }
    public Map<String, String> values() { return Collections.unmodifiableMap(values); }
    public String[] profiles() { return profiles; }
}
java

Answer and reasoning

There are three leaks. First, the constructor stores the caller’s map, so the caller can still add or remove entries, and the unmodifiable view returned by values() will show those changes. Second, the constructor stores the caller’s array, so the caller can overwrite elements. Third, profiles() returns the internal array, so any reader can overwrite elements too. Also, the class is not final, so a subclass could override the accessors.

The corrected version is final, stores Map.copyOf(values) and profiles.clone(), returns the map directly (it is already unmodifiable), and returns either profiles.clone() or, better, stores a List<String> from List.of(profiles). The interviewer wants to see you trace references across both the input and output boundaries.

Continue learning

Explore more with the Java interview questions and the Java MCQs. Related notes: records and shallow immutability, UnsupportedOperationException on lists and thread visibility and happens-before. The official tutorial on defining immutable objects lists the same rules.

More in Java

esc