RizTech Academy logo
RizTech Academy
ConcurrencyLesson 2 of 830 min

Race conditions: breaking a counter on purpose

Eight threads. Each adds one to a counter a hundred thousand times. The answer should be eight hundred thousand.

expected 800,000  got 229,176  lost 570,824

Seventy per cent of the increments vanished. No exception, no warning, no indication that anything went wrong. This lesson is about why, and about the three ways to fix it.

Why count++ is not one operation

It looks atomic. It is three steps:

1. read count from memory
2. add 1
3. write count back

Two threads interleaved:

Thread A: read 100
Thread B: read 100
Thread A: add 1 -> 101
Thread B: add 1 -> 101
Thread A: write 101
Thread B: write 101

Two increments, one result. This is a race condition: the answer depends on timing you do not control.

With eight threads and modern CPU caches the losses are far worse than the example above suggests, because each core is working from its own cached copy. Hence seventy per cent.

The result is also not reproducible. Run it again and the number differs. That is the defining property, and it is why these bugs survive testing and appear in production under load.

Fix one: AtomicInteger

static final AtomicInteger counter = new AtomicInteger();
counter.incrementAndGet();
expected 800,000  got 800,000

The atomic classes use a CPU instruction called compare-and-swap that performs the read, modify and write as one indivisible step.

Class For
AtomicInteger / AtomicLong Counters, sequence numbers
AtomicBoolean Flags
AtomicReference<T> Swapping an object reference
LongAdder A counter under very high contention — faster than AtomicLong
Method Does
incrementAndGet() Adds 1, returns the new value
getAndIncrement() Returns the old value, adds 1
addAndGet(n) Adds n
compareAndSet(expected, new) Sets only if it still holds expected
updateAndGet(fn) Applies a function atomically

Use an atomic for a single shared number. It is the simplest fix and the fastest.

Fix two: synchronized

static synchronized void increment() {
    counter++;
}
expected 800,000  got 800,000

synchronized means only one thread at a time may be inside. Every Java object has a lock; synchronized on an instance method locks this, on a static method locks the class.

The block form locks something specific, which is better because it is narrower and explicit:

private final Object lock = new Object();

void increment() {
    synchronized (lock) {
        counter++;
    }
}

Keep synchronized blocks as short as possible — everything inside is single-threaded, so a slow operation there becomes the bottleneck for the whole application. Never do I/O inside one.

Never synchronise on a String or a boxed number. Both may be shared objects from a pool, so unrelated code can end up locking the same thing. Use a private final Object.

synchronized also provides the visibility guarantee volatile gives, so a field only touched inside synchronized blocks does not need to be volatile.

Fix three: do not share

The best fix. Give each thread its own counter and add them up at the end, or use a stream's sum, or pass immutable data around.

A race condition needs shared mutable state. Remove either word and it cannot happen. Everything this course has said about records, final fields and returning new objects instead of mutating is also a concurrency argument.

The collections are worse

ArrayList          size: 70926 (expected 80,000)
synchronizedList   size: 80000 (expected 80,000)
HashMap            size: 20744 (expected 20,000)
ConcurrentHashMap  size: 20000 (expected 20,000)

Look at the HashMap: 20,744 entries when only 20,000 distinct keys were put in. The internal structure was corrupted by concurrent resizing. It is not merely a lost update — the data structure itself is now wrong, and in older Java versions this could produce an infinite loop on read.

Instead of Use
HashMap ConcurrentHashMap
ArrayList for mostly-read data CopyOnWriteArrayList
ArrayList for a work queue ConcurrentLinkedQueue / BlockingQueue
Collections.synchronizedList Rarely the right answer — see below

Do not use Vector or Hashtable. They are obsolete, synchronise every call, and do not solve the problem below anyway.

Wrapping is not enough: check-then-act

List<String> list = Collections.synchronizedList(new ArrayList<>());

if (!list.contains(key)) {
    list.add(key);
}
distinct keys 5,000, list size 5007

Every individual call was synchronised and the result is still wrong. Two threads can both run contains, both get false, and both add.

Thread safety of individual operations does not make a sequence of them safe. This is the most important idea in the lesson, because it is the one that catches people who think they have already solved the problem.

The fixes:

// 1. An atomic compound operation
map.putIfAbsent(key, value);
map.computeIfAbsent(key, k -> new ArrayList<>());
map.merge(key, 1, Integer::sum);

// 2. Or lock the whole sequence
synchronized (lock) {
    if (!list.contains(key)) {
        list.add(key);
    }
}

ConcurrentHashMap's putIfAbsent, computeIfAbsent and merge are atomic — which is why module 5 introduced them as the idiomatic way to count and group. They were the right answer before you knew about threads, and they are still the right answer now.

Deadlock, in one paragraph

Two threads, two locks, acquired in opposite orders: each holds one and waits forever for the other. Nothing throws; the application simply stops responding.

The rule that prevents it: always acquire locks in the same order, everywhere in the codebase. The way to never have to think about it is to hold one lock at a time, which is another argument for short synchronized blocks.

How to find these bugs

They do not appear in ordinary tests. A test that runs one thread passes every time.

  • Run the operation from many threads in a loop, as this lesson does, and assert the total. That catches lost updates reliably.
  • Run it repeatedly. A single run may pass; a hundred will not.
  • Add -ea and assertions on invariants that must always hold.
  • Review for shared mutable state, which is faster than testing for it. Ask of every field on a shared object: can two requests touch this at once?

Check your work

Why is count++ not atomic? It is read, add, write. Another thread can read between your read and your write, so both writes store the same value.

What is the defining property of a race condition? The result depends on timing, so it is not reproducible — which is why these survive testing.

Which fix is simplest for a single shared number? AtomicInteger.

What does synchronized guarantee besides mutual exclusion? Visibility, the same guarantee volatile gives.

Why not synchronise on a String? String literals are pooled, so unrelated code can lock the same object. Use a private final Object.

Why is a corrupted HashMap worse than a lost update? The structure itself becomes inconsistent — the example produced 20,744 entries from 20,000 distinct keys — and reads afterwards may be wrong or may not terminate.

Why is Collections.synchronizedList not enough for check-then-act? Each call is safe, but two threads can both pass the check before either acts. Use an atomic compound operation or lock the whole sequence.

What prevents deadlock? Acquiring locks in the same order everywhere, and preferably holding only one at a time.

Practice 1, the lost increments. Eight threads times 100,000 should be 800,000; the run in this lesson produced 229,176. Your number will differ and will differ again on the next run — that variation is the bug. With one thread it is always exactly right, which is why the single-threaded test passes.

Practice 4, check-then-act. Collections.synchronizedList with a contains-then-add gave 5,007 entries for 5,000 distinct keys. Replacing it with ConcurrentHashMap.putIfAbsent gives exactly 5,000, because the check and the act are one operation. Wrapping the sequence in a synchronized block also works and is slower, because it serialises the contains scan too.

Practice

  1. Lose some increments. Eight threads, 100,000 increments each, on a plain int. Print expected against actual. Run it five times and write down all five numbers.

  2. Fix it three ways. AtomicInteger, a synchronized method, and giving each thread its own counter summed at the end. Confirm all three give exactly 800,000.

  3. Corrupt a collection. Four threads adding to a shared ArrayList and putting into a shared HashMap. Print both sizes. Note that the map's size can exceed the number of distinct keys.

  4. Fail at check-then-act. Use Collections.synchronizedList with contains then add from four threads and count the duplicates. Then fix it with ConcurrentHashMap.putIfAbsent.

  5. Make a deadlock. Two threads, two locks, opposite orders, a short sleep between acquisitions. Watch the program hang. Then press Ctrl+\ (or run jcmd <pid> Thread.print) and find the words "Found one Java-level deadlock" in the output — the JVM will tell you exactly which two threads and which two locks.

  6. Harder — an audit of real code. Take any class you have written in this course that holds mutable state and ask, field by field, what would happen if two threads used it at once. Write the answer down for each field. Then make the class either immutable or properly synchronised, and say which you chose and why. Immutable is usually easier and is usually the right answer.

Next: executors and virtual threads, which is how you actually run concurrent work.

Stuck on this lesson?

Being stuck is part of it — but being stuck alone for three days is not. Our internship programme pairs this curriculum with code review and one-to-one help from working developers, and it is free.

About the internship