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.
Shared mutable state becomes an anti-pattern when multiple execution contexts can change the same data without a clear ownership or synchronization contract. The code may look correct in a single-threaded trace, yet concurrent interleavings can violate business invariants.
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: sequential unit tests can pass while concurrent load exposes the bug.
- 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
A shared mutable object is safe only when its ownership and access protocol are explicit. Warning signs include compound read-modify-write operations, mutable collections shared by request threads, and visibility or atomicity assumptions that are not encoded in the design. The cost can be corrupted business state that is difficult to reproduce and repair.
Concrete Example
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.
Even a test suite with full line coverage can miss this bug when its tests execute single-threaded. The race may surface only during a burst of concurrent requests.
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 can miss it. Unit tests often run single-threaded by default. The race may not fire during
mvn test, then surface under concurrent load. - "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
Diagnose the problem by looking for shared ownership first, then checking whether every compound operation has a matching atomicity and visibility guarantee.
| 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 SDEpedia Premium.
Related Articles
Learn why overusing singletons creates hidden global state, makes code untestable, introduces threading hazards, and how dependency injection replaces them cleanly.
Learn why a class that knows too much and does too many unrelated things is hard to test, extend, and maintain, and how to break it apart using Single Responsibility and Extract Class.