Skip to main content
PRISM

Immutability

foundational · commonly asked

Publish a whole new version rather than editing in place, and a reader holds a consistent snapshot for as long as it likes. Switch to the mutable pair and watch it tear.

Enumerating the interleavings…

The problem it solves

Every race in this section requires the same ingredient: something changing while somebody else is looking at it. Remove that and there is nothing left to synchronise.

Immutable data cannot be modified after construction. A reader takes one reference and holds a consistent view of it for as long as it likes, because what it is looking at can no longer change. No lock, no atomic, no memory barrier, no ordering rule. The torn read on the reader-writer lock page depends on a moment when the record is half-updated, and an immutable record has no such moment — not because it is prevented, but because it is not representable.

Updates still happen, of course. A writer builds a new version and publishes it with a single store. Readers holding the old version keep reading it, consistently, until they next look. The mutation moves from “modify the thing everybody is reading” to “swap which thing everybody reads next”, and that single store is the only synchronisation the whole design needs.

The mechanism

The record here is packed into one word — the digits standing in for a pointer to a frozen object — so publishing a new version is one store and reading it is one load. Both fields arrive together or not at all.

The property that does the work is not “the writer is careful”. It is that one load gives you a complete snapshot. Two loads can straddle an update; one cannot, and everything a reader derives afterwards is derived from a value that was consistent at the instant it was read.

The store must still be published, which is a visibility question rather than an atomicity one — see memory barriers. In practice the reference is volatile, an AtomicReference, or the language’s equivalent, and the pattern is release-store / acquire-load: build the object fully, then publish the pointer. Java’s final fields make this particularly clean, guaranteeing that anything assigned in a constructor is visible to any thread that sees the object, with no synchronisation at all.

What the enumeration shows

Two variants, identical readers, identical check.

immutability: the writer publishes one word, readers take one snapshot and split it into two fields. 0 torn reads across all 2,772 interleavings — exhaustively, so this is a proof rather than an absence of failures.

immutability-mutable: the same record as two mutable fields. The writer now needs two stores, so there is a moment between them when the record is half updated. 1,304 of 3,150 interleavings catch a reader in that moment — over 40%.

The pairing matters. The check is only worth something because it demonstrably fires on one of the two programs; a detector that never triggers proves nothing about the program it is watching. Same readers, same predicate, different data model, opposite results.

The numbers worth carrying

  • Immutable snapshot: 0 of 2,772 torn reads. Mutable pair: 1,304 of 3,150.
  • The cost is allocation: every update allocates a new version. For data read constantly and written rarely — configuration, routing tables, feature flags, a compiled ruleset — this is obviously right, and the read path becomes cheaper than any lock could be.
  • Persistent data structures make updates cheaper than a full copy by sharing structure. A HAMT-based map updates one path from root to leaf, so an update to a 1M-entry map allocates on the order of log₃₂(1M) ≈ 4 nodes rather than a million. Roughly O(log n) allocation, not O(n).
  • Read cost: one load. No lock acquisition, no reader count to increment, no coherence traffic on shared bookkeeping — which is precisely the overhead that makes reader-writer locks disappointing on read-heavy workloads.

Where it breaks down

Write-heavy workloads pay. Allocating on every update, plus the garbage collection that follows, is worse than mutating in place when writes dominate. Immutability is a read-optimised trade.

Readers can hold stale data indefinitely. A reader with a reference to version 3 keeps using it while the world moves to version 9. That is usually what you want — consistency over freshness — and occasionally it is not, and it is a design decision either way rather than a property to discover later.

Read-modify-write across versions still races. Read the current version, compute a new one from it, publish — and two writers can both read version 3 and both publish a version 4 built from it, losing one update. Immutability makes reads safe; concurrent updates still need compare-and-swap on the reference, and then you are back to a retry loop.

Deep immutability is required. An immutable object holding a mutable list is mutable. List.of(...) wrapping objects with setters gives you a frozen container of things that can change underneath the reader.

Memory pressure is real. Many versions alive at once — long-running readers each holding a different one — means many copies retained. Usually fine, occasionally the thing that surprises you.

What people get wrong

“Immutable means slow.” Reads get faster: one load with no synchronisation at all. Writes get slower. Whether that is a good trade is decided by your read/write ratio, and for most shared configuration it is not close.

“final/readonly makes it immutable.” It makes the reference unchangeable. final List<String> items can still have things added to it. Deep immutability is a property of the whole graph.

“Copy-on-write means copying everything.” Persistent structures share almost all of it. An update to a large map touches a handful of nodes.

“I still need a lock to publish.” One atomic reference store is sufficient. A lock would work and is not necessary, and the difference matters on the read side.

In production

Where this is already the default: Clojure’s data structures, Scala’s immutable collections, Erlang’s terms, Rust’s ownership model (which makes shared-and-mutable a compile error rather than a convention), Java records and List.of/Map.of, and every React state model in front-end code.

The pattern to reach for: an AtomicReference to an immutable snapshot, updated with updateAndGet when concurrent writers exist and a plain set when there is one writer. That is the whole implementation of a hot-reloadable configuration, a routing table, a compiled ruleset, a feature-flag set — read millions of times a second with zero synchronisation on the read path, and updated by swapping a pointer.

The reason to prefer it over a reader-writer lock for that shape of problem: an RW lock’s read side writes to a shared reader count, which is itself contended. An immutable snapshot’s read side writes nothing. On read-heavy workloads the difference is not small, and it comes with a stronger correctness story — see readers-writer locks for the comparison, and actors for the other way of removing shared mutable state.

The follow-up questions

“How does immutability help with concurrency?” — There is no moment at which the data is half updated, so a reader holding a reference is holding something consistent. No lock needed on the read path.

“What does it cost?” — Allocation per update. Fine when reads dominate, wrong when writes do.

“You have an immutable config object. How do you update it?” — Build a new one, swap an atomic reference. And if concurrent writers exist, compare-and-swap so one does not clobber the other.

“Is final enough?” — No. It fixes the reference, not the object graph. This is the question that finds out whether “immutable” means anything specific to the candidate.

In an interview

Why persistent data structures and copy-on-write show up in concurrent code, and the cheapest correct answer to a surprising number of race questions.

  • immutability
  • copy-on-write
  • snapshot
  • persistent structures

Run these next

The rest of patterns in practice