Immutability is a concurrency strategy, not a style preference

A configuration object is loaded at startup and shared by every request handler. It holds a Map of feature flags. A background thread refreshes it every thirty seconds by clearing the map and repopulating it.

Under load, a handler occasionally sees a flag that does not exist, or a partially populated map where half the flags are present. It happens perhaps once in ten million requests. It has been happening for a year. Three engineers have looked at it and concluded the flags are being written incorrectly upstream.

Nothing is written incorrectly. A reader is observing the map partway through the refresher’s clear-and-repopulate, which is two operations that were never atomic and never needed to be, because nothing said they had to be.

Mutation is what creates the problem

Every concurrency primitive exists to manage writes. Locks serialise them. Volatile makes them visible. Atomics make them indivisible. Memory barriers order them.

Remove the writes and you remove the reason for all of it. An object that cannot change after construction is safe to share across any number of threads with no coordination at all, because there is no state transition for a reader to catch halfway through.

That is not a mitigation. It is the removal of the failure mode.

// Every reader must be reasoned about. Every writer must hold the lock.
// Every future maintainer must know this.
public class Config {
    private final Map<String, Boolean> flags = new HashMap<>();

    public synchronized void refresh(Map<String, Boolean> next) {
        flags.clear();
        flags.putAll(next);
    }

    public boolean isEnabled(String key) {
        return flags.getOrDefault(key, false);   // not synchronized. bug.
    }
}
// Nothing to synchronise. Readers see one complete version or another,
// never a half-applied one.
public final class Config {
    private final Map<String, Boolean> flags;

    public Config(Map<String, Boolean> flags) {
        this.flags = Map.copyOf(flags);          // defensive copy, made once
    }

    public boolean isEnabled(String key) {
        return flags.getOrDefault(key, false);
    }
}

// The reference swaps atomically. Readers hold whichever version they got.
private final AtomicReference<Config> current = new AtomicReference<>(initial);

The second version has no synchronized, no lock ordering to get right, and no possibility of a reader observing a partial update. A reader that grabbed the old Config continues using it consistently until it finishes, which is usually the correct behaviour anyway.

flowchart TB
    subgraph mut["Shared mutable object"]
        W1["Refresher: clear()"] --> HALF["Map is empty<br/>mid-update"]
        HALF --> W2["Refresher: putAll()"]
        R1["Reader arrives here"] -.->|"observes"| HALF
    end

    subgraph imm["Immutable snapshot, swapped reference"]
        V1["Config v1<br/>complete"] --> SWAP["AtomicReference.set(v2)"]
        SWAP --> V2["Config v2<br/>complete"]
        R2["Reader arrives here"] -.->|"observes v1 or v2,<br/>never a partial one"| SWAP
    end

    style HALF stroke:#ef4444,stroke-width:3px,color:#fff
    style SWAP stroke:#4ade80,stroke-width:3px,color:#fff

Safe publication is the part people miss

Immutability gives you thread safety only if the object is published correctly, and Java is specific about what that means.

Final fields have a guarantee: if an object is properly constructed, any thread that sees a reference to it will see its final fields fully initialised, with no synchronisation. That guarantee is doing real work, because without it a reader could see a non-null reference to an object whose fields are still at their defaults.

The guarantee has a condition: the constructor must not let a reference to the object escape before it completes.

public final class Registry {
    private final List<String> names;

    public Registry(List<String> names) {
        this.names = List.copyOf(names);
        // Leaks 'this' before construction finishes. Another thread can now
        // see a Registry whose final field is not yet visible.
        GlobalCache.register(this);
    }
}

That is subtle enough that it is worth knowing as a rule rather than deriving each time: do not pass this anywhere inside a constructor.

Deep immutability, not just final fields

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

public final class Order {
    private final List<Item> items;          // final reference

    public Order(List<Item> items) {
        this.items = items;                  // caller still holds it
    }

    public List<Item> getItems() {
        return items;                        // caller can mutate it
    }
}

Both ends leak. The constructor stores a list the caller can keep modifying, and the getter hands out a reference to internal state.

public final class Order {
    private final List<Item> items;

    public Order(List<Item> items) {
        this.items = List.copyOf(items);     // unmodifiable snapshot
    }

    public List<Item> getItems() {
        return items;                        // already unmodifiable
    }
}

List.copyOf, Map.copyOf and Set.copyOf produce genuinely unmodifiable collections rather than views, which is the difference from Collections.unmodifiableList wrapping a list somebody else still holds.

Records give you most of this by construction, with the same caveat about the components themselves:

public record Order(Long id, List<Item> items) {
    public Order {
        items = List.copyOf(items);          // compact constructor, copies once
    }
}

The allocation objection

The reflexive complaint is that copying allocates, and allocation is expensive.

Copying does allocate. Whether it is expensive depends on how long the copies live, and copies made and discarded within a request live extremely briefly. Generational collectors are built for exactly that: the cost of a young collection scales with what survived, not with what was allocated, which means garbage that dies immediately is close to free. That is the same accounting that makes allocation rate rather than heap size the lever for tail latency.

The comparison that gets skipped is what mutability costs. A lock acquired on every read is not free. A mutable object shared across cores causes cache line invalidation on every write, so readers on other cores stall reloading it, which is the same mechanical problem as any shared state bouncing between core caches. And defensive copies happen anyway, scattered through the codebase, made by every caller who cannot prove the object is safe to hold.

Where the objection has teeth is a hot loop building a large structure incrementally. Copying a 100,000 element list on each of 100,000 iterations is quadratic and genuinely bad. The answer is to build mutably in a local scope where no other thread can see it, then freeze once at the boundary. Local mutability is not a concurrency problem, because there is nothing to share.

public List<Result> process(List<Input> inputs) {
    List<Result> acc = new ArrayList<>(inputs.size());   // local, unshared
    for (Input in : inputs) {
        acc.add(transform(in));
    }
    return List.copyOf(acc);                             // one copy, at the exit
}

Where it pays most

Anything shared across threads by construction: configuration, cached values, entries in a concurrent map, messages handed to another thread, objects captured by a lambda submitted to an executor.

Cache values are the case I would treat as non-negotiable. A mutable object stored in a cache is handed to every caller, and one of them will eventually mutate it, at which point every subsequent reader gets the mutated version. That bug presents as cache corruption and is nearly impossible to attribute, because the mutation happened somewhere unrelated to where it was observed.

Value objects and DTOs are the easy default. Money, identifiers, coordinates, date ranges: none of these have any reason to change after construction, and making them immutable costs nothing.

Entities with a genuine lifecycle are where mutability is honest. A JPA entity is mutable because the persistence context tracks changes, and fighting that produces worse code than accepting it.

The framing I have settled on is that mutability is a capability you should have to justify. Not because immutable code is more elegant, but because every mutable object shared between threads is a synchronisation requirement that lives in somebody’s head rather than in the type system, and heads are where these bugs come from.

Frequently Asked Questions

Why are immutable objects thread safe?
Because thread safety problems come from writes. If no thread can modify an object after construction, every thread reading it sees the same state, so there is no interleaving to reason about and no lock required. The Java memory model additionally guarantees that final fields are visible to any thread that sees a properly constructed object, without synchronisation, provided the constructor does not leak a reference to the object before it finishes.
Does immutability hurt performance because of extra allocation?
Usually far less than expected. Short lived objects die in the young generation, where collection cost scales with what survives rather than what was allocated, so garbage that is discarded quickly is close to free. The comparison people skip is what mutability costs: lock acquisition, cache line contention between cores, and defensive copies made throughout the codebase because nobody can prove the object will not be modified.
What is a defensive copy and when do you need one?
A defensive copy is duplicating a mutable object when accepting it as a parameter or returning it from a getter, so callers cannot modify your internal state afterwards. It is necessary whenever a class exposes or stores a mutable object it does not exclusively own, such as a Date, an array or a collection. Immutable types remove the need entirely, which is why the copies disappear rather than being written correctly everywhere.

Sources

[ RELATED_LOGS ]

TTFB: -- ms LOAD: -- s PAYLOAD: -- kb