Thread Safety: Mutexes, Atomics, and Immutability
Four strategies for safe concurrent code, from heaviest to lightest — and the one most teams should default to.
"Make this thread-safe" is a surprisingly empty instruction. There are at least four different tools, each with a different cost profile.
1. Mutex / synchronized
The default hammer. Wrap a critical section; only one thread at a time.
private final Object lock = new Object();
public void increment() {
synchronized (lock) {
counter++;
}
}
Simple and safe. Cost: contention serialises threads, and bad lock discipline → deadlocks.
2. Atomics / lock-free primitives
Hardware compare-and-swap instructions expressed as AtomicLong, AtomicReference, sync/atomic in Go. For counters and single-field state, they're 5–20× faster than a mutex.
private final AtomicLong counter = new AtomicLong();
counter.incrementAndGet();
Cost: the API surface is tiny. Anything more than a single word (multi-field invariants, conditional updates) quickly turns into hand-rolled CAS loops that are easy to get wrong.
3. Immutability
If the value never changes, you cannot have a race on it. This is the secret weapon of functional-leaning codebases: instead of mutating a shared list, replace the reference with a new list. AtomicReference<List> becomes the entire concurrency story.
state.updateAndGet(old -> old.withAddedItem(x));
Cost: allocation pressure. Usually dwarfed by the win in simplicity.
4. Confinement / message passing
Give each piece of mutable state to exactly one thread and communicate via a queue/channel/actor. The goroutine + channel idiom; Erlang actors; the single-writer principle.
// Go: one goroutine owns the counter
for op := range ops {
switch op.kind {
case "inc": counter++
case "get": op.reply <- counter
}
}
Cost: higher latency per op (a channel hop). Reward: no locks in user code, easy to reason about.
The honest recommendation
Default to immutability. When a structure truly must be mutable, use confinement and expose only the channel/queue. Only reach for a mutex when both of the above are a bad fit. Atomics come out when a profiler tells you a mutex is the bottleneck — not before.
Smells to avoid
volatileused as a synchronisation primitive. It's a visibility primitive; it does not provide atomicity for compound operations.- Double-checked locking without
volatileor@Volatile. Broken on modern memory models. - A static mutable field "just for now." It is never just for now.