Overview
When multiple threads access shared mutable state without proper coordination, two fundamental categories of bugs can occur: race conditions and data races. They are related but distinct problems.
Race Condition
A race condition occurs when the correctness of a program depends on the relative timing of thread execution. Multiple threads compete to read and write the same resource, and the outcome varies depending on which thread gets there first.
Classic Example: Lost Update
public class Counter {
private int count = 0;
public void increment() {
count++; // Not atomic: read → modify → write
}
public int getCount() {
return count;
}
}
When two threads call increment() simultaneously:

Both threads read the same value, both compute the same result, and one update is lost. After two increments, the counter shows 1 instead of 2.
Check-Then-Act Race
Another common pattern where the check and the action are not atomic:
public class LazyInitializer {
private Object instance;
public Object getInstance() {
if (instance == null) { // Check
instance = new Object(); // Act
}
return instance;
}
}
Two threads can both see instance == null, both create an object, and the singleton guarantee is broken.
Read-Modify-Write Race
Any operation that reads a value, computes something, and writes back is vulnerable:
// All of these are race-prone without synchronization:
balance = balance - withdrawal; // Bank account
list.add(item); // Adding to shared list
map.put(key, map.get(key) + 1); // Updating map counter
Data Race
A data race is a lower-level problem related to memory visibility and instruction reordering. It occurs when:
- Two threads access the same memory location
- At least one access is a write
- There is no synchronization ordering the accesses
CPU Reordering
Modern CPUs and compilers reorder instructions for performance. If two instructions don’t have a visible dependency from the CPU’s perspective, their order may be swapped:
// Original code
int a = 1; // (1)
boolean ready = true; // (2)
// CPU might execute as:
boolean ready = true; // (2) — moved first!
int a = 1; // (1)
On a single thread this is invisible — the final state is the same. But if another thread reads ready and then a, it might see ready == true but a == 0:
// Thread 1 // Thread 2
a = 42; while (!ready) { /* spin */ }
ready = true; System.out.println(a); // might print 0!
How Data Races Differ from Race Conditions
| Aspect | Race Condition | Data Race |
|---|---|---|
| Level | Logical/algorithmic | Memory model / hardware |
| Cause | Unsynchronized read-write sequences | Missing happens-before relationship |
| Symptom | Wrong result, lost update | Stale/invisible values, reordered effects |
| Fix | Synchronize the critical section | Use volatile, synchronized, or atomics |
| Can exist without the other? | Yes | Yes |
You can have a race condition without a data race (if operations are individually atomic but the sequence is wrong). You can have a data race without a race condition (if reordering causes visibility issues even in a single-writer scenario).
The volatile Keyword as a Memory Barrier
volatile serves two purposes:
- Visibility: Every read of a volatile variable goes to main memory. Every write flushes to main memory immediately.
- Ordering barrier: Instructions before a volatile write cannot be reordered past it. Instructions after a volatile read cannot be reordered before it.
// volatile acts as a "fence" — no reordering crosses it
private int a = 0;
private volatile boolean ready = false;
// Thread 1 (writer)
a = 42; // Guaranteed to happen BEFORE the volatile write
ready = true; // Volatile write — flushes all previous writes
// Thread 2 (reader)
if (ready) { // Volatile read — refreshes all subsequent reads
System.out.println(a); // Guaranteed to see 42
}
What volatile Does NOT Do
- It does not make compound operations atomic (
volatile int count; count++is still not atomic) - It does not replace synchronization for read-modify-write patterns
- It does not provide mutual exclusion
Common Race Condition Patterns
1. Compound Check-Then-Act
// BROKEN: gap between check and act
if (!map.containsKey(key)) {
map.put(key, value);
}
// FIX: use atomic operation
map.putIfAbsent(key, value);
2. Lazy Initialization (Double-Checked Locking)
// BROKEN without volatile
private static Instance instance;
public static Instance getInstance() {
if (instance == null) {
synchronized (Instance.class) {
if (instance == null) {
instance = new Instance(); // Can be seen partially constructed!
}
}
}
return instance;
}
// FIX: add volatile
private static volatile Instance instance;
3. Iterator Invalidation
// BROKEN: another thread modifies the list while iterating
for (String item : sharedList) {
if (item.startsWith("X")) {
sharedList.remove(item); // ConcurrentModificationException
}
}
// FIX: use ConcurrentHashMap, CopyOnWriteArrayList, or external synchronization
4. Time-of-Check to Time-of-Use (TOCTOU)
// BROKEN: file might be deleted between check and read
if (file.exists()) {
// Another thread/process deletes the file here
readFile(file); // FileNotFoundException
}
// FIX: just try the operation and handle the exception
try {
readFile(file);
} catch (FileNotFoundException e) {
// Handle missing file
}
Detecting Race Conditions
| Tool | What It Catches |
|---|---|
| Thread sanitizers (TSan) | Data races at runtime |
| Static analysis (SpotBugs, Error Prone) | Common concurrency antipatterns |
| Stress testing (JCStress) | Race conditions under heavy contention |
| Code review | Logical race conditions in check-then-act patterns |
Summary
- Race condition: the logic is correct only if threads happen to execute in a specific order. Fix by making critical sections atomic.
- Data race: memory operations have no ordering guarantee. Fix with
volatile,synchronized, or atomic classes. volatileprovides visibility and prevents reordering but does not make compound operations atomic.- Most real-world bugs combine both: a data race causes a stale read, which leads to a race condition in the logic.