Race Conditions
A race condition happens when a program's correctness depends on the timing or interleaving of concurrent threads — and Java gives you several well-known patterns to recognize and fix them.
A race condition occurs when the correctness of a program's outcome depends on the relative timing or interleaving of operations performed by multiple threads. Unlike a data race (a specific JMM-level concept about unordered memory access), a race condition is a broader, behavioral notion: even fully atomic, individually-safe operations can still produce a race condition if their sequencing matters and is not controlled. Race conditions are notoriously hard to reproduce in testing because they often depend on scheduling timing that varies run to run.
What a Race Condition Is
A race condition exists whenever the correctness of a result depends on which thread 'wins' — which one executes first, or which one's intermediate state another thread happens to observe. This can occur even between operations that are each individually safe; the problem is that between two or more separate operations, another thread can interleave and change assumptions the code relied on.
A race condition is not the same thing as a data race. A data race is specifically an unsynchronized concurrent access to the same memory location with at least one write. A race condition is a broader correctness concept and can occur even when every individual read/write is properly synchronized, if the overall sequence of operations is not.
Lost Updates
A lost update happens when two threads read a value, each computes a new value based on what they read, and both write back — but the second write overwrites the first, silently discarding one thread's update. The classic example is unsynchronized increment.
count++ is read-modify-write; when two threads interleave between the read and the write, one thread's increment can be silently lost.
class Counter {
private int count = 0;
void increment() { count++; } // read, add 1, write - NOT atomic
}
// Two threads each call increment() 100,000 times concurrently:
Counter c = new Counter();
Runnable task = () -> { for (int i = 0; i < 100_000; i++) c.increment(); };
Thread t1 = new Thread(task), t2 = new Thread(task);
t1.start(); t2.start();
t1.join(); t2.join();
System.out.println(c.count); // frequently LESS than 200,000Check-Then-Act
A check-then-act race occurs when code checks a condition and then acts on the assumption that condition is still true — but another thread changes the state in between the check and the act. This pattern is extremely common and easy to miss because each individual statement can look correct in isolation.
Between containsKey and put, another thread could insert the same key, so the 'if absent' guarantee is not actually enforced.
Use an atomic compound operation (like putIfAbsent) instead of a separate check followed by a separate act.
Map<String, Integer> cache = new HashMap<>(); // shared, unsynchronized
void putIfAbsentBuggy(String key, int value) {
if (!cache.containsKey(key)) { // CHECK
cache.put(key, value); // ACT - another thread may have inserted the key in between!
}
}ConcurrentHashMap<String, Integer> cache = new ConcurrentHashMap<>();
cache.putIfAbsent(key, value); // atomic check-and-act, no raceRead-Modify-Write
Read-modify-write races generalize the lost-update pattern: any sequence of 'read a value, compute something from it, write a new value based on the old one' is unsafe unless the whole sequence is treated as one atomic unit. This includes incrementing counters, appending to collections based on current size, updating running totals or min/max trackers, and more.
// Race: reading current max, then conditionally writing a new max
int currentMax = sharedMax; // READ
if (candidate > currentMax) {
sharedMax = candidate; // WRITE - another thread's concurrent update could be lost
}Example: Bank Account Transfer
A realistic race condition example: two threads both attempt to withdraw from the same account with a balance check, without synchronization.
Both threads read balance=100 before either writes, so both pass the >= check and both withdraw, leaving balance at -60 despite the guard.
class Account {
private int balance = 100;
void withdraw(int amount) { // NOT synchronized
if (balance >= amount) { // CHECK
// another thread could withdraw here, between check and act
balance -= amount; // ACT
}
}
}
// Two threads each withdraw(80) concurrently: both may pass the check
// (balance still 100 for both reads) and both subtract, driving balance negative.Synchronization Strategies — Locking
The most direct fix is to make the entire check-then-act or read-modify-write sequence execute as a single atomic, mutually-exclusive unit using synchronized or an explicit Lock.
class Account {
private int balance = 100;
private final Object lock = new Object();
void withdraw(int amount) {
synchronized (lock) {
if (balance >= amount) {
balance -= amount;
}
}
}
}Synchronization Strategies — Atomic Classes
For simple counters and accumulators, java.util.concurrent.atomic classes (AtomicInteger, AtomicLong, AtomicReference, etc.) provide lock-free atomic compound operations like incrementAndGet(), compareAndSet(), and updateAndGet(), avoiding the need for explicit locking in many common cases.
AtomicInteger count = new AtomicInteger(0);
count.incrementAndGet(); // atomic read-modify-write, no lost updatesSynchronization Strategies — Concurrent Collections
For collections, use classes from java.util.concurrent (ConcurrentHashMap, CopyOnWriteArrayList, BlockingQueue implementations, etc.) which provide atomic compound operations (putIfAbsent, computeIfAbsent, compute) that eliminate check-then-act races on the collection itself, rather than wrapping a plain HashMap with your own external locking.
ConcurrentHashMap<String, Integer> counts = new ConcurrentHashMap<>();
counts.compute("hits", (k, v) -> (v == null) ? 1 : v + 1); // atomic increment-or-initializeSynchronization Strategies — Immutability and Confinement
The strongest fix is often to avoid shared mutable state entirely: use immutable objects (which cannot race because they cannot change after construction) or confine mutable state to a single thread (never share it, e.g. via ThreadLocal or by not sharing an object across threads at all). No synchronization is needed if there is genuinely nothing shared and mutable to race over.
Edge Cases and Testing Difficulty
Race conditions are notoriously hard to catch through testing: the buggy interleaving may require a specific, narrow timing window that rarely occurs under normal load, or may only appear under heavy concurrency, on multi-core hardware, or after JIT compilation changes instruction timing. A test suite passing consistently is not proof of thread safety. Tools like static analyzers, stress testing with many concurrent threads, and careful code review of every piece of shared mutable state are more reliable than hoping a race surfaces in a unit test.
Symptoms of race conditions are often intermittent and environment-dependent — 'works on my machine' is a particularly weak signal for concurrent code.
Key Takeaways
A race condition is any outcome-correctness bug caused by uncontrolled thread interleaving; it is a broader concept than a data race.
Lost updates occur when concurrent read-modify-write sequences overwrite each other's results.
Check-then-act bugs happen when a condition checked by one thread can change before that thread acts on it.
Fix races by making the whole check-then-act or read-modify-write sequence atomic — via locks, atomic classes, or atomic collection operations — not just synchronizing individual reads/writes.
Immutability and thread confinement eliminate races by removing shared mutable state altogether.
Passing tests do not prove the absence of race conditions; they depend on timing that testing may not reliably reproduce.
if (!map.containsKey(key)) { map.put(key, value); // race: another thread may insert between the two calls } map.putIfAbsent(key, value); // atomic check-and-act
Splitting a check and an act into two separate calls on a shared collection is a check-then-act race; use the collection's built-in atomic compound method instead.
synchronized void deposit(int amt) { balance += amt; } void withdraw(int amt) { // missing synchronized! if (balance >= amt) balance -= amt; } synchronized void deposit(int amt) { balance += amt; } synchronized void withdraw(int amt) { if (balance >= amt) balance -= amt; }
Synchronizing only some of the methods that touch shared state leaves the unsynchronized ones free to race with the synchronized ones — every method touching the same shared state must use the same lock consistently.
volatile int max = 0; void update(int candidate) { if (candidate > max) max = candidate; // still a read-modify-write race despite volatile }
AtomicInteger max = new AtomicInteger(0); void update(int candidate) { max.updateAndGet(cur -> Math.max(cur, candidate)); } volatile only fixes visibility of individual reads/writes; it does not make the check-then-write sequence atomic. Use an atomic class's compound update method instead.
How does a race condition differ from a data race?
A data race is a JMM-level concept: an unsynchronized concurrent access to shared memory with at least one write and no happens-before ordering. A race condition is a broader correctness concept: any bug where the outcome depends on thread interleaving/timing, which can occur even when individual operations are properly synchronized but their overall sequence is not.
Why is count++ unsafe when called from multiple threads without synchronization?
It is not a single atomic operation — it is a read, an increment, and a write. Two threads can both read the same value before either writes back, causing one thread's increment to be silently lost (a lost update).
What is a check-then-act race, and give an example pattern where it commonly appears?
It occurs when code checks a condition and then acts based on the assumption that the condition still holds, but another thread changes the state in between. A common example is 'if (!map.containsKey(key)) map.put(key, value)' on a plain HashMap shared across threads — another thread could insert the key between the check and the put.
Why can a bank account withdraw() method with an if (balance >= amount) guard still allow the balance to go negative under concurrency?
Two threads can both read the same (sufficient) balance before either subtracts, so both pass the guard, and both then subtract — over-withdrawing beyond what a single, atomic check-then-subtract would have permitted.
Why are atomic classes like AtomicInteger often preferable to synchronized blocks for simple counters?
They provide lock-free, atomic compound operations (like incrementAndGet or compareAndSet) tailored to exactly this problem, avoiding the overhead and potential contention of explicit locking while still eliminating lost updates.
Why is passing a test suite not sufficient proof that concurrent code is free of race conditions?
Race conditions typically depend on a specific timing/interleaving window that may rarely or never occur during a given test run, especially under light load or on particular hardware/JVM configurations; a passing test only shows that specific interleaving wasn't triggered, not that the bug doesn't exist.
How does immutability eliminate race conditions entirely for a given piece of state?
If an object cannot be mutated after construction, there is no write to race against — any number of threads can read it concurrently with no risk of a lost update, check-then-act inconsistency, or visibility issue, since there is nothing changing.
What race condition exists here, and what is its practical consequence?
nextId++ is a read-modify-write, not atomic. Two threads can read the same nextId value before either writes back the incremented value, causing both to receive the same 'unique' id (a lost update) while the counter under-advances — violating the uniqueness the generator is meant to guarantee.
Write a thread-safe version of the ID generator from above using AtomicInteger.
import java.util.concurrent.atomic.AtomicInteger; class IdGenerator { private final AtomicInteger nextId = new AtomicInteger(0); int next() { return nextId.getAndIncrement(); } } getAndIncrement() performs the read-and-increment as a single atomic operation (typically via a hardware compare-and-swap loop), so concurrent callers can never observe or receive the same id twice, and no increment is ever lost.
Write a program demonstrating a lost-update race with a plain int counter shared across two threads, then fix it with synchronized.
public class RaceDemo { private static int counter = 0; static synchronized void safeIncrement() { counter++; } public static void main(String[] args) throws InterruptedException { Runnable task = () -> { for (int i = 0; i < 50_000; i++) safeIncrement(); }; Thread t1 = new Thread(task); Thread t2 = new Thread(task); t1.start(); t2.start(); t1.join(); t2.join(); System.out.println(counter); // reliably 100000 } }
Wrapping counter++ in a synchronized method ensures each increment executes as one atomic, mutually-exclusive step (protected by the monitor lock on the class's Class object, since safeIncrement is a static synchronized method), so no increment from either thread can be lost — the printed result is reliably 100000, unlike an unsynchronized version which would typically print a smaller, inconsistent number.
class IdGenerator {
private int nextId = 0;
int next() { return nextId++; }
}
// called concurrently by many threads