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.
// SPONSORSHIP
If this research saved you time or improved your architecture, consider sponsoring my work on GitHub. All sponsorships go directly toward infrastructure and further technical research.
[ Become a Sponsor ]