skip to content
everything.concurrency.raceConditions

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:

javap -c Counter
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: return

a 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.

count++ each =
guarded by =
T1count++ 1/1
  1. int r = count;
  2. r = r + 1;
  3. count = r;
r =·
shared · heap
count0
T2count++ 1/1
  1. int r = count;
  2. r = r + 1;
  3. count = r;
r =·

two threads, both about to run count++. pick who goes first.

T1
T2
sharedcount
·
0
click step T1 and step T2 in any order you like. try: T1 read, T2 read, then let both finish. the moment a thread writes over a value it never saw, the write turns red. then switch the guard to synchronized and try to break it again.

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:

Main.java
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:

expected2,000,000
run 11,050,057
run 21,789,270
run 31,483,727
run 41,742,785
run 51,026,536
run 61,766,010
run 71,030,191
run 81,340,723
eight real runs of the program above. the green bar is what the code says. the red ones are what it did. it lost between 211 thousand and 973 thousand increments, and never the same number twice.
predict the output

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.

all 20 orders. 2 end at 2, 18 end at 1. read add write
order #4: T1 → T1 → T2 → T2 → T2 → T1
T1
T2
sharedcount
·
0
1
read → 0
0
2
add → 1
0
3
read → 0
0
4
add → 1
0
5
write 1
1
6
write 1
1
each tile is one interleaving: top row T1, bottom row T2, time going right. only the two where one thread finishes before the other starts end at 2. click a 1 to see where its update dies.

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.

one level deeper: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.

T1
T2
sharedbalance
·
100

one account with 100 in it. two threads each want to withdraw 100.

step 1/5
both checks pass because both happen before either withdrawal. the bug is the gap between the check and the act, not either line on its own.

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.

don'ttwo safe calls, one unsafe pair
java
if (!map.containsKey(user)) {
    map.put(user, new Session(user));
}
doone atomic call
java
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.

fixhowchapter
don't sharekeep state on the stack or inside one threadthreads
don't mutateimmutable objects can be shared freelyimmutability
lock itsynchronized or a Lock makes read-add-write one stepsynchronized
do it atomicallyAtomicInteger, CAS, LongAdderatomics and CAS
use a safe structureConcurrentHashMap.merge, computeIfAbsentconcurrent 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:

Main.java
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());
}
output
count = 2000000

every time. that's the difference.

keylines
  • 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.