Loading
Loading
Concurrency in Object Design
A race needs shared, mutable state. Take away either word and the race cannot happen — that is most of thread-safe design.
By the end of this chapter
Every concurrency bug in an object model has the same three ingredients: some state is reachable from more than one thread, at least one of those threads changes it, and nothing coordinates the accesses. That is a useful definition because it is a checklist. If you can remove any one of the three, there is nothing left to go wrong.
final class HitCounter { private int count = 0; void hit() { count++; // read, add, write: three steps, not one } int count() { return count; }}count = 41; threads T1 and T2 both call hit()T1 reads 41T1 loads the field into a register.T2 reads 41T2 loads the same, still-unchanged value.T1 writes 42T1 adds one and stores.T2 writes 42T2 adds one to its stale copy and overwrites T1's work.count = 42 — one hit has vanished, and no exception said soConcept
Shared (reachable from more than one thread), mutable (some thread changes it after construction), uncoordinated (no lock, atomic or queue orders the accesses). Thread-safe design is choosing which ingredient to remove, and the order of preference is: stop sharing, then stop mutating, and only then coordinate.
An object that cannot change after its constructor finishes can be handed to any number of threads with no further thought. In Java that takes more than skipping the setters: the class is final so no subclass can add mutable state, every field is private final, mutable inputs are copied on the way in, and nothing mutable is handed out on the way out.
public final class Itinerary { private final String travellerId; private final List<Leg> legs; // Leg is itself a record public Itinerary(String travellerId, List<Leg> legs) { this.travellerId = Objects.requireNonNull(travellerId); this.legs = List.copyOf(legs); // defensive copy, and unmodifiable } public List<Leg> legs() { return legs; } // safe to hand out public Itinerary withLeg(Leg leg) { // "change" = new object List<Leg> next = new ArrayList<>(legs); next.add(leg); return new Itinerary(travellerId, next); }}Before and after
Mutable Address, shared
Immutable Address, swapped
from dataclasses import dataclass, replacefrom decimal import Decimal @dataclass(frozen=True)class Money: amount: Decimal currency: str def plus(self, other: "Money") -> "Money": if other.currency != self.currency: raise ValueError("currency mismatch") return replace(self, amount=self.amount + other.amount) @dataclass(frozen=True)class Basket: lines: tuple[Money, ...] # tuple, not list: frozen all the way downSometimes a value really does need to change while other threads are reading it — a configuration, a routing table, a set of feature flags. The pattern is copy-on-write: keep an immutable snapshot behind an AtomicReference, let readers grab whichever snapshot is current, and let writers build a new snapshot and swap it in atomically.
final class FeatureFlags { private final AtomicReference<Map<String, Boolean>> current = new AtomicReference<>(Map.of()); boolean isOn(String flag) { // readers never block return current.get().getOrDefault(flag, false); } void set(String flag, boolean on) { current.updateAndGet(old -> { // may retry under contention, Map<String, Boolean> next = new HashMap<>(old); // so no side effects here next.put(flag, on); return Map.copyOf(next); }); }}The other way to remove an ingredient is to stop sharing. An object that only one thread can reach is thread-safe without being immutable, because there is nobody to race with. The discipline is making sure the reference never escapes.
Approaches
Values: money, dates, ids, coordinates, events, configuration snapshots. Anything whose identity is its contents.
Working state that belongs to one request or one owner thread, and can stay that way if references are not leaked.
State that genuinely must be shared and changed in place — a seat map, an account balance. Keep it inside one owning type.
A last subtlety. Building an object correctly is not enough if another thread can see the reference before the constructor's writes are visible. Java's memory model guarantees that final fields are seen fully initialised by any thread that obtains the reference, provided `this` did not escape during construction — another reason immutable classes are the easy case. For mutable objects, publish through something that provides the ordering: a volatile field, an atomic reference, a concurrent collection, or a lock.
| Kind of type | Default strategy |
|---|---|
| Value object (Money, DateRange, Position) | Immutable |
| Per-request working object (builder, command) | Confined to the request |
| Read-mostly shared data (config, routing table) | Immutable snapshot behind an atomic reference |
| Shared, frequently changed state (inventory, balances) | One owning type, internally synchronised |
| Entity with a lifecycle (Order, Booking) | Owned by one aggregate; concurrency handled at the store |
Sign in to keep a note here. It is free, and nobody else can see it.
Sign inWhat this chapter was for:
With a mutable object, a reader can observe half of a two-field update. With an immutable one, a reader sees the whole old value or the whole new value, never a mixture.