Bug Hunt: Find the Race in This Counter
The classic compound-update race. The counter looks atomic but isn't, read, increment, write are three separate steps that can interleave. Fix with an atomic primitive or a lock around the read-modify-write.
The puzzle
A teammate wrote a Counter class. They're confident it's correct because "I tested it 100 times and got the right answer." Run their tests twice. The first run is correct; the second is off by one.
The task: figure out why, not just how to fix it.
How to use this Bug Hunt Read the broken code in the preferred language tab below. Try to spot the bug before clicking the second tab (the fix). The puzzle is small but reveals one of the most fundamental concurrency mistakes, and the answer carries over to every shared-state bug seen in production.
What to look for
The bug isn't about visibility (everyone reads the same field) and isn't about ordering. It's about atomicity, whether a single line of code is one operation from the hardware's perspective, or several.
The mental check
For a single line like x++, x += 1, or if (x == y) x = z, ask: how many CPU instructions is this? If the answer is more than one, two threads can interleave between them. That's a race.
After the reveal
The fix uses an atomic compound operation, a single hardware-level instruction that reads, modifies, and writes in one step that no other thread can split. AtomicInteger.incrementAndGet, atomic.Add, Lock-wrapped +=. Pick based on contention level: low contention → atomic primitive, high contention → sharded counter, multi-variable invariant → lock.
Why this matters in production Counter races don't crash. They report wrong numbers. Monitoring shows 12,847 requests; the service actually served 12,852. At low scale, nobody notices. At billing scale, this is a money bug.
Implementations
Two threads each call inc() 100,000 times. The expected final value is 200,000. Run this 10 times, what does it print? Guess before reading the fix.
1 class Counter {
2 int value = 0;
3 void inc() { value++; } // ← spot the bug
4 }
5
6 public class RaceDemo {
7 public static void main(String[] args) throws Exception {
8 Counter c = new Counter();
9 Thread t1 = new Thread(() -> { for (int i = 0; i < 100_000; i++) c.inc(); });
10 Thread t2 = new Thread(() -> { for (int i = 0; i < 100_000; i++) c.inc(); });
11 t1.start(); t2.start();
12 t1.join(); t2.join();
13 System.out.println(c.value); // expected 200000, actual: less, varies
14 }
15 }The bug: value++ compiles to three bytecodes, LOAD value, IADD 1, STORE value. Between LOAD and STORE on thread A, thread B can run its own LOAD and read the same old value. Both then write back the same incremented value. One update is lost. The fix is to make the read-modify-write atomic. AtomicInteger.incrementAndGet() does this with hardware CAS, no locking, fastest. synchronized works but is heavier under contention. LongAdder shards across threads, best for very high contention.
1 // Fix #1, AtomicInteger (preferred for hot counters)
2 class Counter {
3 AtomicInteger value = new AtomicInteger(0);
4 void inc() { value.incrementAndGet(); } // single CAS-based op
5 }
6
7 // Fix #2, synchronized
8 class Counter {
9 int value = 0;
10 synchronized void inc() { value++; }
11 }
12
13 // Fix #3, LongAdder (best under heavy contention)
14 class Counter {
15 LongAdder value = new LongAdder();
16 void inc() { value.increment(); }
17 long get() { return value.sum(); }
18 }Key points
- •Compound operations (++ , += , !x) are NEVER atomic without explicit synchronization
- •Two threads can both read 5, both compute 6, both write 6, losing one increment
- •Tests pass on dev laptops; the race fires under production load
- •Three correct fixes, atomic primitive (best), lock, or per-thread accumulator with merge
Follow-up questions
▸Why isn't `volatile` (Java) / `atomic.Bool` enough?
▸Why does this 'work' on a dev machine?
▸Tests pass 1000 times, is the code safe?
▸When is LongAdder better than AtomicLong?
Gotchas
- !Tests pass on x86, fail on ARM, same code, different memory model
- !Java: AtomicInteger.get() then .set() is NOT atomic (two ops); use accumulateAndGet/updateAndGet
- !Python: even integer assignment may need synchronization on shared globals
- !Go: counter++ on int doesn't flag without -race; always run with -race in CI
- !Locking around the wrong thing, protecting the read but not the write
Every metrics library hits this. StatsD client counters, application throughput counters, request-counters in middleware. The fix is always one of: atomic primitive, sharded counter, or a lock.