Skip to content

10 - Concurrency II: the tools that fix races

What this session is

About two hours. Chapter 09 showed you the three problems - races, visibility, ordering. This chapter is the toolbox that fixes them: synchronized and intrinsic locks, ReentrantLock, atomic variables, the thread-safe collections, and how to avoid the deadlock you can create while fixing a race. By the end you can take the broken counter and the check-then-act bug from chapter 09 and make them correct, and you'll know which tool to reach for.

synchronized: mutual exclusion + visibility in one keyword

The foundational fix. synchronized ensures that only one thread at a time can execute a guarded block, and it establishes the happens-before edge (chapter 09) that makes writes visible. It solves races and visibility together.

The broken counter from chapter 09, fixed:

public class Counter {
    private int count = 0;

    public synchronized void increment() {   // only one thread in here at a time
        count++;                              // now atomic with respect to other threads
    }

    public synchronized int get() {
        return count;
    }
}

Run two threads incrementing 100,000 times each through this and you get exactly 200000, every time. The synchronized keyword guarantees the read-modify-write of count++ completes without interruption from another thread, and that the result is visible to everyone.

How it works: intrinsic locks (monitors)

Every Java object has an associated intrinsic lock (also called a monitor). synchronized uses it:

  • synchronized instance method locks on this.
  • synchronized static method locks on the Class object.
  • synchronized (someObject) { ... } block locks on someObject.

A thread entering a synchronized block must acquire the lock; if another thread holds it, the entering thread blocks (waits) until it's released. Exiting the block releases the lock. Only one thread can hold a given lock at a time - that's the mutual exclusion.

The block form lets you lock on a specific object and narrow the critical section:

public class BankAccount {
    private final Object lock = new Object();   // a dedicated lock object
    private double balance;

    public void deposit(double amount) {
        synchronized (lock) {              // critical section - as small as possible
            balance += amount;
        }
    }
}

Using a private, dedicated lock object (rather than this) is good practice: it prevents outside code from accidentally (or maliciously) locking on your object and interfering with your synchronization. The lock is an implementation detail; keep it private.

The critical rules of synchronized

  1. Guard all access to shared mutable state with the same lock. If increment() is synchronized but get() isn't, get() can see a torn or stale value. Every read and write of the shared data must go through the same lock. A lock only protects against other threads using the same lock.

  2. Keep critical sections small. While you hold a lock, every other thread wanting it waits. Do the minimum inside synchronized; never do I/O, network calls, or call unknown code while holding a lock (that's a deadlock risk and a throughput killer).

  3. Synchronized is reentrant. A thread that holds a lock can re-acquire it (e.g., a synchronized method calling another synchronized method on the same object) without deadlocking itself. The JVM tracks a hold count.

ReentrantLock: explicit locking with more control

synchronized is implicit and scoped to a block. java.util.concurrent.locks.ReentrantLock is an explicit lock object with the same semantics plus extra capabilities.

import java.util.concurrent.locks.ReentrantLock;

public class Counter {
    private final ReentrantLock lock = new ReentrantLock();
    private int count = 0;

    public void increment() {
        lock.lock();                  // acquire
        try {
            count++;
        } finally {
            lock.unlock();            // ALWAYS release in finally - or you leak the lock forever
        }
    }
}

The lock()/try/finally/unlock() pattern is mandatory: if the body throws and you don't unlock in finally, the lock is never released and every other thread blocks forever. This verbosity is the price of the extra power, which is:

  • tryLock() - attempt to acquire without blocking forever; returns false (or times out) if you can't get it. Lets you avoid waiting indefinitely.
    if (lock.tryLock(1, TimeUnit.SECONDS)) {
        try { /* got it */ } finally { lock.unlock(); }
    } else {
        /* couldn't get the lock in 1s - do something else instead of blocking */
    }
    
  • Interruptible locking (lockInterruptibly()) - a waiting thread can be cancelled.
  • Fairness - an optional FIFO ordering so threads acquire in request order (slower, rarely needed).
  • Multiple condition variables (newCondition()) for advanced wait/signal patterns.

The guidance: use synchronized by default - it's simpler, less error-prone (no forgotten unlock), and the JVM optimizes it well. Reach for ReentrantLock only when you need its specific features (tryLock with timeout, interruptibility, fairness, or multiple conditions).

ReadWriteLock: many readers, one writer

When data is read far more often than written, a plain lock is wasteful - it blocks readers from each other even though concurrent reads are safe. ReentrantReadWriteLock allows many simultaneous readers but exclusive writers:

import java.util.concurrent.locks.ReentrantReadWriteLock;

public class Cache {
    private final ReentrantReadWriteLock rw = new ReentrantReadWriteLock();
    private final Map<String, String> map = new HashMap<>();

    public String get(String key) {
        rw.readLock().lock();                 // many threads can hold the read lock at once
        try { return map.get(key); }
        finally { rw.readLock().unlock(); }
    }

    public void put(String key, String value) {
        rw.writeLock().lock();                // exclusive - blocks all readers and writers
        try { map.put(key, value); }
        finally { rw.writeLock().unlock(); }
    }
}

Use it for read-heavy shared data. (Though for the specific case of a map, ConcurrentHashMap below is usually better - it's lock-striped internally and you don't manage locks at all.)

Atomic variables: lock-free single-variable updates

For the common case of a single counter or flag that multiple threads update, locking is overkill. The java.util.concurrent.atomic package gives lock-free atomic types built on hardware compare-and-swap (CAS) instructions - faster than locks under contention.

The counter, the lock-free way:

import java.util.concurrent.atomic.AtomicInteger;

public class Counter {
    private final AtomicInteger count = new AtomicInteger(0);

    public void increment() {
        count.incrementAndGet();    // atomic ++ in one call, no lock
    }

    public int get() {
        return count.get();
    }
}

incrementAndGet() is a single atomic operation - no race, no lock, no synchronized. The atomics:

AtomicInteger ai = new AtomicInteger(0);
ai.incrementAndGet();        // ++ai, atomic
ai.getAndAdd(5);             // add 5, return old value
ai.compareAndSet(10, 20);    // if value is 10, set to 20; atomic check-and-act

AtomicLong;                  // long version
AtomicBoolean;               // boolean version
AtomicReference<T>;          // atomic reference to an object
LongAdder;                   // even faster counter under HIGH contention (preferred for hot counters)

compareAndSet (CAS) is the primitive under all of them: "if the current value is X, atomically set it to Y; tell me if it worked." It lets you build lock-free read-modify-write loops:

AtomicReference<List<String>> ref = new AtomicReference<>(List.of());
// Atomically add to an immutable list without a lock:
List<String> oldList, newList;
do {
    oldList = ref.get();
    newList = new ArrayList<>(oldList);
    newList.add("item");
} while (!ref.compareAndSet(oldList, newList));   // retry if someone else changed it

Use atomics for single-variable counters/flags/references. For coordinating multiple related variables together, you still need a lock (atomics only make one variable atomic; "increment A and B together" needs a lock around both).

Thread-safe collections

A plain HashMap or ArrayList is not thread-safe (chapter 05) - concurrent modification can corrupt it or throw. The java.util.concurrent package provides safe versions designed for concurrency.

ConcurrentHashMap - the workhorse. A thread-safe HashMap that allows concurrent reads and writes without locking the whole map (it's internally lock-striped/lock-free). Use it whenever multiple threads share a map.

import java.util.concurrent.ConcurrentHashMap;

ConcurrentHashMap<String, Integer> counts = new ConcurrentHashMap<>();
counts.merge("key", 1, Integer::sum);    // atomic increment - the right way to count concurrently
counts.compute("key", (k, v) -> (v == null ? 0 : v) + 1);   // atomic compound update

Critically, its compound methods (merge, compute, computeIfAbsent, putIfAbsent) are atomic - they fix the check-then-act race from chapter 09. Two threads both doing counts.merge(k, 1, Integer::sum) will correctly count both, no lost updates, no lock needed.

The check-then-act bug from chapter 09, fixed:

// BROKEN (chapter 09): two threads both pass the check, both compute.
// if (!map.containsKey(k)) map.put(k, expensiveCompute());

// FIXED: computeIfAbsent is atomic - expensiveCompute runs exactly once per key.
map.computeIfAbsent(k, key -> expensiveCompute());

Other concurrent collections:

  • CopyOnWriteArrayList - thread-safe list where every write copies the whole array. Great for read-heavy, write-rare data (listener lists, config). Terrible for write-heavy (copies on every write).
  • ConcurrentLinkedQueue - lock-free FIFO queue.
  • BlockingQueue (e.g., ArrayBlockingQueue, LinkedBlockingQueue) - a queue where take() blocks until an element is available and put() blocks when full. The backbone of producer-consumer patterns and thread pools (chapter 11).
BlockingQueue<Task> queue = new LinkedBlockingQueue<>();
// Producer thread:
queue.put(task);                 // blocks if the queue is full
// Consumer thread:
Task t = queue.take();           // blocks until a task is available

The guidance: for shared collections, reach for ConcurrentHashMap (maps), CopyOnWriteArrayList (read-heavy lists), and BlockingQueue (handoff between threads) instead of wrapping plain collections in synchronized. They're designed for concurrency and far outperform a giant lock around a HashMap.

Deadlock: the bug you create while fixing races

Locks fix races but introduce a new failure mode: deadlock - two threads each holding a lock the other needs, both waiting forever.

// Thread 1                          // Thread 2
synchronized (lockA) {               synchronized (lockB) {
    synchronized (lockB) {               synchronized (lockA) {
        // ...                                // ...
    }                                    }
}                                    }

Thread 1 holds A, wants B. Thread 2 holds B, wants A. Neither can proceed. The program hangs - no exception, no crash, just frozen threads. This happens whenever two threads acquire multiple locks in different orders.

The classic real example - transferring money between accounts:

void transfer(Account from, Account to, double amount) {
    synchronized (from) {           // lock `from`
        synchronized (to) {         // lock `to`
            from.debit(amount);
            to.credit(amount);
        }
    }
}
// transfer(a, b) on one thread locks a then b.
// transfer(b, a) on another thread locks b then a.
// DEADLOCK.

The fix: always acquire multiple locks in a consistent global order. If every thread locks accounts in the same order (say, by account ID), the cycle can't form:

void transfer(Account from, Account to, double amount) {
    // Always lock the lower-id account first - consistent order, no cycle possible.
    Account first  = from.id() < to.id() ? from : to;
    Account second = from.id() < to.id() ? to : from;
    synchronized (first) {
        synchronized (second) {
            from.debit(amount);
            to.credit(amount);
        }
    }
}

Other deadlock defenses: hold one lock at a time when possible; use tryLock with a timeout (back off and retry instead of waiting forever); use higher-level concurrency tools (chapter 11) that avoid manual locking entirely. The rule to remember: multiple locks acquired in inconsistent order = deadlock waiting to happen.

Choosing the right tool

Situation Reach for
Single counter / flag, multiple writers AtomicInteger / AtomicLong / LongAdder
Single reference swapped atomically AtomicReference + compareAndSet
Guard a few related fields together synchronized (block on a private lock)
Need tryLock, timeout, or fairness ReentrantLock
Read-heavy shared data ReentrantReadWriteLock (or a concurrent collection)
Shared map ConcurrentHashMap
Read-heavy, write-rare list CopyOnWriteArrayList
Handoff work between threads BlockingQueue
Counting concurrently ConcurrentHashMap.merge or LongAdder

The meta-rule: prefer the highest-level tool that fits. A ConcurrentHashMap is better than a synchronized block around a HashMap; an AtomicInteger is better than a lock around an int. The high-level tools are correct by construction and faster under contention. Drop to manual synchronized/ReentrantLock only when no higher-level tool fits your exact coordination need.

Try it

  1. Fix the counter four ways. Take chapter 09's RaceDemo. Fix it with (a) synchronized method, (b) synchronized block on a private lock, (c) AtomicInteger, (d) LongAdder. Run each with 4 threads x 1,000,000 increments. Confirm all four give exactly 4,000,000. Time them - note the atomics/adder are faster than the locks under this contention.

  2. Forget the finally. Write a ReentrantLock counter but unlock() outside a finally, and make the body throw on some iterations. Watch the program hang (the lock never releases). Move unlock() into finally and watch it work. Feel why the pattern is mandatory.

  3. Fix check-then-act. Take chapter 09's double-expensiveCompute bug. Fix it with ConcurrentHashMap.computeIfAbsent. Add a print inside the compute lambda and confirm it runs exactly once per key, even with 8 threads racing.

  4. Build a deadlock. Write the two-account transfer with inconsistent lock order. Run transfer(a, b) and transfer(b, a) on two threads in a tight loop. It will hang within seconds. Take a thread dump (jstack <pid> or Ctrl-\) and read it - the JVM tells you "Found one Java-level deadlock" and names the threads and locks. Then apply the ordered-lock fix and confirm it runs forever without hanging.

  5. ReadWriteLock vs synchronized. Build a read-heavy cache (1000 reads per write) both with a plain synchronized and with a ReentrantReadWriteLock. Run many reader threads. The read-write version should show higher read throughput because readers don't block each other.

  6. BlockingQueue producer-consumer. One producer thread puts 100 tasks into a LinkedBlockingQueue; three consumer threads take and process them. Use a poison-pill or count to stop cleanly. Notice you wrote zero synchronized - the queue handles all the coordination.

What you might wonder

"synchronized vs ReentrantLock - really, which?" Default to synchronized: simpler, can't-forget-to-unlock, well-optimized, and reads clearly. Use ReentrantLock only when you specifically need tryLock/timeout, interruptible acquisition, fairness, or multiple condition variables. If you're not using one of those features, synchronized is the better choice.

"Are atomics always faster than locks?" Under low contention, similar. Under high contention, atomics (especially LongAdder, which spreads updates across cells) win because they don't block threads - they retry. But atomics only handle single-variable updates; the moment you need to update multiple things together atomically, you need a lock. Right tool for the granularity.

"Is ConcurrentHashMap just a synchronized HashMap?" No, and the difference matters. Collections.synchronizedMap(new HashMap<>()) locks the entire map for every operation - one thread at a time, total. ConcurrentHashMap allows many threads to operate concurrently (lock striping / lock-free reads) and provides atomic compound methods. It's dramatically more scalable. Never use synchronizedMap for a contended map.

"How do I detect deadlocks?" A thread dump (jstack, or jcmd <pid> Thread.print) explicitly reports "Found one Java-level deadlock" with the cycle. ThreadMXBean.findDeadlockedThreads() detects them programmatically. But detection is after-the-fact - the real defense is consistent lock ordering so they can't form. Chapter 13 covers reading thread dumps.

"What's a race condition vs a data race?" Often used interchangeably, but: a data race is the specific low-level event of unsynchronized concurrent access to a variable (what the JMM forbids). A race condition is the broader correctness bug where timing affects results (check-then-act can be a race condition even using thread-safe pieces, if the sequence isn't atomic). Fixing data races (synchronize access) doesn't automatically fix all race conditions (you may need the whole compound operation atomic).

"Can I just make everything synchronized to be safe?" No - over-synchronizing kills performance (threads serialize, defeating the point of concurrency) and increases deadlock risk (more locks, more chances for inconsistent ordering). The goal is the minimum synchronization that's correct: minimize shared mutable state (chapter 09), then guard only what's truly shared, with the highest-level tool that fits.

Done

  • You can fix races with synchronized (mutual exclusion + visibility via the intrinsic lock).
  • You know ReentrantLock for tryLock/timeout/fairness, and the mandatory lock()/finally/unlock() pattern.
  • You know ReadWriteLock for read-heavy data.
  • You can use atomics (AtomicInteger, LongAdder, compareAndSet) for lock-free single-variable updates.
  • You reach for ConcurrentHashMap, CopyOnWriteArrayList, and BlockingQueue instead of locking plain collections, and you know the concurrent map's atomic compound methods fix check-then-act.
  • You can recognize, reproduce, and prevent deadlock with consistent lock ordering.
  • You can choose the right tool by granularity, preferring the highest-level one that fits.

Next: the highest-level concurrency - executors, futures, CompletableFuture, and virtual threads. Where you stop managing threads and locks by hand entirely.

Next: Concurrency III →

Comments