Guide contents

Lesson 25 of 71

Locks and Synchronizers

The java.util.concurrent.locks package and the synchronizers in java.util.concurrent give control that synchronized does not: timed and interruptible lock attempts, several wait queues per lock, shared read access, and coordination between groups of threads. This lesson builds on Threads, synchronized, wait and notify. After it, a reader can pick the right tool for a coordination problem and explain how it is built.

Tool Purpose
ReentrantLock Mutual exclusion with tryLock, timeouts, fairness, interruptible waits
Condition Named wait queues attached to a Lock
ReentrantReadWriteLock Many readers or one writer
StampedLock Read/write lock with optimistic reads, not reentrant
AQS The framework most of the above are built on
Semaphore Limit the number of concurrent holders of a permit
CountDownLatch Wait until N events happen, one-shot
CyclicBarrier N threads wait for each other, reusable
Phaser Barrier with a changing number of parties and phases

Every example joins its threads, or waits on a synchronizer, before printing results, so the output does not depend on scheduling.

Example 1: ReentrantLock as a replacement for synchronized

A ReentrantLock is a mutual-exclusion lock that the same thread can acquire again without blocking itself. The unlock call always goes in a finally block, because unlike synchronized the lock is not released when an exception leaves the block.

Java
import java.util.concurrent.locks.ReentrantLock;

public class LockCounter {
    private final ReentrantLock lock = new ReentrantLock();
    private long count;

    void increment() {
        lock.lock();
        try {
            count++;
        } finally {
            lock.unlock();
        }
    }

    public static void main(String[] args) throws InterruptedException {
        LockCounter c = new LockCounter();
        Runnable work = () -> { for (int i = 0; i < 100_000; i++) c.increment(); };
        Thread a = new Thread(work), b = new Thread(work);
        a.start(); b.start();
        a.join(); b.join();
        System.out.println("count = " + c.count);

        c.lock.lock();
        c.lock.lock();               // reentrant: same thread, no deadlock
        System.out.println("hold count = " + c.lock.getHoldCount());
        c.lock.unlock();
        c.lock.unlock();
        System.out.println("locked = " + c.lock.isLocked());
    }
}
Output
count = 200000
hold count = 2
locked = false

The lock keeps a hold count. Each lock() by the owner increments it and each unlock() decrements it; the lock is free at zero. Calling unlock() from a thread that does not own the lock throws IllegalMonitorStateException.

Example 2: tryLock, timeouts, and interruptible waits

tryLock() returns at once with true or false. tryLock(timeout, unit) waits up to the timeout and responds to interruption. lockInterruptibly() waits without a limit but throws InterruptedException when the thread is interrupted. A thread blocked on synchronized cannot be interrupted out of the wait; these methods can.

Java
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.ReentrantLock;

public class TryLockDemo {
    public static void main(String[] args) throws Exception {
        ReentrantLock lock = new ReentrantLock();
        CountDownLatch held = new CountDownLatch(1);
        CountDownLatch release = new CountDownLatch(1);

        Thread owner = new Thread(() -> {
            lock.lock();
            try {
                held.countDown();
                release.await();
            } catch (InterruptedException ignored) {
            } finally {
                lock.unlock();
            }
        });
        owner.start();
        held.await();

        System.out.println("tryLock: " + lock.tryLock());
        System.out.println("timed tryLock: " + lock.tryLock(50, TimeUnit.MILLISECONDS));

        Thread waiter = new Thread(() -> {
            try {
                lock.lockInterruptibly();
                lock.unlock();
                System.out.println("waiter got lock");
            } catch (InterruptedException e) {
                System.out.println("waiter interrupted");
            }
        });
        waiter.start();
        waiter.interrupt();
        waiter.join();

        release.countDown();
        owner.join();
        System.out.println("after release: " + lock.tryLock());
        lock.unlock();
    }
}
Output
tryLock: false
timed tryLock: false
waiter interrupted
after release: true

The waiter is interrupted while the owner still holds the lock, so it cannot succeed. If the interrupt arrives before lockInterruptibly() is called, the method throws at once because it checks the interrupt status first. A CountDownLatch (Example 6) is used here only to make the order of steps fixed.

Timed tryLock is the standard way to avoid deadlock when a thread needs two locks: if the second lock is not acquired in time, release the first and retry. This removes the "hold and wait" condition from the four deadlock conditions.

Fairness. new ReentrantLock(true) creates a fair lock: waiting threads acquire it in roughly arrival order. The default is unfair, which lets a newly arriving thread take a just-released lock before queued threads ("barging"). Unfair locks have higher throughput because the lock does not sit idle while a parked thread wakes up; fair locks reduce starvation at a throughput cost. The untimed tryLock() barges even on a fair lock.

Example 3: Condition for a bounded buffer

A Condition is a wait queue bound to a Lock, created with lock.newCondition(). await() releases the lock and waits; signal() wakes one waiter. A lock can have several conditions, so producers and consumers wait in separate queues, which wait/notify on one monitor cannot do.

Java
import java.util.ArrayDeque;
import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.ReentrantLock;

public class BoundedBuffer<T> {
    private final ArrayDeque<T> items = new ArrayDeque<>();
    private final int capacity;
    private final ReentrantLock lock = new ReentrantLock();
    private final Condition notFull = lock.newCondition();
    private final Condition notEmpty = lock.newCondition();

    BoundedBuffer(int capacity) { this.capacity = capacity; }

    void put(T item) throws InterruptedException {
        lock.lock();
        try {
            while (items.size() == capacity) notFull.await();   // loop: spurious wakeups
            items.addLast(item);
            notEmpty.signal();
        } finally { lock.unlock(); }
    }

    T take() throws InterruptedException {
        lock.lock();
        try {
            while (items.isEmpty()) notEmpty.await();
            T item = items.removeFirst();
            notFull.signal();
            return item;
        } finally { lock.unlock(); }
    }

    public static void main(String[] args) throws InterruptedException {
        BoundedBuffer<Integer> buf = new BoundedBuffer<>(2);
        long[] sum = new long[1];
        Thread producer = new Thread(() -> {
            try { for (int i = 1; i <= 1000; i++) buf.put(i); } catch (InterruptedException ignored) {}
        });
        Thread consumer = new Thread(() -> {
            try { for (int i = 0; i < 1000; i++) sum[0] += buf.take(); } catch (InterruptedException ignored) {}
        });
        producer.start(); consumer.start();
        producer.join(); consumer.join();
        System.out.println("sum = " + sum[0]);
    }
}
Output
sum = 500500

The while loop around await() is required for the same reason as with wait(): spurious wakeups are allowed, and another thread may change the state between the signal and the reacquisition of the lock. join() makes the consumer's write to sum[0] visible to main.

Example 4: ReentrantReadWriteLock for a read-mostly cache

A read-write lock has a shared read lock and an exclusive write lock. Any number of threads can hold the read lock when no thread holds the write lock. It pays off when reads are frequent, writes rare, and the critical section is long enough to outweigh the extra bookkeeping.

Java
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.locks.ReentrantReadWriteLock;

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

    String get(String key) {
        rw.readLock().lock();
        try { return map.get(key); } finally { rw.readLock().unlock(); }
    }

    void put(String key, String value) {
        rw.writeLock().lock();
        try { map.put(key, value); } finally { rw.writeLock().unlock(); }
    }

    public static void main(String[] args) throws InterruptedException {
        RwCache cache = new RwCache();
        cache.put("EURUSD", "1.0850");

        rwDemo(cache);
        System.out.println(cache.get("EURUSD"));
    }

    static void rwDemo(RwCache cache) throws InterruptedException {
        cache.rw.readLock().lock();
        Thread other = new Thread(() -> {
            System.out.println("second reader allowed: " + cache.rw.readLock().tryLock());
            cache.rw.readLock().unlock();
            System.out.println("writer allowed: " + cache.rw.writeLock().tryLock());
        });
        other.start();
        other.join();
        cache.rw.readLock().unlock();

        cache.rw.writeLock().lock();
        cache.rw.readLock().lock();      // downgrade: write -> read is allowed
        cache.rw.writeLock().unlock();
        System.out.println("downgraded, read holds = " + cache.rw.getReadHoldCount());
        cache.rw.readLock().unlock();
    }
}
Output
second reader allowed: true
writer allowed: false
downgraded, read holds = 1
1.0850

A thread holding the write lock may acquire the read lock and then release the write lock (downgrading). The reverse, upgrading from read to write, is not supported: a thread that holds the read lock and calls writeLock().lock() blocks forever, because it waits for all readers to leave, including itself.

Example 5: StampedLock and optimistic reads

StampedLock (Java 8) returns a long stamp from each acquire. Its optimistic read mode takes no lock at all: tryOptimisticRead() returns a stamp, the thread reads fields into locals, and validate(stamp) reports whether a write happened in between. If validation fails, the code falls back to a real read lock.

Java
import java.util.concurrent.locks.StampedLock;

public class Point {
    private double x, y;
    private final StampedLock sl = new StampedLock();

    void move(double dx, double dy) {
        long stamp = sl.writeLock();
        try { x += dx; y += dy; } finally { sl.unlockWrite(stamp); }
    }

    double distanceFromOrigin() {
        long stamp = sl.tryOptimisticRead();
        double cx = x, cy = y;                 // copy into locals
        if (!sl.validate(stamp)) {             // a writer intervened
            stamp = sl.readLock();
            try { cx = x; cy = y; } finally { sl.unlockRead(stamp); }
        }
        return Math.sqrt(cx * cx + cy * cy);
    }

    public static void main(String[] args) {
        Point p = new Point();
        p.move(3, 4);
        System.out.println(p.distanceFromOrigin());

        long s = p.sl.tryOptimisticRead();
        p.move(1, 1);
        System.out.println("valid after write: " + p.sl.validate(s));
    }
}
Output
5.0
valid after write: false

Optimistic reads remove the write to shared lock state that a read lock needs, so readers on different cores do not contend on one cache line. The rules are strict:

  • Read fields into locals and use only the locals after validate succeeds. Values read before validation may be inconsistent.
  • Do not follow references or call methods that can fail on inconsistent data before validating.
  • StampedLock is not reentrant. A thread that calls writeLock() while holding a stamp deadlocks itself.
  • It has no Condition support.

Example 6: AQS, Semaphore, and CountDownLatch

AbstractQueuedSynchronizer (AQS) is the base class behind ReentrantLock, ReentrantReadWriteLock, Semaphore, and CountDownLatch. It holds one volatile int state, changes it with compare-and-set, and keeps a FIFO queue of waiting threads that it parks and unparks with LockSupport. A subclass defines what state means by overriding tryAcquire/tryRelease (exclusive) or tryAcquireShared/tryReleaseShared (shared). For ReentrantLock, state is the hold count; for Semaphore, the number of free permits; for CountDownLatch, the remaining count. StampedLock and Phaser do not use AQS.

A Semaphore holds a number of permits. acquire() takes one or blocks; release() returns one. A CountDownLatch starts at N; countDown() decrements it and await() blocks until it reaches zero. A latch cannot be reset.

Java
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.Semaphore;

public class SemaphoreLatch {
    public static void main(String[] args) throws InterruptedException {
        Semaphore permits = new Semaphore(3);          // at most 3 concurrent "connections"
        CountDownLatch done = new CountDownLatch(10);
        Object stats = new Object();                   // guards active and maxActive
        int[] active = {0}, maxActive = {0};

        for (int i = 0; i < 10; i++) {
            new Thread(() -> {
                try {
                    permits.acquire();
                    try {
                        synchronized (stats) {
                            active[0]++;
                            maxActive[0] = Math.max(maxActive[0], active[0]);
                        }
                        Thread.sleep(10);
                        synchronized (stats) { active[0]--; }
                    } finally {
                        permits.release();
                    }
                } catch (InterruptedException ignored) {
                } finally {
                    done.countDown();
                }
            }).start();
        }
        done.await();
        synchronized (stats) {
            System.out.println("max concurrent <= 3: " + (maxActive[0] <= 3));
        }
        System.out.println("permits left: " + permits.availablePermits());
    }
}
Output
max concurrent <= 3: true
permits left: 3

The program prints a property ("at most 3") instead of the exact maximum, because the exact value depends on scheduling. The counters are one-element arrays because a lambda can capture only effectively final locals; synchronized (stats) makes each update atomic. Atomic Classes and CAS shows a lock-free way to keep such counters.

A semaphore has no owner: any thread may call release(), and releasing more than was acquired raises the permit count above its starting value. That makes it a counter of capacity, not a lock.

Example 7: CyclicBarrier and Phaser for phased computation

A CyclicBarrier makes a fixed number of threads (parties) wait until all of them call await(). It then runs an optional barrier action once and resets for the next round. A Phaser does the same with a party count that can change: threads register() and arriveAndDeregister() at run time, and the phase number advances each round.

Java
import java.util.concurrent.CyclicBarrier;
import java.util.concurrent.Phaser;

public class Phases {
    public static void main(String[] args) throws Exception {
        long[] total = {0};                         // guarded by synchronized (total)
        CyclicBarrier barrier = new CyclicBarrier(4, () -> {
            synchronized (total) { System.out.println("round done, total = " + total[0]); }
        });

        Thread[] workers = new Thread[4];
        for (int w = 0; w < 4; w++) {
            workers[w] = new Thread(() -> {
                try {
                    for (int round = 0; round < 2; round++) {
                        synchronized (total) { total[0] += 10; }
                        barrier.await();
                    }
                } catch (Exception e) { throw new RuntimeException(e); }
            });
            workers[w].start();
        }
        for (Thread t : workers) t.join();

        Phaser phaser = new Phaser(1);              // main is registered
        for (int i = 0; i < 3; i++) {
            phaser.register();
            new Thread(() -> {
                phaser.arriveAndAwaitAdvance();     // phase 0
                phaser.arriveAndDeregister();       // leave during phase 1
            }).start();
        }
        int phase = phaser.arriveAndAwaitAdvance();
        System.out.println("phaser now in phase " + phase);
        phase = phaser.arriveAndAwaitAdvance();
        System.out.println("phaser now in phase " + phase
                + ", parties = " + phaser.getRegisteredParties());
        phaser.arriveAndDeregister();
    }
}
Output
round done, total = 40
round done, total = 80
phaser now in phase 1
phaser now in phase 2, parties = 1

The barrier action runs in the last thread to arrive, before any thread is released, so total is exactly 40 and then 80. If one party is interrupted or times out, the barrier becomes broken and every other waiter gets BrokenBarrierException.

Example 8: Choosing a tool

The table maps the problem to the tool. The criteria are the shape of the coordination (exclusion, counting, waiting for events, waiting for peers) and the read/write ratio.

Need Tool Reason
Short critical section, no extras synchronized Simplest; released automatically
Timeout, interruptible wait, or tryLock ReentrantLock synchronized has none of these
Several wait conditions on one lock ReentrantLock + Condition One queue per condition
Read-mostly, long reads ReentrantReadWriteLock Readers run in parallel
Read-mostly, very short reads, hot path StampedLock optimistic read No shared write on the read path
Limit concurrent access to N Semaphore Permits count capacity
Wait for N one-time events CountDownLatch One-shot, cannot reset
N threads meet each round CyclicBarrier Reusable, barrier action
Parties join and leave between rounds Phaser Dynamic registration
Java
import java.util.concurrent.CountDownLatch;

public class StartGate {
    public static void main(String[] args) throws InterruptedException {
        CountDownLatch startGate = new CountDownLatch(1);
        CountDownLatch endGate = new CountDownLatch(5);
        int[] finished = {0};                      // guarded by synchronized (finished)
        for (int i = 0; i < 5; i++) {
            new Thread(() -> {
                try {
                    startGate.await();             // all start together
                    synchronized (finished) { finished[0]++; }
                } catch (InterruptedException ignored) {
                } finally { endGate.countDown(); }
            }).start();
        }
        startGate.countDown();
        endGate.await();
        synchronized (finished) { System.out.println("finished = " + finished[0]); }
    }
}
Output
finished = 5

This start-gate/end-gate pattern is common in load tests: one latch releases all workers at once, another lets the caller wait until all finish. The same pattern works with a thread pool, covered in Executors and CompletableFuture.

Interview points

  • ReentrantLock adds tryLock, timed and interruptible acquisition, fairness, and multiple Conditions; it must be unlocked in finally.
  • Unfair locks allow barging and give higher throughput; fair locks reduce starvation and cost throughput.
  • Condition.await() must sit in a while loop, like wait().
  • Read-write locks allow downgrade (write to read), not upgrade; an upgrade attempt deadlocks.
  • StampedLock optimistic reads take no lock; validate before using values; the lock is not reentrant and has no conditions.
  • AQS is a volatile int state, CAS, and a FIFO queue of parked threads; subclasses define the meaning of state.
  • A Semaphore has no owner; a CountDownLatch is one-shot; a CyclicBarrier is reusable and breaks if one party fails; a Phaser supports dynamic parties.

Summary

  • ReentrantLock keeps a hold count and an owner thread.
  • Timed tryLock breaks the hold-and-wait deadlock condition.
  • Condition gives one wait queue per predicate on a single lock.
  • ReentrantReadWriteLock permits many readers or one writer.
  • StampedLock.tryOptimisticRead plus validate reads without locking.
  • ReentrantLock, ReentrantReadWriteLock, Semaphore, and CountDownLatch are built on AQS.
  • CountDownLatch waits for events, CyclicBarrier and Phaser wait for peers.