Beginner12 min readConcurrency & Thread Safetylive prototype

Immutable Objects for Safety

Every concurrency bug in this phase needs the same three ingredients: shared data, mutable data, and no coordination. Take away mutable and the whole class of bugs disappears — an object that can never change needs no lock, no volatile, no careful publishing. Think of a printed boarding pass: anyone can hold a copy and read it safely, because nobody can edit it while you're looking. Change the gate and you print a new pass; your old one stays a perfectly valid snapshot of an earlier moment.

The idea

What it is

Look back at every bug in this phase — the race condition, the stale value, the half-built singleton. They all need three things at once: data that is shared between threads, data that is mutable, and no coordination between the threads touching it. Locks attack the third ingredient: they add coordination. Immutability attacks the second one, and it is by far the cheapest fix — because a bug that cannot be expressed costs nothing to prevent.

Here's the mental model: a printed boarding pass versus a shared whiteboard. The whiteboard holds today's gate number, and anyone can walk up and rewrite it — so everyone has to take turns, and if you read while someone is mid-rewrite you get half of the old gate and half of the new one. A printed pass is different: your copy says Gate A1 and it will always say Gate A1. Ten people can read ten copies at the same time with no queue, no marker, no turn-taking. When the gate changes, the airline prints a new pass — and your old one is still a consistent snapshot of an earlier moment, not garbage.

You already know how to build such an object from Immutability & value objects — final fields, no setters, withX() instead of setX(). This lesson is about why that is a concurrency superpower: an immutable object needs no lock at all, and can be shared by any number of threads, forever, with zero synchronization.

The one sentence to remember

If an object never changes, there is no write to race with — so any number of threads may read it at the same time with zero locks, and no reader can ever see a half-updated state.

You've met it already

Java's String, Integer, LocalDate and record types; Python's tuple, str and frozenset; every message you push through a queue or a Go channel. These are handed between threads all day long and nobody ever locks them — because there is nothing to lock.

Mechanics

How it works

Three ingredients — remove one and the bug is gone

A data race needs shared and mutable and uncoordinated. That gives you three ways out, and they are not equally priced:

  • Remove shared — give each thread its own copy or confine the object to one thread. Works, but you often need the sharing.
  • Remove uncoordinated — add a lock (Locks, Mutex, Semaphore) or a read-write lock (Read-Write locks). Correct, but now you own a lock: contention, blocked readers, and a chance to deadlock (Deadlock, race conditions, starvation).
  • Remove mutable — make the object immutable. No lock to acquire, no lock to forget, nothing to contend on, and readers never block each other. This is the cheapest of the three whenever it fits.

The torn read: a state that never legally existed

This is the bug the prototype dramatises. A writer updates two fields of a shared object. In the source it looks like a single change; at runtime it is two separate writes with a gap in between. A reader that arrives in that gap sees the new x and the old y — an object that is internally inconsistent, a combination the program never intended to produce. That's a torn read (also called an inconsistent or half-updated read):

TornRead.java
// MUTABLE and shared — the writer's "one change" is really two writes.
class Point {
    int x = 1, y = 2;               // invariant we care about: x == y
}

Point p = new Point();              // shared by every thread

// writer thread
p.x = 9;                            // ← step 1
                                    // ← a reader arriving HERE sees x:9, y:2
p.y = 9;                            // ← step 2

// reader thread
System.out.println(p.x + "," + p.y); // may print 9,2 — a state that
                                     // never legally existed

Notice what makes this so nasty: nothing crashed, no exception was thrown, and the reader's own code is flawless. It simply observed a moment that the writer never meant to expose. Bugs like this appear once in ten thousand runs and are almost impossible to reproduce on demand.

Change by copy — the old object stays valid

An immutable object cannot be half-updated, because it is never updated at all. A "change" is a method like withX(9) that builds and returns a brand-new object and leaves the original exactly as it was. That's the boarding pass being reprinted. Crucially, a thread still holding the old reference is not broken or stale-in-a-dangerous-way — it holds a consistent snapshot of an earlier moment, which is almost always fine and sometimes exactly what you want (a report, a request being served, a frame being rendered).

Point.java
// A record is immutable: fields are final, there are no setters.
public record Point(int x, int y) {

    // "mutators" are really factories — the original is never touched.
    public Point withX(int newX) { return new Point(newX, y); }
    public Point moveTo(int nx, int ny) { return new Point(nx, ny); }
}

Point v1 = new Point(1, 2);
Point v2 = v1.moveTo(9, 9);   // v2 is a NEW object, fully formed
// v1 is STILL (1, 2) for anyone holding it — a valid older snapshot.
// There is no moment in which any Point exists as (9, 2).

Safe publication for free

There is a second, subtler win. Normally, handing a freshly built object to another thread is risky: the other thread can see the reference before it sees the fields the constructor wrote — the classic broken double-checked lock in Thread-safe Singleton. Java closes that hole for immutable objects: if a field is final, any thread that sees the object at all is guaranteed to see that field correctly initialised, with no volatile and no lock. That guarantee is written into the language spec (the final field semantics of the Java Memory Model). Immutable objects are therefore safe to publish by any means at all — a plain field, a HashMap, a queue, a callback.

Plain words: "safe publication"

Publishing an object means making it visible to another thread. Safely published means the other thread is guaranteed to see the object fully built, not a half-constructed shell. Mutable objects need a lock, a volatile, or a concurrent collection to be published safely. Immutable objects with final fields need nothing.

The atomic swap: keep the mutable part to one reference

"Nothing ever changes" is fine for a Money or a Point, but real programs do change — configuration reloads, prices update, routing tables shift. The idiom that makes immutability practical is to shrink the mutable part down to a single reference, and make every change one pointer swap. The data is immutable; only the pointer to the current version moves. Because swapping a reference is a single atomic operation, every reader sees either the whole old version or the whole new version — never a mixture. That is exactly the current ▼ arrow swinging from v1 to v2 in the prototype.

ConfigHolder.java
public record Config(String host, int port, int timeoutMs) {}

// The ONLY mutable thing in the whole design: one reference.
static final AtomicReference<Config> CURRENT =
        new AtomicReference<>(new Config("a.example", 80, 500));

// Readers — thousands of threads, no lock, never blocked.
Config c = CURRENT.get();        // one read gets a consistent whole
use(c.host(), c.port());         // c can NEVER change under us

// Writer — build the new version, then swap in one step.
Config next = new Config("b.example", 443, 800);
CURRENT.set(next);               // all-or-nothing for every reader

Note what readers get here: CURRENT.get() once, into a local variable, and from then on the object is frozen for as long as they need it. A reader that started before the swap simply finishes its work against the older config — consistently. This is the same shape as copy-on-write collections (CopyOnWriteArrayList), where a mutation copies the whole array and swaps the reference: reads are free and lock-free, writes are expensive.

The shallow trap — an object is only immutable if everything it holds is too

A final List<String> tags field freezes the reference, not the list. Any thread that can reach it can still call tags.add("x") and mutate your "immutable" object through the back door — and now you have a data race again, in a class you believed was safe. Fix it on both sides: copy on the way in (this.tags = List.copyOf(tags)) so the caller's list isn't yours, and never hand the live collection out of a getter. This is the 🕳 shallow trap chip in the prototype: final reference ≠ frozen contents.

What it costs

  • Allocation and GC pressure — every change makes a new object. For small value objects in a modern runtime this is close to free; in a hot loop that rebuilds a large object millions of times, it is not.
  • Copying big structures — replacing one entry in a 100 000-element immutable map by copying the whole map is O(n) per write. Persistent (structural-sharing) data structures solve this by sharing the untouched parts between versions, giving O(log n) "copies" — that's what Clojure's collections, Scala's Vector, and libraries like Immutable.js do.
  • Write-heavy contention moves, it doesn't vanish — with an atomic swap, two writers can still clash. Use compareAndSet in a retry loop rather than blind set, or accept a lock on the (rare) write path.
  • Ceremony — constructors, builders, withX() methods and defensive copies are more typing than a setter. Records, frozen dataclasses and const cut most of it away.

Where it shines

  • Configuration and lookup tables — read constantly by everything, changed rarely by one reloader. The atomic-swap idiom was practically invented for this.
  • Value objectsMoney, Point, LocalDate, Currency, IDs. Small, shared everywhere, and safe map keys precisely because their hash can never change.
  • Messages between threads — anything you hand to a queue, an executor, a channel or an actor should be immutable, so sender and receiver can't stomp on each other after the handoff.
  • Functional and actor styles — Clojure, Erlang/Elixir and Akka build their entire concurrency story on "never mutate shared data", which is why they need so few locks.
  • Caches and snapshots — a cached immutable result can be handed to every caller with no defensive copying at all.

One line per language

  • Javarecord gives you final fields, value equality and the final-field publication guarantee; List.copyOf / Map.copyOf make truly unmodifiable copies; AtomicReference holds the one mutable pointer.
  • Pythontuple, frozenset and @dataclass(frozen=True); rebinding a module-level or attribute reference is atomic under the GIL, which makes the swap idiom easy (use a lock only around read-modify-write).
  • C++const members and const methods; share as std::shared_ptr<const Config> and swap it with std::atomic<std::shared_ptr<const T>> so readers never lock.
  • Go — structs are values, so passing one copies it; keep the shared thing a sync/atomic.Pointer or, better, follow the proverb "don't communicate by sharing memory; share memory by communicating" and send immutable values over a channel.

The design habit

When you catch yourself reaching for a lock, ask first: does this object have to change at all? Very often the answer is no, and the lock — plus every bug that lock could ever have — simply evaporates.

Interactive prototype

See it. Build it. Break it.

A sandboxed, hands-on simulation — no setup, no install. Play with it as you read.

About this simulation

One shared Point, one ✍️ Writer, two 👓 Readers. In ✏️ Mutable mode press ▶ Update (x, y) — the write lands in two visible micro-steps (x = 9 … then y = 9), and Reader A, reading in the gap, captures x: 9 · y: 2 and flashes red with a ⚠️ torn read chip: a state that never legally existed. The chip 🔒 Add a lock fixes it and you immediately see the price — both readers grey out as ⏳ blocked while the writer holds the lock. Now flip to 🔒 Immutable and press the same button: a whole new card v2 · x: 9 · y: 9 appears beside the old one, then the blue current ▼ pointer swings across in one step. Reader A keeps showing a consistent v1 · x: 1 · y: 2; Reader B reads v2. Nobody ever sees a mix, and the readout stays locks needed: 0 · torn reads: 0 · readers blocked: 0. The 🕳 shallow trap chip shows the one way this still breaks.

Hands-on

Try these yourself

Open the prototype above, predict what happens, then verify.

try 01

Break it in Mutable mode

Start in ✏️ Mutable and press ▶ Update (x, y). The writer lane shows x = 9 (step 1 of 2) while the card still reads y: 2 and glows amber. In that gap Reader A reads and flashes red with ⚠️ torn read, showing x: 9 · y: 2 — a state that never legally existed. The readout ticks to locks needed: 1 · torn reads: 1. Reader B, arriving after step 2, is fine — which is exactly why this bug hides in testing.

try 02

Pay for the fix, then stop paying

Click the 🔒 Add a lock chip and press ▶ Update again. No torn read this time — but both readers go amber ⏳ blocked while the writer holds the lock, and readers blocked climbs. Now flip the toggle to 🔒 Immutable and press ▶ Update (x, y). A new card v2 · x: 9 · y: 9 appears fully formed beside v1, then the blue current ▼ pointer swings across in one step. Readouts: locks needed: 0 · torn reads: 0 · readers blocked: 0.

try 03

Old snapshots, and the one way it still breaks

In 🔒 Immutable mode, notice Reader A still shows v1 · x: 1 · y: 2 — it holds an older reference and that snapshot is perfectly consistent, not corrupt. Press 👓 Read now and both readers jump to the current version, with no lock taken. Finally press the 🕳 shallow trap chip: point.tags.add("x") mutates every version through the shared list and the cards flash red — proof that a final reference is not frozen contents. Press ↺ Reset to start over.

In practice

When to use it — and what trips people up

Make it immutable when...

  • Many threads read it and few (or none) change it — config, feature flags, routing tables, pricing rules, compiled regexes, lookup maps. Readers never block each other and never need a lock.
  • It's a value — money, dates, coordinates, identifiers, units. Its identity is its data, so there is no reason it should ever mutate, and it becomes a safe map/set key for free.
  • It crosses a thread boundary — anything handed to a queue, executor, channel, actor or callback. Immutable payloads mean sender and receiver can't corrupt each other after the handoff.
  • You want safe publication without ceremony — final fields let you share the object through a plain field or a plain HashMap with no volatile and no lock.

Keep it mutable when...

  • Writes are frequent and the object is large — rebuilding a big structure on every edit will cost more than a well-scoped lock. Reach for a persistent data structure first, a lock second.
  • The thing genuinely has a lifecycle — an in-progress Order, a game world, a connection pool. Model those as mutable entities, but build them out of immutable values.
  • It's confined to one thread anyway — a local buffer or a per-request builder is not shared, so mutating it is free of risk. (Build mutably in private, then freeze and publish.)
  • You need in-place performance — tight numeric loops over big arrays are the classic case where copying is simply not an option.

What it gives you

  • Zero locks — an immutable object can be read by any number of threads at once, with no synchronization and no contention.
  • Torn reads become impossible — there is no half-updated state to observe, so readers always see an internally consistent object.
  • Safe publication for free — final fields are guaranteed visible after construction, so you can share the object through any channel with no volatile.
  • Old references stay valid — a reader holding a previous version keeps a coherent snapshot instead of watching data shift mid-computation.

Common mistakes

  • Allocation and GC pressure — every change creates a new object, which matters in hot write paths.
  • Copying large structures is O(n) per write unless you use persistent/structural-sharing collections.
  • Writers still need care — two threads swapping the same reference must use compare-and-swap or a lock, or one update is silently lost.
  • Shallow immutability is a trap — a final reference to a mutable list or map leaves the door wide open, and the class looks safe.

Reference

Code & further reading

A minimal reference implementation and pointers worth bookmarking.

import java.util.*;
import java.util.concurrent.atomic.AtomicReference;

// ---- The immutable value. A record's fields are final, so any thread
// ---- that sees this object is GUARANTEED to see it fully built.
public record Config(String host, int port, List<String> tags) {

    // Compact constructor: defensive copy IN.
    // Without this, the caller keeps a live handle on our list.
    public Config {
        tags = List.copyOf(tags);      // truly unmodifiable, not just final
    }

    // "Change" = build a new object; this one is never touched.
    public Config withPort(int newPort) {
        return new Config(host, newPort, tags);
    }
}

public class ConfigService {

    // The ONLY mutable thing in the design: a single reference.
    private static final AtomicReference<Config> CURRENT =
            new AtomicReference<>(new Config("a.example", 80, List.of("v1")));

    // Readers: any number of threads, no lock, never blocked.
    static void handleRequest() {
        Config c = CURRENT.get();      // one read → a consistent whole
        // c can never change under us, so no torn read is possible
        System.out.println(c.host() + ":" + c.port());
    }

    // Writer: build the new version, then swap the pointer atomically.
    static void reload(int newPort) {
        Config next = CURRENT.get().withPort(newPort);
        CURRENT.set(next);             // readers see all-old or all-new
    }

    // Read-modify-write? Use compareAndSet so two writers can't lose an update.
    static void bumpPort() {
        Config old, next;
        do {
            old = CURRENT.get();
            next = old.withPort(old.port() + 1);
        } while (!CURRENT.compareAndSet(old, next));
    }

    public static void main(String[] args) throws Exception {
        Thread reader = new Thread(() -> { for (int i = 0; i < 3; i++) handleRequest(); });
        Thread writer = new Thread(() -> reload(443));
        reader.start(); writer.start();
        reader.join();  writer.join();
        // Zero locks were taken. Zero torn reads are possible.
    }
}

References & further reading

7 sources

Knowledge check

Did it land?

Quick questions, answers revealed on submit. Sign in to save your best score.

question 01 / 06

Why is an immutable object thread-safe without any lock?

question 02 / 06

In the prototype's Mutable mode, Reader A reports x: 9 · y: 2. What is that?

question 03 / 06

A class has only final fields, but one of them is a List that other code can still call .add() on. Is it safe to share across threads?

question 04 / 06

Configuration must be reloadable, but you want lock-free readers. What's the standard idiom?

question 05 / 06

After an atomic swap to v2, a worker thread is still holding its reference to v1. What should you do?

question 06 / 06

What is the main practical cost of an immutable-first design?

0/6 answered