race conditions
two threads, one counter++, and a lost update. interleave them yourself.
- chapter
- chapter 09.02
- reading time
- 10 min read
a race condition is a bug where the result depends on how threads happen to interleave. the classic one: count++ looks like one step, but it's three (read, add, write). two threads interleave those, both read the same old value, and one increment silently disappears. that's a lost update.
the picture
two people keeping a tally on one whiteboard. each one walks up, reads the number, does +1 in their head, and writes the new number over the old one. one at a time, it works. but if both read 41 before either writes, they both write 42, and one tally mark is gone. nobody made an arithmetic mistake. the bug is in the timing.
that "in their head" part is the whole thing. each thread copies the value into a register (its own private scratch space), changes it there, and only then writes it back. between the read and the write, the shared value can change under it, and the thread has no idea.
count++ is three instructions
here's what javac actually turns count++ into, straight from javap -c:
void increment();
Code:
0: aload_0
1: dup
2: getfield #7 // Field count:I ← read
5: iconst_1
6: iadd ← add
7: putfield #7 // Field count:I ← write
10: returna getfield, an iadd, a putfield. a thread can be paused, or simply run alongside another core, between any two of them. so let's split it into those three steps and let you be the scheduler.
- int r = count;
- r = r + 1;
- count = r;
- int r = count;
- r = r + 1;
- count = r;
two threads, both about to run count++. pick who goes first.
see? nothing in that history is wrong on its own. every read read a real value, every add added correctly, every write wrote what it computed. the bug only exists in the order. that's why races survive code review: every line looks fine.
run it for real
two platform threads, one shared static int, a million count++ each:
static int count = 0;
void main() throws InterruptedException {
Runnable work = () -> {
for (int i = 0; i < 1_000_000; i++) {
count++;
}
};
Thread a = new Thread(work);
Thread b = new Thread(work);
a.start();
b.start();
a.join();
b.join();
IO.println("count = " + count);
}no output is printed under this one on purpose: it's different every time. here are eight runs in a row, on jdk 25 on a 14-core arm mac:
two threads each run count++ a million times on a shared int starting at 0, with no synchronization. what's the smallest final value that's actually possible?
how many orders go wrong?
two threads, three steps each, gives exactly 20 possible orders. here's every one of them.
18 out of 20. if every order were equally likely, the counter would almost never be right. so why did the real runs keep a million-plus increments instead of losing nearly all of them?
because real scheduling is lumpy. a thread usually runs a long streak of increments before anything interrupts it, and two cores only collide when their streaks overlap on the same value at the same moment. the bug fires in bursts, and how often depends on core count, the JIT, what else the machine is doing. which is exactly why a test that passes 1000 times proves nothing. more on that in testing concurrent code.
why the JIT makes this worse, and then hides it+
once C2 compiles that loop, it's allowed to keep count in a register for the entire loop and write it back once at the end (nothing in the code says another thread is watching). in a warmed-up JVM the loop then finishes so fast that the second thread often hasn't even started yet, and you get 2,000,000 every time. when I ran the same race 300 times in one JVM, the first two runs lost about 950,000 increments each. the next 298 were perfect. same bug, invisible.
check-then-act
count++ is the poster child, but the more common race in real code is check-then-act: look at some state, decide, then act on a decision that may already be stale.
one account with 100 in it. two threads each want to withdraw 100.
it shows up everywhere:
if (!map.containsKey(k)) map.put(k, v);(two threads both put, one value lost)if (instance == null) instance = new Thing();(two instances of your "singleton")if (!file.exists()) file.createNewFile();(it's not just memory: files and databases race too)
making each step thread-safe doesn't help. a ConcurrentHashMap makes containsKey and put each safe, but not the pair. you need the whole check-and-act to be one atomic step: a lock around both, or an API that does both at once.
if (!map.containsKey(user)) {
map.put(user, new Session(user));
}map.computeIfAbsent(user, Session::new);data race vs race condition
two terms people use interchangeably. they're not the same thing.
- data race
- a precise memory-model term: two threads access the same variable, at least one writes, and nothing orders them (no happens-before). the racy
count++above has one. so does a plain flag one thread sets and another reads. the effects are spelled out in the java memory model. - race condition
- a logic bug: the program is wrong under some timing. the overdraft is one. you can have a race condition with zero data races (every access done through atomics, still wrong), and a data race that happens to be harmless (a cached hash code that two threads might both compute).
fixing the data race is necessary. it isn't always sufficient.
the fixes, in one table
every fix is one of three ideas: don't share, don't mutate, or coordinate.
| fix | how | chapter |
|---|---|---|
| don't share | keep state on the stack or inside one thread | threads |
| don't mutate | immutable objects can be shared freely | immutability |
| lock it | synchronized or a Lock makes read-add-write one step | synchronized |
| do it atomically | AtomicInteger, CAS, LongAdder | atomics and CAS |
| use a safe structure | ConcurrentHashMap.merge, computeIfAbsent | concurrent collections |
the smallest fix for the counter is swapping the int for an AtomicInteger, whose incrementAndGet() does read-add-write as one indivisible hardware step:
import java.util.concurrent.atomic.AtomicInteger;
static AtomicInteger count = new AtomicInteger();
void main() throws InterruptedException {
Runnable work = () -> {
for (int i = 0; i < 1_000_000; i++) {
count.incrementAndGet();
}
};
Thread a = new Thread(work);
Thread b = new Thread(work);
a.start();
b.start();
a.join();
b.join();
IO.println("count = " + count.get());
}count = 2000000
every time. that's the difference.
count++is three steps: read, add, write. another thread can cut in between any two.- a lost update is two threads writing results computed from the same old value.
- 18 of the 20 possible orders of two increments are wrong. reality hides it with lumpy scheduling.
- check-then-act races even when every single call is thread-safe.
- data race: unordered conflicting accesses. race condition: wrong under some timing. not the same.
- don't share, don't mutate, or coordinate. there is no fourth option.