Definition
- Race condition – a bug where the correctness of a program depends on the relative timing of thread execution. Two or more threads access shared mutable state, at least one writes, and there is no synchronization.
- Check-then-act –
if (balance >= amount) balance -= amounthas a gap between the check and the act. Another thread can change the balance in that gap, causing an overdraft. - Read-modify-write –
counter++reads the current value, increments it, and writes it back. Two threads can read the same value, both increment to the same result, and one increment is lost. - Non-deterministic – race conditions may work correctly most of the time and fail only under specific timing, making them extremely difficult to reproduce and diagnose.
Teller-1 checks Rajesh's balance: Rs.100,000. Teller-2 also checks the balance: Rs.100,000. Both see enough for Rs.60,000. Teller-1 withdraws, leaving Rs.40,000. Teller-2 also withdraws, leaving -Rs.20,000. The check and the act were not atomic: another teller raced in between. The fix is to lock the account so the check and the withdrawal happen as one indivisible operation.
Understanding the Concepts
What is a race condition?
A race condition occurs when the outcome of a program depends on which thread executes first. The result is non-deterministic and may produce different (often wrong) values on different runs.
What is check-then-act?
The check-then-act pattern reads a condition and acts on it in separate steps. Another thread can change the condition between the check and the act.
package com.bank.core;
import java.math.BigDecimal;
public class CheckThenActRace {
private BigDecimal balance = new BigDecimal("100000.00");
// WRONG: check and act are not atomic
public void unsafeWithdraw(BigDecimal amount) {
if (balance.compareTo(amount) >= 0) { // CHECK
// Another thread can change balance HERE!
balance = balance.subtract(amount); // ACT
}
}
// CORRECT: check and act inside one synchronized block
private final Object lock = new Object();
public void safeWithdraw(BigDecimal amount) {
synchronized (lock) {
if (balance.compareTo(amount) >= 0) { // CHECK
balance = balance.subtract(amount); // ACT
} // Both atomic
}
}
}
What is read-modify-write?
counter++ looks like one operation but compiles to three: read the value, increment it, write it back.
package com.bank.core;
import java.util.concurrent.atomic.AtomicInteger;
public class ReadModifyWriteRace {
private int unsafeCounter = 0;
private final AtomicInteger safeCounter = new AtomicInteger(0);
// WRONG: counter++ is three operations
public void unsafeIncrement() {
unsafeCounter++; // read(0) -> increment -> write(1)
// Another thread reads(0) in the gap -> writes(1) -> lost update!
}
// CORRECT: AtomicInteger uses CAS for atomic increment
public void safeIncrement() {
safeCounter.incrementAndGet(); // single CAS operation
}
public static void main(String[] args) throws InterruptedException {
ReadModifyWriteRace demo = new ReadModifyWriteRace();
Thread t1 = new Thread(() -> {
for (int i = 0; i < 100000; i++) demo.unsafeIncrement();
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < 100000; i++) demo.unsafeIncrement();
});
t1.start(); t2.start();
t1.join(); t2.join();
System.out.println("Unsafe counter: " + demo.unsafeCounter);
// Expected: 200000. Actual: less (e.g., 187432)
}
}
Unsafe counter: 187432
What about compound operations on collections?
The put-if-absent pattern on a HashMap is a check-then-act race.
package com.bank.core;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
public class CollectionRace {
// WRONG: check and put are separate operations
public void unsafePutIfAbsent(Map<String, List<String>> map, String key) {
if (!map.containsKey(key)) {
// Another thread can put the key HERE!
map.put(key, new ArrayList<>());
}
}
// CORRECT: atomic compound operation
public void safePutIfAbsent(ConcurrentHashMap<String, List<String>> map,
String key) {
map.computeIfAbsent(key, k -> new ArrayList<>());
}
}
How do you visualize thread interleaving?
Thread-1 Thread-2 balance
--------- --------- -------
read balance=100000 100000
read balance=100000 100000
subtract 60000 100000
write 40000 40000
subtract 60000 40000
write -20000 -20000 OVERDRAFT!
What are the fixes?
| Pattern | Fix | When to use |
|---|---|---|
| Check-then-act | synchronized block | Multi-step compound operations |
| Read-modify-write | AtomicInteger.incrementAndGet() | Single-variable counters |
| Put-if-absent on map | ConcurrentHashMap.computeIfAbsent() | Collection operations |
| General compound | ReentrantLock with try-finally | When you need tryLock or fairness |
What should a senior engineer know?
- Race conditions are non-deterministic: they may pass unit tests 100 times and fail in production under load. Use stress testing and tools like jcstress to detect them.
- Happens-before: the Java Memory Model defines happens-before relationships that determine when one thread's writes are visible to another. Without a happens-before edge (synchronized, volatile, or thread join), there is no visibility guarantee.
- Data races vs race conditions: a data race is a specific JMM term (unsynchronized access to a shared variable where at least one is a write). A race condition is a higher-level correctness problem. Not all race conditions involve data races (e.g., check-then-act with volatile still races).
Visual Overview
Check-Then-Act Race:
Thread-1 Thread-2 balance = 100,000
───────── ─────────
if (bal >= 60K) true
if (bal >= 60K) true (stale!)
bal -= 60K
write 40,000 40,000
bal -= 60K
write -20,000 -20,000 OVERDRAFT!
Fix: synchronized(lock) { if (bal >= 60K) bal -= 60K; }
Thread-2 sees 40,000, rejects withdrawal.
Key Takeaway
A race condition occurs when program correctness depends on thread timing. The two classic patterns are check-then-act (the condition changes between the check and the act) and read-modify-write (the value changes between the read and the write-back). Both produce non-deterministic bugs that may work correctly 99% of the time and fail only under load.
The fixes map to the patterns: synchronized for multi-step compound operations, AtomicInteger for single-variable counters, and ConcurrentHashMap.computeIfAbsent() for collection operations. The key insight is that any compound operation on shared mutable state must be made atomic.
Remember one sentence: a race condition is a gap between operations on shared state where another thread can interleave, and the fix is to make the compound operation atomic.
---