🧵 Concurrency Fundamentals · Advanced

Race conditions in Java

count++ is read-modify-write and not atomic.

🧩 The mysteryTwo threads each run count++ one million times. Expected: 2,000,000. Actual: 1,374,021, and a different number on every run. Where did the rest go?

One line, three steps

count++ looks like one step, but it's three: read count, add one, write it back. Nothing makes those three steps indivisible (atomic), so another thread can slip in between them.

count++;
// is really:
int tmp = count;   // 1. read
tmp = tmp + 1;     // 2. modify
count = tmp;       // 3. write

Watch an update vanish

count is 5. 1. A reads 5. 2. B reads 5. 3. A writes 6. 4. B writes 6. Two increments ran, yet count grew by one. B overwrote A's work: a lost update. A bug whose result depends on how threads happen to interleave is a race condition.

🔮 Predict it

Your turn

Two threads each do count++ 10,000 times on a shared int with no locking. After both are joined, what can count be?

  1. Always exactly 20,000
  2. Any value up to 20,000, often less
  3. Always exactly 10,000
Show the answer

Lost updates strike at random, so the total is at most 20,000 and usually less, changing from run to run. (In theory it can even sink far below 10,000.) No exception is thrown: the numbers are just silently wrong.

⚠️ The trap

volatile won't save you

Tempting fix: mark count as volatile. It doesn't work. volatile makes each read and write *visible*, but count++ is still three separate steps, so two threads can still both read 5. Atomicity needs a lock, an AtomicInteger/AtomicLong, or a LongAdder.

Check-then-act: the other race

Two users try to book the last seat. Both run the check, both see free == 1, both act: two bookings, one seat. The check and the act must happen as one unit, for example by making book() synchronized.

boolean book() {
    if (free > 0) {   // check
        free--;       // act
        return true;
    }
    return false;
}
🤔 Think first

Why do the tests pass?

This race is in your code, yet every unit test is green. How?

Think about it, then reveal the answer

The bad interleaving needs exact timing. With one test thread on a quiet laptop it almost never happens. Under production load, with many cores and threads, it happens constantly. Races hide in tests and appear only sometimes, which is exactly what makes them dangerous.

💼 In the real world

The 3% that vanished

A busy site's page-view counter reports about 3% fewer views than the access logs. Request threads run views++ on a shared long. Under load, concurrent increments overwrite each other. The fix is one line: an AtomicLong, or a LongAdder for very hot counters.

Key takeaways

  1. count++ is read → modify → write, not atomic
  2. Lost updates appear only sometimes, so tests often miss them
  3. Check-then-act (if free, then take) is another race
  4. Fix with locks, atomics, or by not sharing state
🤯 Did you know?

Even a single write can tear: the JLS (§17.7) allows a non-volatile 64-bit long or double write to be split into two 32-bit halves.

Practice questions

Two threads each run count++ 10,000 times on a shared int with no synchronization. After both are joined, what can count be?

  1. Any value up to 20,000 — often less
  2. Always exactly 20,000
  3. Always exactly 10,000
  4. It throws ConcurrentModificationException
Check your answer

Any value up to 20,000 — often less. Increments interleave unpredictably, so some are lost. The result changes from run to run, which is exactly what makes races hard to catch.

A busy site's page-view counter reports about 3% fewer views than the access logs. Request threads do views++ on a shared long field. Most likely cause?

  1. Lost updates from a non-atomic read-modify-write
  2. The long overflowed
  3. The garbage collector discarded some increments
  4. The JIT compiler removed the ++ as dead code
Check your answer

Lost updates from a non-atomic read-modify-write. Under load, concurrent views++ calls overwrite each other. Use AtomicLong or LongAdder for shared counters.

Next: the bathroom key. Java's synchronized lets only one thread into the room at a time, and the lost updates disappear.