Thread Safety
Shared Mutable State Problems
Read aloud is unavailable in this browser.
The Root of All Concurrency Evil
In 1985, computer scientist Leslie Lamport wrote: "A distributed system is one in which the failure of a computer you didn't even know existed can render your own computer unusable." The same insight applies to concurrent programming: a thread you didn't even know was running can corrupt data you thought was yours alone.
At the heart of nearly every concurrency bug lies a single culprit: shared mutable state. These three words—shared, mutable, state—represent the perfect storm that enables race conditions, data corruption, and the subtle, intermittent failures that haunt production systems.
Understanding shared mutable state isn't just about identifying a category of bugs—it's about developing a mental model that guides every design decision in concurrent systems.
By the end of this page, you will understand why shared mutable state is the fundamental source of concurrency hazards, how to identify it in your codebase, and the three strategies for eliminating or managing it. You'll develop the instinct to spot dangerous patterns and make informed decisions about when and how to share state between threads.
Dissecting Shared Mutable State
Let's break down the three components and understand why their combination creates problems.
State: Data stored in memory—variables, fields, data structures. State represents information that a program maintains and uses.
Mutable: State that can be changed after initialization. Any field that can be reassigned, any collection that can be modified, any object whose internal values can be altered.
Shared: State that can be accessed by more than one thread. This includes global/static variables, object fields accessible via shared references, and any data passed between threads.
Here's the key insight: any one of these properties is safe on its own. Problems only arise when all three combine:
| Combination | Safe? | Explanation |
|---|---|---|
| Shared + Mutable + State | ❌ UNSAFE | The dangerous combination—multiple threads can simultaneously read and write |
| Shared + Immutable + State | ✅ Safe | No thread can change state, so reads are always consistent |
| Unshared + Mutable + State | ✅ Safe | Only one thread accesses, so no conflicts possible (thread confinement) |
| Shared + Mutable + No State | ✅ Safe | Pure functions with no side effects—nothing to corrupt |
To achieve thread safety, you must eliminate at least ONE of the three properties: make state immutable, prevent sharing (thread confinement), or eliminate state entirely (stateless operations). Most thread-safe designs apply one or more of these strategies rather than trying to synchronize shared mutable state.
This insight transforms how we approach concurrent design:
- Default to immutability — Make objects immutable unless mutation is essential
- Confine mutable state to single threads — Use thread-local storage, message passing, or actor models
- Minimize shared state — Question every piece of state that seems to require sharing
- When sharing is essential, synchronize properly — Use appropriate primitives with full understanding of the memory model
Let's explore why the dangerous combination is so problematic.
Why Sharing + Mutation = Hazard
When multiple threads can read and write the same data, three categories of hazards emerge:
1. Data Races
A data race occurs when two or more threads access the same memory location concurrently, at least one access is a write, and no synchronization orders the accesses. Data races cause undefined behavior in most languages—anything can happen.
2. Race Conditions
A race condition occurs when the correctness of a program depends on the relative timing or interleaving of multiple threads. Unlike data races (which are about unsynchronized memory access), race conditions are about logical errors in the sequence of operations.
3. Memory Consistency Errors
Due to caching, compiler optimizations, and instruction reordering, one thread's writes may not be visible to another thread in a timely or ordered manner, even without data races.
12345678910111213141516171819202122232425262728293031323334353637383940414243
// Conceptual example of shared mutable state hazards// (Actual JavaScript is single-threaded in main context, but Worker threads can share memory) class BankAccount { private balance: number; constructor(initialBalance: number) { this.balance = initialBalance; } // HAZARD 1: Data Race // Two threads reading/writing 'balance' without synchronization getBalance(): number { return this.balance; // Read may see stale/partial value } // HAZARD 2: Race Condition (check-then-act) // Even if getBalance() and setBalance() were atomic individually... withdraw(amount: number): boolean { if (this.balance >= amount) { // Thread A: balance=100, amount=60 → true // Thread B: balance=100, amount=60 → true // ...context switch here... this.balance -= amount; // Thread A: balance = 40 // Thread B: balance = -20 (INVARIANT VIOLATED!) return true; } return false; } // HAZARD 3: Compound operation non-atomicity transfer(other: BankAccount, amount: number): boolean { if (this.withdraw(amount)) { // Step 1: Deduct from this // System crashes here → money is lost! other.deposit(amount); // Step 2: Add to other return true; } return false; } deposit(amount: number): void { this.balance += amount; // Read-modify-write: NOT atomic! }}The BankAccount example illustrates all three hazards:
Data Race: Multiple threads reading and writing balance without synchronization. On some platforms, reading a double during a write can yield a corrupted value (torn read).
Race Condition: The withdraw method has a check-then-act pattern. Between the check (balance >= amount) and the action (balance -= amount), another thread can modify the balance, invalidating the check.
Compound Non-Atomicity: The transfer method involves multiple steps that must all succeed together, but there's no guarantee of atomicity across the operations.
Anatomy of a Shared State Bug
Let's trace through exactly how a shared mutable state bug manifests, step by step. This detailed analysis builds intuition for recognizing these patterns in real code.
Scenario: A concurrent user session counter in a web application.
12345678910111213141516
public class SessionManager { // Shared mutable state private int activeSessionCount = 0; public void onSessionStart() { activeSessionCount++; // Read (1) → Add (2) → Write (3) } public void onSessionEnd() { activeSessionCount--; } public int getActiveCount() { return activeSessionCount; }}This looks innocent enough. Let's examine what activeSessionCount++ actually does at the instruction level:
1. READ: Load activeSessionCount from memory into CPU register (value: 5)
2. ADD: Add 1 to the register value (value: 6)
3. WRITE: Store register value back to memory (activeSessionCount = 6)
Now consider two threads executing onSessionStart() simultaneously:
| Step | Thread A | Thread B | Register A | Register B | Memory Value |
|---|---|---|---|---|---|
| Initial | — | — | — | — | 5 |
| 1 | READ activeSessionCount | — | 5 | — | 5 |
| 2 | — | READ activeSessionCount | 5 | 5 | 5 |
| 3 | ADD 1 | — | 6 | 5 | 5 |
| 4 | — | ADD 1 | 6 | 6 | 5 |
| 5 | WRITE 6 | — | 6 | 6 | 6 |
| 6 | — | WRITE 6 | 6 | 6 | 6 |
| Final | Complete | Complete | — | — | 6 (should be 7!) |
Both threads read the same initial value (5), both add 1 to get 6, and both write 6. One increment is completely lost. With high concurrency, you might process 10,000 session starts but only count 8,500. Your monitoring dashboards show 15% fewer users than reality.
Why didn't we catch this in testing?
On a developer's laptop, thread scheduling is relatively deterministic. The window between read and write is microseconds. The probability of two threads hitting that exact window is low in light testing.
But in production:
- Hundreds of threads compete for CPU
- System load causes context switches at unpredictable times
- Garbage collection can pause threads mid-operation
- The law of large numbers guarantees the bug will manifest
The fix requires atomic operations:
12345678910111213141516171819202122232425262728293031323334353637383940414243
import java.util.concurrent.atomic.AtomicInteger; public class SessionManager { // Thread-safe atomic counter private final AtomicInteger activeSessionCount = new AtomicInteger(0); public void onSessionStart() { activeSessionCount.incrementAndGet(); // Atomic: read+add+write as ONE operation } public void onSessionEnd() { activeSessionCount.decrementAndGet(); } public int getActiveCount() { return activeSessionCount.get(); }} // OR: Using synchronized public class SessionManagerSync { private int activeSessionCount = 0; private final Object lock = new Object(); public void onSessionStart() { synchronized (lock) { activeSessionCount++; // Protected by lock } } public void onSessionEnd() { synchronized (lock) { activeSessionCount--; } } public int getActiveCount() { synchronized (lock) { return activeSessionCount; } }}Categories of Shared State
Not all shared state is equally obvious. Developing thread-safe code requires recognizing shared state in all its forms—including subtle ones that are easy to overlook.
- •Global/Static Variables — Accessible from anywhere, clearly shared. Examples: configuration singletons, registries, caches.
- •Instance Fields of Shared Objects — When an object reference is accessible from multiple threads, all its fields are potentially shared.
- •Explicitly Passed References — Objects passed to multiple threads via constructors, method parameters, or queues.
- •Heap-Allocated Data — Any object on the heap can be shared if references to it escape to multiple threads.
- •Closure Captures — Variables captured by lambdas/closures become accessible from any thread that executes that closure.
- •Container Contents — Adding an object to a shared collection makes that object shared too, even if the object itself wasn't before.
- •Transitive Sharing — If object A is shared, and A holds a reference to B, then B is also effectively shared.
- •External Resources — Files, databases, network connections, and system resources are implicitly shared across all threads.
- •Singletons and Caches — Cached values returned by methods become shared if returned to multiple threads.
- •Memoization State — Lazy initialization patterns store state that becomes shared once computed.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960
import java.util.*;import java.util.concurrent.*; public class SubtleSharingExamples { // Example 1: Closure captures private int counter = 0; // Instance field public void runInBackground() { Runnable task = () -> { counter++; // BUG: 'counter' is captured and now shared // with whatever thread runs this Runnable! }; executor.submit(task); } // Example 2: Container contents become shared private final List<User> users = new CopyOnWriteArrayList<>(); // Thread-safe list public void registerUser(User user) { users.add(user); // Now 'user' is accessible from any thread // reading the list. Is User itself thread-safe? } public void updateAllUsers() { for (User user : users) { user.incrementLoginCount(); // BUG: User might not be thread-safe! } } // Example 3: Memoization creates shared state private volatile ExpensiveObject cached; // Lazy singleton pattern public ExpensiveObject getExpensive() { if (cached == null) { cached = new ExpensiveObject(); // BUG: Multiple threads might // create multiple instances! } return cached; // The returned object is now shared } // Example 4: Return value sharing public List<String> getActiveUserNames() { List<String> names = new ArrayList<>(); // ... populate names ... return names; // BUG: If caller stores this reference, // and another thread calls this method, // they might get the same ArrayList instance! } // Example 5: External resource sharing private final FileWriter logFile; // External state public void log(String message) { logFile.write(message); // BUG: FileWriter isn't thread-safe! // Concurrent writes corrupt output. } private final ExecutorService executor = Executors.newFixedThreadPool(4);}If object A is thread-safely published to multiple threads, every object reachable from A is also effectively shared. Thread safety analysis must follow the entire reference graph, not just direct fields. A thread-safe container of non-thread-safe elements is NOT thread-safe as a whole.
The Three Strategies for Safety
Given that shared mutable state is the root problem, we have three fundamental strategies to achieve thread safety. Each removes one component of the dangerous triad.
Remove Mutability: Make state immutable.
Immutable objects are inherently thread-safe because their state cannot change after construction. No synchronization is ever needed when sharing immutable data.
How to achieve immutability:
- All fields are
final(or equivalent) - No setter methods
- No methods that modify state
- Defensive copies of any mutable parameters or return values
- Ensure proper construction (no
thisescape)
Example:
12345678910111213141516171819202122
// Immutable class - always thread-safepublic final class ImmutablePoint { private final int x; private final int y; public ImmutablePoint(int x, int y) { this.x = x; this.y = y; } public int getX() { return x; } public int getY() { return y; } // "Modification" returns NEW object public ImmutablePoint translate(int dx, int dy) { return new ImmutablePoint(x + dx, y + dy); }} // Usage: safely share across threadsImmutablePoint origin = new ImmutablePoint(0, 0);// Can pass 'origin' to any thread with zero synchronizationWhen to use: Whenever possible! Immutability should be the default. The performance cost of creating new objects is often far less than the complexity cost of synchronizing mutable state.
Common immutable types: String, Integer, LocalDate, BigDecimal, most value objects.
Identifying Shared State in Code
Developing the skill to spot shared mutable state requires systematic analysis. Here's a framework for reviewing code for concurrency hazards.
Code Review Checklist for Shared State
- •Identify all mutable fields — Any field that is not
finalor is a mutable collection/object. - •Trace access paths — From where can this field be accessed? Follow all references that could reach it.
- •Determine thread context — Which threads could possibly execute code that accesses this field?
- •Look for escape — Does the field or a reference to it ever escape the current scope? (returned, passed to callbacks, stored in collections)
- •Check compound operations — Are there any check-then-act or read-modify-write sequences involving the field?
- •Verify synchronization — If multiple threads can access, is there proper synchronization covering all access paths?
1234567891011121314151617181920212223242526272829303132333435363738394041424344
public class UserService { // REVIEW POINT 1: Is this shared? // - It's an instance field of UserService // - UserService might be a singleton (need to check) // - ConcurrentHashMap is thread-safe for individual operations private final Map<String, User> cache = new ConcurrentHashMap<>(); // ✓ OK // REVIEW POINT 2: Is this shared? // - Instance field, mutable (not final, even if effectively so) // - Multiple threads could access via getUserStats() private long totalLogins = 0; // ❌ UNSAFE: read-modify-write // REVIEW POINT 3: Method parameter analysis public void login(User user) { // Is 'user' thread-safe? user.setLastLoginTime(now()); // ❌ If User is shared, this mutates shared state! cache.put(user.getId(), user); // ❌ After this, 'user' IS shared via cache totalLogins++; // ❌ RACE CONDITION: read-modify-write not atomic } // REVIEW POINT 4: Return value analysis public User getUser(String id) { return cache.get(id); // ❌ Caller receives reference to shared User // Any mutations by caller affect shared state! } // REVIEW POINT 5: Compound operation public User getOrCreate(String id, Supplier<User> factory) { User user = cache.get(id); // ← Thread A reads null if (user == null) { // ← Thread B reads null user = factory.get(); // ← Both create new User cache.put(id, user); // ← Thread A puts its User } // ← Thread B overwrites with its User! return user; // ❌ RACE: factory called twice, wrong user returned } // CORRECT: Use atomic compute operation public User getOrCreateCorrect(String id, Supplier<User> factory) { return cache.computeIfAbsent(id, k -> factory.get()); // ✓ Atomic }}Static analysis tools can help identify potential concurrency issues: Java has FindBugs/SpotBugs, IntelliJ IDEA's inspections, and Error Prone. Go has the built-in race detector (go run -race). C++ has ThreadSanitizer. These tools catch many issues, but they can't replace careful design.
Summary: Managing Shared Mutable State
We've explored the root cause of concurrency hazards and the strategies for addressing them. Let's consolidate the key insights:
- •Shared mutable state is the root of concurrency hazards — All three properties must be present for problems to occur: sharing, mutability, and statefulness.
- •Eliminate one property to achieve safety — Make state immutable, confine it to one thread, or eliminate state entirely.
- •Understand all forms of sharing — Direct fields, transitive references, closure captures, container contents, and external resources can all be shared.
- •Compound operations are the danger zone — Check-then-act and read-modify-write patterns are inherently non-atomic without explicit synchronization.
- •Prefer immutability and confinement over synchronization — They're simpler, less error-prone, and have no runtime overhead.
- •Develop systematic code review skills — Trace access paths, identify escape, and verify synchronization for all shared mutable state.
What's next:
Now that we understand how shared mutable state creates hazards, we'll examine the specific manifestation of those hazards: race conditions. The next page provides a deep dive into the anatomy of race conditions, their various forms, and techniques for detecting and preventing them.
You now understand why shared mutable state is the fundamental source of concurrency bugs, how to identify it in various forms, and the three strategies for achieving thread safety. This knowledge prepares you to recognize and address race conditions in the next page.
KEEP LEARNING
There’s more to understand.
You’re reading the introduction. Create a free account to read the complete lesson, save notes and keep track of your progress.