Concurrency Basics

Project: A Thread-Safe Counter

15 min Lesson 10 of 13

Project: A Thread-Safe Counter

Throughout this tutorial you have studied every major concurrency primitive: threads, synchronization, volatile, atomics, wait/notify, and deadlocks. This final lesson ties it all together by building a correct, production-quality thread-safe counter from the ground up — iterating from a naive broken version through several successively better designs, comparing their trade-offs, and landing on the right choice for different contexts.

Why a Counter is the Perfect Case Study

A counter looks trivially simple — just an integer that goes up. Yet every concurrency hazard from this tutorial appears in its implementation: race conditions, memory visibility gaps, lost updates, and over-synchronization that kills throughput. Fixing each problem in isolation is instructive; fixing all of them together is engineering.

Version 1: The Broken Counter

Start with the obvious, wrong approach so you can see exactly what breaks:

public class BrokenCounter { private int count = 0; public void increment() { count++; // NOT atomic: read, add, write — 3 separate operations } public int get() { return count; } }

The count++ expression compiles to three bytecode instructions: read the current value, add 1, write back. Two threads can both read the same stale value, both compute the same result, and both write it — losing one increment. Run it with 10 threads doing 100 000 increments each and the final value is almost never 1 000 000.

Do not rely on "it usually works." Race conditions are probabilistic. On a single-core machine or under light load the broken counter may appear correct for months, then fail spectacularly in production on a modern multi-core server.

Version 2: synchronized — Correct but Coarse

Mark both methods synchronized on the same monitor:

public class SynchronizedCounter { private int count = 0; public synchronized void increment() { count++; } public synchronized int get() { return count; } public synchronized void reset() { count = 0; } }

This is correct. The intrinsic lock on this guarantees mutual exclusion and happens-before visibility. It is also the clearest code — anyone reading it immediately understands the contract.

The cost is contention: every increment() call blocks every other thread, even when reads vastly outnumber writes. For a simple global counter that is acceptable. For a high-throughput counter shared across hundreds of threads it may become a bottleneck.

Version 3: AtomicInteger — Correct and Fast

Replace the int field with AtomicInteger from java.util.concurrent.atomic:

import java.util.concurrent.atomic.AtomicInteger; public class AtomicCounter { private final AtomicInteger count = new AtomicInteger(0); public void increment() { count.incrementAndGet(); } public int get() { return count.get(); } public void reset() { count.set(0); } // Conditional increment: only increment if below a limit public boolean incrementIfBelow(int limit) { int current; do { current = count.get(); if (current >= limit) return false; } while (!count.compareAndSet(current, current + 1)); return true; } }

AtomicInteger uses a Compare-And-Swap (CAS) CPU instruction rather than a mutex. CAS tries to update the value only if it still equals the expected value; if another thread changed it first, CAS fails and the loop retries. There is no blocking, no context switch, and no thread is ever "parked" waiting for a lock. Under high concurrency this is dramatically faster than synchronized.

CAS is optimistic. It assumes contention is rare and retries on failure. Synchronized is pessimistic — it blocks everyone else upfront. For short critical sections with many threads, optimistic CAS wins. For long critical sections, pessimistic locking can be better because CAS loops waste CPU spinning.

Version 4: LongAdder — Maximum Throughput

When you only need the final sum and do not need a consistent snapshot during counting (typical for metrics, hit counters, rate limiters), LongAdder is faster still:

import java.util.concurrent.atomic.LongAdder; public class LongAdderCounter { private final LongAdder adder = new LongAdder(); public void increment() { adder.increment(); } public long get() { return adder.sum(); // approximate if increments are concurrent } public void reset() { adder.reset(); } }

LongAdder maintains a cell array — each CPU thread tends to update its own private cell, reducing CAS contention to near zero. sum() adds all cells together. The trade-off: sum() is not atomic relative to concurrent increment() calls, so you may read a slightly out-of-date total. For counters where eventual correctness is fine (page-view counts, throughput metrics) this is the right choice.

Version 5: A Thread-Safe Account Class

Real systems need richer invariants. Here is a bank account that prevents negative balances using synchronized with compound operations:

public class BankAccount { private final String owner; private long balanceCents; // store as cents to avoid floating-point issues public BankAccount(String owner, long initialBalanceCents) { this.owner = owner; this.balanceCents = initialBalanceCents; } public synchronized void deposit(long cents) { if (cents <= 0) throw new IllegalArgumentException("Deposit must be positive"); balanceCents += cents; } public synchronized boolean withdraw(long cents) { if (cents <= 0) throw new IllegalArgumentException("Withdrawal must be positive"); if (balanceCents < cents) return false; // insufficient funds balanceCents -= cents; return true; } public synchronized long getBalance() { return balanceCents; } // Transfer must lock both accounts — but in a fixed order to prevent deadlock public static void transfer(BankAccount from, BankAccount to, long cents) { // Determine lock order by identity hash code to prevent deadlock BankAccount first = System.identityHashCode(from) <= System.identityHashCode(to) ? from : to; BankAccount second = first == from ? to : from; synchronized (first) { synchronized (second) { if (!from.withdraw(cents)) { throw new IllegalStateException("Insufficient funds for transfer"); } to.deposit(cents); } } } }
Lock ordering prevents deadlock. Thread A locking account-1 then account-2 while Thread B locks account-2 then account-1 is a classic deadlock. By always acquiring locks in a consistent order (here, by identity hash code) both threads agree on the sequence and deadlock is impossible.

Choosing the Right Implementation

  • Simple, low-contention counter: synchronized methods — clearest code, easiest to reason about.
  • High-contention increment/decrement, need consistent reads: AtomicInteger / AtomicLong — non-blocking, fast.
  • Pure accumulation, approximate reads acceptable: LongAdder — highest throughput for hot paths like metrics.
  • Compound invariants (check-then-act, multi-field updates): synchronized blocks — atomicity spans multiple fields/checks that atomics cannot express.

Putting It All Together: A Runnable Demo

import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicInteger; public class CounterDemo { public static void main(String[] args) throws InterruptedException { final int THREADS = 10; final int OPS_EACH = 100_000; AtomicInteger atomicCounter = new AtomicInteger(0); ExecutorService pool = Executors.newFixedThreadPool(THREADS); for (int i = 0; i < THREADS; i++) { pool.submit(() -> { for (int j = 0; j < OPS_EACH; j++) { atomicCounter.incrementAndGet(); } }); } pool.shutdown(); pool.awaitTermination(30, TimeUnit.SECONDS); System.out.println("Expected : " + (THREADS * OPS_EACH)); System.out.println("Actual : " + atomicCounter.get()); // Always prints: Expected: 1000000 / Actual: 1000000 } }

Summary

A thread-safe counter is a microcosm of all concurrent programming: you must identify shared mutable state, choose the right synchronization strategy, and reason about compound operations. You now have four tools — synchronized, AtomicInteger, LongAdder, and lock-ordered multi-object locking — each with a clear use case. Pick the simplest one that satisfies your correctness and performance requirements, document the invariants in code, and you will write concurrent Java that is both correct and fast.