Race conditions in Java
count++ is read-modify-write and not atomic.
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. writeWatch 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.
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?
Always exactly 20,000Any value up to 20,000, often lessAlways 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.
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;
}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.
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
- count++ is read → modify → write, not atomic
- Lost updates appear only sometimes, so tests often miss them
- Check-then-act (if free, then take) is another race
- Fix with locks, atomics, or by not sharing state
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?
- Any value up to 20,000 — often less
- Always exactly 20,000
- Always exactly 10,000
- 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?
- Lost updates from a non-atomic read-modify-write
- The long overflowed
- The garbage collector discarded some increments
- 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.