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
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:
Expected: 100000
Actual: 73842Every 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:
- Read the current value of
counterinto a CPU register - Add 1 to that register value
- 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:
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:
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:
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:
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:
| Step | Thread A | Thread B | counter (memory) |
|---|---|---|---|
| 1 | reads 5 | 5 | |
| 2 | reads 5 | 5 | |
| 3 | computes 6 | 5 | |
| 4 | computes 6 | 5 | |
| 5 | writes 6 | 6 | |
| 6 | writes 6 | 6 |
Both incremented, but counter went from 5 to 6 instead of 5 to 7. One update was lost.
Now with AtomicInteger.incrementAndGet():
| Step | Thread A | Thread B | counter (memory) |
|---|---|---|---|
| 1 | CAS(5 -> 6) succeeds | 6 | |
| 2 | CAS(5 -> 6) FAILS (expected 5, found 6) | 6 | |
| 3 | retries: CAS(6 -> 7) succeeds | 7 |
The CAS instruction detects the conflict and retries. No update is ever lost.
Common Mistakes
- **Using
volatileinstead ofAtomicInteger.**volatileguarantees visibility (all threads see the latest value) but does NOT makecounter++atomic. You still get lost updates. - **Forgetting to unlock in a
finallyblock.** If the code betweenlock()andunlock()throws, the lock is held forever. - Locking on different objects. If Thread A locks on
lockAand Thread B locks onlockB, 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 forlongordouble(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
- 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? - Build a thread-safe counter class with
increment(),decrement(), andget()methods. Which approach do you choose and why? - 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.