Mutable shared state anti-pattern
Learn why shared mutable state causes race conditions, data corruption, and heisenbugs in concurrent code, and how immutability, actors, and proper synchronization prevent them.
TL;DR
- Shared mutable state is data that multiple threads can read and write simultaneously without coordination. It causes race conditions, lost updates, and torn reads.
- Race conditions are timing-dependent: they pass every unit test and explode at 3 AM under production load.
- Three fundamental strategies: eliminate sharing (copy the data), eliminate mutation (immutability), or coordinate access (locks, atomics, actors).
- Immutability is the default choice. Java records and unmodifiable collections make shared data safe with zero coordination overhead.
- When mutation is truly required, use AtomicInteger for simple counters, ReentrantLock for complex state, or the Actor model for high-contention systems.
The Problem
Your e-commerce platform tracks inventory counts in a shared map. Two threads handle concurrent purchase requests:
// β Shared mutable inventory without synchronization
public class InventoryService {
private final Map<String, Integer> stock = new HashMap<>();
public boolean purchase(String itemId, int quantity) {
int available = stock.getOrDefault(itemId, 0);
if (available >= quantity) {
stock.put(itemId, available - quantity); // not atomic
return true;
}
return false;
}
}
Two customers buy the last item at the same time. Both threads read available = 1, both pass the check, both write stock = 0. You just sold one item twice.
I've debugged this exact bug in a ticketing system. The team had 100% unit test coverage. Every test passed because tests run single-threaded. The bug only appeared during flash sales when concurrent requests spiked.
The read-check-write sequence is a compound operation. Without synchronization, the thread scheduler interleaves these steps in any order. One write silently overwrites the other.
Why It Happens
- Single-threaded thinking. Most developers write and test sequentially. The mental model is "line A runs, then line B." With threads, lines from different threads interleave unpredictably.
- Invisible interleaving. The JVM and OS scheduler decide when to swap threads. Your code looks sequential. The execution is not.
- Tests lie. Unit tests run single-threaded by default. The race condition never fires during
mvn test. It surfaces under concurrent load in staging or production. - "It's just a counter." Developers underestimate the complexity of concurrent mutation. Even
count++is three operations (read, increment, write) that can interleave.
The testing trap
Race conditions are heisenbugs: they disappear when you observe them. Adding logging or breakpoints changes timing enough to hide the race. If a bug only reproduces under load and never in the debugger, shared mutable state is the first suspect.
How to Detect It
| Signal | What to look for | Tool |
|---|---|---|
| Non-final mutable fields in concurrent services | HashMap, ArrayList, or plain fields without synchronization | Code review, grep -rn "new HashMap" |
| Read-then-write patterns | get() followed by put() on shared collections | Manual review for compound operations |
| Flickering test failures | Tests pass alone, fail when run in parallel | Run with -DforkCount=4 or --parallel |
| "Works on my machine" bugs | Bug only appears under concurrent load | Stress testing with JMH or Gatling |
Missing synchronized, Lock, or Atomic | Service handling concurrent requests with plain fields | Static analysis (SpotBugs, Error Prone) |
volatile without atomicity | Field is volatile but compound read-write is not atomic | Review uses of volatile for correctness |
The Fix
The progression from simple to complex: start with immutability. If mutation is required, use atomics for simple state or locks for compound operations. For high-contention systems, consider the Actor model.
Continue Reading with Premium
Unlock this article and every other in-depth system design guide on the platform with NotesFromSDE Premium.