JAVA / THREADS AND CONCURRENCY
Race conditions and why shared mutation breaks
Explain and deliberately reproduce lost updates and check-then-act races in Java, and identify which shared mutations need to be one indivisible action.
What you will learn
- Split count++ into read, compute, write and spot the losing interleaving
- Reproduce a lost update on demand by widening the read-to-write window
- Recognise check-then-act races even when every single call is thread-safe
- Decide whether state is shared, mutated and unordered before calling it safe
Understanding Race conditions and why shared mutation breaks
A race condition is not a kind of crash, it is a dependency on timing. count++ looks indivisible because it is one expression, but it is three actions: read the field into the thread's own stack, add one to that copy, and store the result back. Between the read and the write a thread is holding a number that may already be out of date, so if a second thread reads the same original value, both compute the same result and both store it, and one increment vanishes. A field is a mailbox, not a ledger: the last writer wins and whatever arrived in between is overwritten without a trace.
The second shape of the same bug is check-then-act. A condition you test - the balance is large enough, the map has no entry for this key, the field is still null - is only true at the instant it is read; after that it is a guess about the past. Making the individual operations thread-safe does not help, because what has to be indivisible is the whole sequence from the check to the action that depends on it. An AtomicInteger guarantees that no single decrement is lost and still lets two threads both pass if (balance.get() >= amount) and push the balance negative.
Races resist testing because the losing interleaving is usually rare: it depends on core count, on how the JIT compiled the loop, and on what else the machine is doing, so code can behave for a year on a laptop and fail in a minute on a loaded server. The sleeps in the examples below do not create the bug, they pin down one schedule the scheduler is already free to choose. A race needs three ingredients at once - state that is shared, state that is mutated, and no ordering between the threads touching it - so removing any one of them removes the race: confine the state to a single thread, make it immutable, or make the whole compound action indivisible.
public class LostUpdate {
static int count = 0;
static void increment(String who) throws InterruptedException {
int seen = count; // step 1: read
System.out.println(who + " read " + seen);
Thread.sleep(100); // the scheduler may pause here anyway
int next = seen + 1; // step 2: compute on a private copy
count = next; // step 3: write back
System.out.println(who + " wrote " + count);
}
public static void main(String[] args) throws InterruptedException {
Runnable task = () -> {
try {
increment(Thread.currentThread().getName());
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
};
Thread a = new Thread(task, "A");
Thread b = new Thread(task, "B");
a.start();
Thread.sleep(20); // let A get past its read first
b.start();
a.join();
b.join();
System.out.println("increments performed: 2, count = " + count);
}
}Shared mutation breaks when an invariant spans several operations, because another thread can read stale state or overwrite your result in the gap between them.
Worked examples
Check-then-act with an atomic field
Shows that making each individual operation atomic does not stop two threads from acting on the same stale decision.
import java.util.concurrent.atomic.AtomicInteger;
public class CheckThenAct {
static final AtomicInteger balance = new AtomicInteger(100);
static void withdraw(String who, int amount) throws InterruptedException {
if (balance.get() >= amount) { // check
Thread.sleep(50); // the other thread slips in here
int left = balance.addAndGet(-amount); // act
System.out.println(who + " withdrew " + amount + ", left " + left);
} else {
System.out.println(who + " declined, balance " + balance.get());
}
}
public static void main(String[] args) throws InterruptedException {
Runnable task = () -> {
try {
withdraw(Thread.currentThread().getName(), 100);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
};
Thread t1 = new Thread(task, "T1");
Thread t2 = new Thread(task, "T2");
t1.start();
Thread.sleep(10);
t2.start();
t1.join();
t2.join();
System.out.println("final balance " + balance.get());
}
}Example explained
Line 1balance.get() >= amount is true for both threads because T2 runs the check during T1's 50 ms pause, while the balance is still 100.
Line 2addAndGet is atomic, so no update is lost here: both withdrawals apply in full and the second one returns -100.
Line 3The else branch never executes, which is the tell - the guard was evaluated against a balance that no longer existed when the money moved.
Line 4The repair is to make the check and the subtraction one indivisible step, not to make each half individually thread-safe.
Important notes
Thread.sleep neither causes nor cures the race. It only makes one legal schedule reproducible so the lost update is visible every run.
Local variables and parameters live on the calling thread's own stack and cannot be raced on. Only state reachable from two threads - fields, arrays, objects handed to a task - is at risk.
Common mistakes
Assuming count++ is atomic because it is a single expression: two threads read the same value, both store value + 1, and 200000 increments finish somewhere below 200000.
Replacing an int with an AtomicInteger but keeping if (n.get() > 0) n.decrementAndGet(): each call is atomic, the pair is not, so the counter still drops below zero.
Treating one clean run as proof of correctness: the bad interleaving may need thousands of runs or a busier machine, so the bug ships and later surfaces as impossible data.
Try it yourself
Change, predict, then run
Change the lost-update program to four threads that each read the counter, sleep 60 ms, then write seen + 1, with starts staggered 10 ms apart; predict the final count before running it and explain why it is 1.
Open the Java workspaceCheck your understanding
Two threads run if (queue.isEmpty()) queue.add(item); on the same thread-safe queue, and the queue ends up holding two items. What went wrong?
- Thread-safe collections synchronize writes but not reads, so isEmpty returned a stale answer.
- add is not atomic, so it was interrupted halfway and ran twice.
- isEmpty and add are each atomic, but the pair is not, so both threads saw an empty queue before either added.
- The second thread needed a volatile read of the queue reference before it could see the first add.
Show answer
The collection makes each call atomic and publishes its own writes, so neither a stale read nor a half-finished add is the cause; the gap between the check and the action is. Option 0 is tempting because the second thread really did observe an empty queue, but that observation was accurate when it was made - it simply stopped being true before the add ran.