A race condition is when the correctness of a program depends on the relative timing or interleaving of threads — typically because a multi-step operation on shared state is not atomic, so two threads' steps overlap and one clobbers the other.
The classic example
count++ looks like one operation but is three: read count, add one, write count back. With no synchronization, two threads can read the same value, both increment it, and both write back the same result — losing an update.
class Counter {
private int count = 0;
public void increment() { count++; } // read-modify-write, NOT atomic
public int get() { return count; }
}
// Two threads each call increment() 1_000_000 times.
// Expected: 2_000_000. Actual: usually less.
The interleaving that loses an update:
Thread-A: read count (5) Thread-B: read count (5) Thread-A: 5 + 1 = 6, write 6 Thread-B: 5 + 1 = 6, write 6 <-- A's increment vanished
Two flavors
This is a read-modify-write race. The other common kind is check-then-act: lazy init, if (instance == null) instance = new X();, or if (!map.containsKey(k)) map.put(k, v) — the gap between the check and the act lets another thread invalidate the check.
Why it happens
Two independent reasons, both rooted in the Java Memory Model:
- Atomicity — the compound operation can be interrupted mid-way by another thread.
- Visibility — without a happens-before edge, a write by one thread may never become visible to another; each may work off a stale, cached value.
Fixing it
Establish mutual exclusion and a happens-before edge:
public synchronized void increment() { count++; }
Or use a lock-free atomic, which is usually faster under contention:
private final AtomicInteger count = new AtomicInteger();
public void increment() { count.incrementAndGet(); }
Note that volatile alone does not fix this — it gives visibility but not atomicity, and count++ is still a non-atomic compound action.