LLD
Machine Coding
Interview Course
Java โ€ข Interview Prep
๐Ÿงช Concurrency Practice/

Thread-Safe Counter: Your First Concurrency Bug

Lesson 1 of 5

Thread-Safe Counter: Your First Concurrency Bug


The Problem

You need a counter. Multiple threads increment it. At the end, you want the correct total.

Sounds trivial, right? Let us say 10 threads each increment a counter 10,000 times. The expected result is 100,000. How hard can it be?

Run the code below and find out.


Let us See It Break

java
1public class BrokenCounter {
2    private static int counter = 0;
3
4    public static void main(String[] args) throws InterruptedException {
5        int numThreads = 10;
6        int incrementsPerThread = 10_000;
7        Thread[] threads = new Thread[numThreads];
8
9        for (int i = 0; i < numThreads; i++) {
10            threads[i] = new Thread(() -> {
11                for (int j = 0; j < incrementsPerThread; j++) {
12                    counter++;  // This line is the bug
13                }
14            });
15            threads[i].start();
16        }
17
18        for (Thread t : threads) {
19            t.join();
20        }
21
22        System.out.println("Expected: " + (numThreads * incrementsPerThread));
23        System.out.println("Actual:   " + counter);
24    }
25}

Run it. You will see something like:

java
Expected: 100000
Actual:   73842

Every run gives a different wrong answer. Sometimes 68,000. Sometimes 91,000. Never 100,000.


Why It Breaks

The line counter++ looks like one operation. It is not. It is three operations:

  1. Read the current value of counter into a CPU register
  2. Add 1 to that register value
  3. Write the new value back to counter

This is called a read-modify-write operation. When two threads do this at the same time, they can interleave:

java
Thread A reads counter = 42
Thread B reads counter = 42      <-- reads the SAME value
Thread A writes counter = 43
Thread B writes counter = 43     <-- overwrites A's work!

Both threads did an increment, but the counter only went up by 1. This is called a lost update. Over 100,000 increments, thousands of updates get lost.


Fix #1: The Simple Way (synchronized)

The most straightforward fix is to put a lock around the increment:

java
1public class SynchronizedCounter {
2    private static int counter = 0;
3    private static final Object lock = new Object();
4
5    public static void main(String[] args) throws InterruptedException {
6        int numThreads = 10;
7        int incrementsPerThread = 10_000;
8        Thread[] threads = new Thread[numThreads];
9
10        for (int i = 0; i < numThreads; i++) {
11            threads[i] = new Thread(() -> {
12                for (int j = 0; j < incrementsPerThread; j++) {
13                    synchronized (lock) {
14                        counter++;
15                    }
16                }
17            });
18            threads[i].start();
19        }
20
21        for (Thread t : threads) {
22            t.join();
23        }
24
25        System.out.println("Expected: " + (numThreads * incrementsPerThread));
26        System.out.println("Actual:   " + counter);
27        // Always prints 100000
28    }
29}

synchronized forces one thread at a time into the critical section. It works, but every thread contends for the same lock. For a simple counter, that is a lot of overhead.


Fix #2: The Better Way (AtomicInteger)

AtomicInteger uses CPU-level compare-and-swap (CAS) instructions. No lock needed:

java
1import java.util.concurrent.atomic.AtomicInteger;
2
3public class AtomicCounter {
4    private static final AtomicInteger counter = new AtomicInteger(0);
5
6    public static void main(String[] args) throws InterruptedException {
7        int numThreads = 10;
8        int incrementsPerThread = 10_000;
9        Thread[] threads = new Thread[numThreads];
10
11        for (int i = 0; i < numThreads; i++) {
12            threads[i] = new Thread(() -> {
13                for (int j = 0; j < incrementsPerThread; j++) {
14                    counter.incrementAndGet();
15                }
16            });
17            threads[i].start();
18        }
19
20        for (Thread t : threads) {
21            t.join();
22        }
23
24        System.out.println("Expected: " + (numThreads * incrementsPerThread));
25        System.out.println("Actual:   " + counter.get());
26        // Always prints 100000
27    }
28}

incrementAndGet() does the read-modify-write atomically. If another thread changed the value between the read and write, the CAS detects it and retries automatically. No lock, no blocking, much faster under contention.


Fix #3: The Java Way (ReentrantLock)

ReentrantLock gives you more control than synchronized: try-lock, timed waits, fairness:

java
1import java.util.concurrent.locks.ReentrantLock;
2
3public class LockCounter {
4    private static int counter = 0;
5    private static final ReentrantLock lock = new ReentrantLock();
6
7    public static void main(String[] args) throws InterruptedException {
8        int numThreads = 10;
9        int incrementsPerThread = 10_000;
10        Thread[] threads = new Thread[numThreads];
11
12        for (int i = 0; i < numThreads; i++) {
13            threads[i] = new Thread(() -> {
14                for (int j = 0; j < incrementsPerThread; j++) {
15                    lock.lock();
16                    try {
17                        counter++;
18                    } finally {
19                        lock.unlock();
20                    }
21                }
22            });
23            threads[i].start();
24        }
25
26        for (Thread t : threads) {
27            t.join();
28        }
29
30        System.out.println("Expected: " + (numThreads * incrementsPerThread));
31        System.out.println("Actual:   " + counter);
32        // Always prints 100000
33    }
34}

Always unlock in a finally block. If you forget and an exception is thrown, the lock is held forever and every other thread deadlocks.


Step-by-Step Trace

Let us trace the broken version with two threads, starting at counter = 5:

StepThread AThread Bcounter (memory)
1reads 55
2reads 55
3computes 65
4computes 65
5writes 66
6writes 66

Both incremented, but counter went from 5 to 6 instead of 5 to 7. One update was lost.

Now with AtomicInteger.incrementAndGet():

StepThread AThread Bcounter (memory)
1CAS(5 -> 6) succeeds6
2CAS(5 -> 6) FAILS (expected 5, found 6)6
3retries: CAS(6 -> 7) succeeds7

The CAS instruction detects the conflict and retries. No update is ever lost.


Common Mistakes

  • **Using volatile instead of AtomicInteger.** volatile guarantees visibility (all threads see the latest value) but does NOT make counter++ atomic. You still get lost updates.
  • **Forgetting to unlock in a finally block.** If the code between lock() and unlock() throws, the lock is held forever.
  • Locking on different objects. If Thread A locks on lockA and Thread B locks on lockB, there is no mutual exclusion. Both must lock on the SAME object.
  • **Assuming ++ is atomic.** In Java, i++ is NEVER atomic for shared variables, even for long or double (which are not even guaranteed to be written atomically on 32-bit JVMs).

Interview Tip: When asked about thread-safe counters, start with the problem (lost updates due to non-atomic read-modify-write), mention the three solutions, and recommend AtomicInteger for simple counters. Interviewers love when you explain the CAS mechanism: "It reads the current value, computes the new value, then atomically writes it only if the current value has not changed. If it has, it retries."


Try It Yourself

  1. Benchmark all three approaches. Use System.nanoTime() to measure how long each approach takes with 10 threads and 1,000,000 increments each. Which is fastest?
  2. Build a thread-safe counter class with increment(), decrement(), and get() methods. Which approach do you choose and why?
  3. Break it differently. Change the code so two threads each do 50,000 increments and 50,000 decrements. The final value should be 0. Does the broken version ever produce 0?

Summary

  • counter++ is three operations (read, add, write), not one. Concurrent threads can interleave and lose updates.
  • synchronized works but creates contention: only one thread at a time.
  • AtomicInteger uses lock-free CAS operations: faster under contention.
  • ReentrantLock gives explicit control: try-lock, timeouts, fairness. Always unlock in finally.